diff --git a/docs/docs.go b/docs/docs.go index 0e20c6b..1e62216 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -810,7 +810,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Returns node labels for the organization in X-Org-ID. Filters: ` + "`" + `name` + "`" + `, ` + "`" + `value` + "`" + `, and ` + "`" + `q` + "`" + ` (name contains). Add ` + "`" + `include=node_pools` + "`" + ` to include linked node groups.", + "description": "Returns node labels for the organization in X-Org-ID. Filters: ` + "`" + `key` + "`" + `, ` + "`" + `value` + "`" + `, and ` + "`" + `q` + "`" + ` (key contains). Add ` + "`" + `include=node_pools` + "`" + ` to include linked node groups.", "consumes": [ "application/json" ], @@ -831,8 +831,8 @@ const docTemplate = `{ }, { "type": "string", - "description": "Exact name", - "name": "name", + "description": "Exact key", + "name": "key", "in": "query" }, { @@ -843,7 +843,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Name contains (case-insensitive)", + "description": "Key contains (case-insensitive)", "name": "q", "in": "query" }, @@ -1180,6 +1180,257 @@ const docTemplate = `{ } } }, + "/api/v1/labels/{id}/node_pools": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns node pools attached to the label. Supports ` + "`" + `q` + "`" + ` (name contains, case-insensitive).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "labels" + ], + "summary": "List node pools linked to a label (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Label ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name contains (case-insensitive)", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/labels.nodePoolBrief" + } + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Links the label to one or more node pools in the same organization.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "labels" + ], + "summary": "Attach label to node pools (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Label ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IDs to attach", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/labels.addLabelToPoolRequest" + } + }, + { + "type": "string", + "description": "Optional: node_pools", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/labels.labelResponse" + } + }, + "400": { + "description": "invalid id / invalid json / invalid node_pool_ids", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "attach failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/labels/{id}/node_pools/{poolId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Unlinks the label from the specified node pool.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "labels" + ], + "summary": "Detach label from a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Label ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "poolId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "detach failed", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/node-pools": { "get": { "security": [ @@ -1545,6 +1796,242 @@ const docTemplate = `{ } } }, + "/api/v1/node-pools/{id}/labels": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "List labels attached to a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/nodepools.labelBrief" + } + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "Attach labels to a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Label IDs to attach", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/nodepools.attachLabelsRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id / invalid label_ids", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "attach failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/node-pools/{id}/labels/{labelId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "Detach one label from a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Label ID (UUID)", + "name": "labelId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "detach failed", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/node-pools/{id}/servers": { "get": { "security": [ @@ -3062,7 +3549,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Returns node taints for the organization in X-Org-ID. Filters: ` + "`" + `name` + "`" + `, ` + "`" + `value` + "`" + `, and ` + "`" + `q` + "`" + ` (name contains). Add ` + "`" + `include=node_groups` + "`" + ` to include linked node groups.", + "description": "Returns node taints for the organization in X-Org-ID. Filters: ` + "`" + `key` + "`" + `, ` + "`" + `value` + "`" + `, and ` + "`" + `q` + "`" + ` (key contains). Add ` + "`" + `include=node_groups` + "`" + ` to include linked node groups.", "consumes": [ "application/json" ], @@ -3083,8 +3570,8 @@ const docTemplate = `{ }, { "type": "string", - "description": "Exact name", - "name": "name", + "description": "Exact key", + "name": "key", "in": "query" }, { @@ -3095,7 +3582,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Name contains (case-insensitive)", + "description": "key contains (case-insensitive)", "name": "q", "in": "query" }, @@ -3433,6 +3920,87 @@ const docTemplate = `{ } }, "/api/v1/taints/{id}/node_pools": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns node pools attached to the taint. Supports ` + "`" + `q` + "`" + ` (name contains, case-insensitive).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "taints" + ], + "summary": "List node pools linked to a taint (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Taint ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name contains (case-insensitive)", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/taints.nodePoolResponse" + } + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, "post": { "security": [ { @@ -3826,6 +4394,17 @@ const docTemplate = `{ "updated_at": {} } }, + "labels.addLabelToPoolRequest": { + "type": "object", + "properties": { + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "labels.createLabelRequest": { "type": "object", "properties": { @@ -4001,6 +4580,17 @@ const docTemplate = `{ } } }, + "nodepools.attachLabelsRequest": { + "type": "object", + "properties": { + "label_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "nodepools.attachServersRequest": { "type": "object", "properties": { @@ -4038,6 +4628,20 @@ const docTemplate = `{ } } }, + "nodepools.labelBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "nodepools.nodePoolResponse": { "type": "object", "properties": { @@ -4325,6 +4929,43 @@ const docTemplate = `{ } } }, + "taints.nodePoolResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/taints.serverBrief" + } + } + } + }, + "taints.serverBrief": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "taints.taintResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 2c1c878..d103dc1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -806,7 +806,7 @@ "BearerAuth": [] } ], - "description": "Returns node labels for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_pools` to include linked node groups.", + "description": "Returns node labels for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node groups.", "consumes": [ "application/json" ], @@ -827,8 +827,8 @@ }, { "type": "string", - "description": "Exact name", - "name": "name", + "description": "Exact key", + "name": "key", "in": "query" }, { @@ -839,7 +839,7 @@ }, { "type": "string", - "description": "Name contains (case-insensitive)", + "description": "Key contains (case-insensitive)", "name": "q", "in": "query" }, @@ -1176,6 +1176,257 @@ } } }, + "/api/v1/labels/{id}/node_pools": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns node pools attached to the label. Supports `q` (name contains, case-insensitive).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "labels" + ], + "summary": "List node pools linked to a label (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Label ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name contains (case-insensitive)", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/labels.nodePoolBrief" + } + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Links the label to one or more node pools in the same organization.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "labels" + ], + "summary": "Attach label to node pools (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Label ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IDs to attach", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/labels.addLabelToPoolRequest" + } + }, + { + "type": "string", + "description": "Optional: node_pools", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/labels.labelResponse" + } + }, + "400": { + "description": "invalid id / invalid json / invalid node_pool_ids", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "attach failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/labels/{id}/node_pools/{poolId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Unlinks the label from the specified node pool.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "labels" + ], + "summary": "Detach label from a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Label ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "poolId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "detach failed", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/node-pools": { "get": { "security": [ @@ -1541,6 +1792,242 @@ } } }, + "/api/v1/node-pools/{id}/labels": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "List labels attached to a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/nodepools.labelBrief" + } + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "Attach labels to a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Label IDs to attach", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/nodepools.attachLabelsRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id / invalid label_ids", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "attach failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/node-pools/{id}/labels/{labelId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "node-pools" + ], + "summary": "Detach one label from a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Node Pool ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Label ID (UUID)", + "name": "labelId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "detach failed", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/node-pools/{id}/servers": { "get": { "security": [ @@ -3058,7 +3545,7 @@ "BearerAuth": [] } ], - "description": "Returns node taints for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_groups` to include linked node groups.", + "description": "Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_groups` to include linked node groups.", "consumes": [ "application/json" ], @@ -3079,8 +3566,8 @@ }, { "type": "string", - "description": "Exact name", - "name": "name", + "description": "Exact key", + "name": "key", "in": "query" }, { @@ -3091,7 +3578,7 @@ }, { "type": "string", - "description": "Name contains (case-insensitive)", + "description": "key contains (case-insensitive)", "name": "q", "in": "query" }, @@ -3429,6 +3916,87 @@ } }, "/api/v1/taints/{id}/node_pools": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns node pools attached to the taint. Supports `q` (name contains, case-insensitive).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "taints" + ], + "summary": "List node pools linked to a taint (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Taint ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name contains (case-insensitive)", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/taints.nodePoolResponse" + } + } + }, + "400": { + "description": "invalid id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, "post": { "security": [ { @@ -3822,6 +4390,17 @@ "updated_at": {} } }, + "labels.addLabelToPoolRequest": { + "type": "object", + "properties": { + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "labels.createLabelRequest": { "type": "object", "properties": { @@ -3997,6 +4576,17 @@ } } }, + "nodepools.attachLabelsRequest": { + "type": "object", + "properties": { + "label_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "nodepools.attachServersRequest": { "type": "object", "properties": { @@ -4034,6 +4624,20 @@ } } }, + "nodepools.labelBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "nodepools.nodePoolResponse": { "type": "object", "properties": { @@ -4321,6 +4925,43 @@ } } }, + "taints.nodePoolResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/taints.serverBrief" + } + } + } + }, + "taints.serverBrief": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "taints.taintResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b1553da..50552e4 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -152,6 +152,13 @@ definitions: type: string updated_at: {} type: object + labels.addLabelToPoolRequest: + properties: + node_pool_ids: + items: + type: string + type: array + type: object labels.createLabelRequest: properties: key: @@ -267,6 +274,13 @@ definitions: updated_at: type: string type: object + nodepools.attachLabelsRequest: + properties: + label_ids: + items: + type: string + type: array + type: object nodepools.attachServersRequest: properties: server_ids: @@ -291,6 +305,15 @@ definitions: type: string type: array type: object + nodepools.labelBrief: + properties: + id: + type: string + key: + type: string + value: + type: string + type: object nodepools.nodePoolResponse: properties: id: @@ -480,6 +503,30 @@ definitions: name: type: string type: object + taints.nodePoolResponse: + properties: + id: + type: string + name: + type: string + servers: + items: + $ref: '#/definitions/taints.serverBrief' + type: array + type: object + taints.serverBrief: + properties: + hostname: + type: string + id: + type: string + ip: + type: string + role: + type: string + status: + type: string + type: object taints.taintResponse: properties: effect: @@ -1019,7 +1066,7 @@ paths: consumes: - application/json description: 'Returns node labels for the organization in X-Org-ID. Filters: - `name`, `value`, and `q` (name contains). Add `include=node_pools` to include + `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node groups.' parameters: - description: Organization UUID @@ -1027,15 +1074,15 @@ paths: name: X-Org-ID required: true type: string - - description: Exact name + - description: Exact key in: query - name: name + name: key type: string - description: Exact value in: query name: value type: string - - description: Name contains (case-insensitive) + - description: Key contains (case-insensitive) in: query name: q type: string @@ -1261,6 +1308,171 @@ paths: summary: Update label (org scoped) tags: - labels + /api/v1/labels/{id}/node_pools: + get: + consumes: + - application/json + description: Returns node pools attached to the label. Supports `q` (name contains, + case-insensitive). + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Label ID (UUID) + in: path + name: id + required: true + type: string + - description: Name contains (case-insensitive) + in: query + name: q + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/labels.nodePoolBrief' + type: array + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: fetch failed + schema: + type: string + security: + - BearerAuth: [] + summary: List node pools linked to a label (org scoped) + tags: + - labels + post: + consumes: + - application/json + description: Links the label to one or more node pools in the same organization. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Label ID (UUID) + in: path + name: id + required: true + type: string + - description: IDs to attach + in: body + name: body + required: true + schema: + $ref: '#/definitions/labels.addLabelToPoolRequest' + - description: 'Optional: node_pools' + in: query + name: include + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/labels.labelResponse' + "400": + description: invalid id / invalid json / invalid node_pool_ids + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: attach failed + schema: + type: string + security: + - BearerAuth: [] + summary: Attach label to node pools (org scoped) + tags: + - labels + /api/v1/labels/{id}/node_pools/{poolId}: + delete: + consumes: + - application/json + description: Unlinks the label from the specified node pool. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Label ID (UUID) + in: path + name: id + required: true + type: string + - description: Node Pool ID (UUID) + in: path + name: poolId + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + type: string + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: detach failed + schema: + type: string + security: + - BearerAuth: [] + summary: Detach label from a node pool (org scoped) + tags: + - labels /api/v1/node-pools: get: consumes: @@ -1499,6 +1711,159 @@ paths: summary: Update node pool (org scoped) tags: - node-pools + /api/v1/node-pools/{id}/labels: + get: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Node Pool ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/nodepools.labelBrief' + type: array + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: fetch failed + schema: + type: string + security: + - BearerAuth: [] + summary: List labels attached to a node pool (org scoped) + tags: + - node-pools + post: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Node Pool ID (UUID) + in: path + name: id + required: true + type: string + - description: Label IDs to attach + in: body + name: body + required: true + schema: + $ref: '#/definitions/nodepools.attachLabelsRequest' + produces: + - application/json + responses: + "204": + description: No Content + schema: + type: string + "400": + description: invalid id / invalid label_ids + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: attach failed + schema: + type: string + security: + - BearerAuth: [] + summary: Attach labels to a node pool (org scoped) + tags: + - node-pools + /api/v1/node-pools/{id}/labels/{labelId}: + delete: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Node Pool ID (UUID) + in: path + name: id + required: true + type: string + - description: Label ID (UUID) + in: path + name: labelId + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + type: string + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: detach failed + schema: + type: string + security: + - BearerAuth: [] + summary: Detach one label from a node pool (org scoped) + tags: + - node-pools /api/v1/node-pools/{id}/servers: get: consumes: @@ -2483,7 +2848,7 @@ paths: consumes: - application/json description: 'Returns node taints for the organization in X-Org-ID. Filters: - `name`, `value`, and `q` (name contains). Add `include=node_groups` to include + `key`, `value`, and `q` (key contains). Add `include=node_groups` to include linked node groups.' parameters: - description: Organization UUID @@ -2491,15 +2856,15 @@ paths: name: X-Org-ID required: true type: string - - description: Exact name + - description: Exact key in: query - name: name + name: key type: string - description: Exact value in: query name: value type: string - - description: Name contains (case-insensitive) + - description: key contains (case-insensitive) in: query name: q type: string @@ -2726,6 +3091,60 @@ paths: tags: - taints /api/v1/taints/{id}/node_pools: + get: + consumes: + - application/json + description: Returns node pools attached to the taint. Supports `q` (name contains, + case-insensitive). + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Taint ID (UUID) + in: path + name: id + required: true + type: string + - description: Name contains (case-insensitive) + in: query + name: q + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/taints.nodePoolResponse' + type: array + "400": + description: invalid id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: not found + schema: + type: string + "500": + description: fetch failed + schema: + type: string + security: + - BearerAuth: [] + summary: List node pools linked to a taint (org scoped) + tags: + - taints post: consumes: - application/json diff --git a/internal/api/routes.go b/internal/api/routes.go index 9a7e9e1..0dffbaf 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -100,6 +100,11 @@ func RegisterRoutes(r chi.Router) { np.Get("/{id}/taints", nodepools.ListNodePoolTaints) np.Post("/{id}/taints", nodepools.AttachNodePoolTaints) np.Delete("/{id}/taints/{taintId}", nodepools.DetachNodePoolTaint) + + // labels + np.Get("/{id}/labels", nodepools.ListNodePoolLabels) + np.Post("/{id}/labels", nodepools.AttachNodePoolLabels) + np.Delete("/{id}/labels/{labelId}", nodepools.DetachNodePoolLabel) }) v1.Route("/taints", func(t chi.Router) { @@ -110,6 +115,7 @@ func RegisterRoutes(r chi.Router) { t.Patch("/{id}", taints.UpdateTaint) t.Delete("/{id}", taints.DeleteTaint) t.Post("/{id}/node_pools", taints.AddTaintToNodePool) + t.Get("/{id}/node_pools", taints.ListNodePoolsWithTaint) t.Delete("/{id}/node_pools/{poolId}", taints.RemoveTaintFromNodePool) }) @@ -120,6 +126,9 @@ func RegisterRoutes(r chi.Router) { l.Get("/{id}", labels.GetLabel) l.Patch("/{id}", labels.UpdateLabel) l.Delete("/{id}", labels.DeleteLabel) + l.Get("/{id}/node_pools", labels.ListNodePoolsWithLabel) + l.Post("/{id}/node_pools", labels.AddLabelToNodePool) + l.Delete("/{id}/node_pools/{poolId}", labels.RemoveLabelFromNodePool) }) }) }) diff --git a/internal/handlers/labels/dto.go b/internal/handlers/labels/dto.go index 01755da..9b500ac 100644 --- a/internal/handlers/labels/dto.go +++ b/internal/handlers/labels/dto.go @@ -24,3 +24,7 @@ type updateLabelRequest struct { Key *string `json:"key"` Value *string `json:"value"` } + +type addLabelToPoolRequest struct { + NodePoolIDs []string `json:"node_pool_ids"` +} diff --git a/internal/handlers/labels/labels.go b/internal/handlers/labels/labels.go index eab46c1..f8f3944 100644 --- a/internal/handlers/labels/labels.go +++ b/internal/handlers/labels/labels.go @@ -17,14 +17,14 @@ import ( // ListLabels godoc // @Summary List node labels (org scoped) -// @Description Returns node labels for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_pools` to include linked node groups. +// @Description Returns node labels for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node groups. // @Tags labels // @Accept json // @Produce json // @Param X-Org-ID header string true "Organization UUID" -// @Param name query string false "Exact name" +// @Param key query string false "Exact key" // @Param value query string false "Exact value" -// @Param q query string false "Name contains (case-insensitive)" +// @Param q query string false "Key contains (case-insensitive)" // @Param include query string false "Optional: node_pools" // @Security BearerAuth // @Success 200 {array} labelResponse @@ -39,19 +39,25 @@ func ListLabels(w http.ResponseWriter, r *http.Request) { return } - q := db.DB.Where("organization_id = ?", ac.OrganizationID) + qb := db.DB.Where("organization_id = ?", ac.OrganizationID) + if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" { + qb = qb.Where(`"key" = ?`, key) + } + if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" { + qb = qb.Where(`"value" = ?`, val) + } if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { - q = q.Where("name ILIKE ?", "%"+needle+"%") + qb = qb.Where("name ILIKE ?", "%"+needle+"%") } includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools") if includePools { - q.Preload("NodePools") + qb.Preload("NodePools") } var rows []models.Label - if err := q.Order("created_at DESC").Find(&rows).Error; err != nil { - http.Error(w, "failed to list taints", http.StatusInternalServerError) + if err := qb.Order("created_at DESC").Find(&rows).Error; err != nil { + http.Error(w, "failed to list labels", http.StatusInternalServerError) return } @@ -264,10 +270,225 @@ func DeleteLabel(w http.ResponseWriter, r *http.Request) { return } - if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).Delete(&models.Taint{}).Error; err != nil { + if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).Delete(&models.Label{}).Error; err != nil { http.Error(w, "delete failed", http.StatusInternalServerError) return } response.NoContent(w) } + +// ListNodePoolsWithLabel godoc +// @Summary List node pools linked to a label (org scoped) +// @Description Returns node pools attached to the label. Supports `q` (name contains, case-insensitive). +// @Tags labels +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Label ID (UUID)" +// @Param q query string false "Name contains (case-insensitive)" +// @Security BearerAuth +// @Success 200 {array} nodePoolBrief +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "fetch failed" +// @Router /api/v1/labels/{id}/node_pools [get] +func ListNodePoolsWithLabel(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + labelID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + // Ensure the label exists and belongs to this org + var l models.Label + if err := db.DB.Where("id = ? AND organization_id = ?", labelID, ac.OrganizationID). + First(&l).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + // Query pools through the join table defined on the model: many2many:node_labels + q := db.DB.Model(&models.NodePool{}). + Joins("JOIN node_labels nl ON nl.node_pool_id = node_pools.id"). + Where("nl.label_id = ? AND node_pools.organization_id = ?", labelID, ac.OrganizationID) + + if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { + q = q.Where("node_pools.name ILIKE ?", "%"+needle+"%") + } + + var pools []models.NodePool + if err := q.Order("node_pools.created_at DESC").Find(&pools).Error; err != nil { + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + out := make([]nodePoolBrief, 0, len(pools)) + for _, p := range pools { + out = append(out, nodePoolBrief{ID: p.ID, Name: p.Name}) + } + + _ = response.JSON(w, http.StatusOK, out) +} + +// AddLabelToNodePool godoc +// @Summary Attach label to node pools (org scoped) +// @Description Links the label to one or more node pools in the same organization. +// @Tags labels +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Label ID (UUID)" +// @Param body body addLabelToPoolRequest true "IDs to attach" +// @Param include query string false "Optional: node_pools" +// @Security BearerAuth +// @Success 200 {object} labelResponse +// @Failure 400 {string} string "invalid id / invalid json / invalid node_pool_ids" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "attach failed" +// @Router /api/v1/labels/{id}/node_pools [post] +func AddLabelToNodePool(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + labelID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var t models.Label + if err := db.DB. + Where("id = ? AND organization_id = ?", labelID, ac.OrganizationID). + First(&t).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + var in struct { + NodePoolIDs []string `json:"node_pool_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || len(in.NodePoolIDs) == 0 { + http.Error(w, "invalid json or empty node_pool_ids", http.StatusBadRequest) + return + } + + ids, err := parseUUIDs(in.NodePoolIDs) + if err != nil { + http.Error(w, "invalid node_pool_ids", http.StatusBadRequest) + return + } + if err := ensureNodePoolsBelongToOrg(ac.OrganizationID, ids); err != nil { + http.Error(w, "invalid node_pool_ids for this organization", http.StatusBadRequest) + return + } + + var pools []models.NodePool + if err := db.DB. + Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID). + Find(&pools).Error; err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + if err := db.DB.Model(&t).Association("NodePools").Append(&pools); err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + + includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools") + if includePools { + if err := db.DB.Preload("NodePools"). + First(&t, "id = ? AND organization_id = ?", labelID, ac.OrganizationID).Error; err != nil { + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + } + + _ = response.JSON(w, http.StatusOK, toResp(t, includePools)) +} + +// RemoveLabelFromNodePool godoc +// @Summary Detach label from a node pool (org scoped) +// @Description Unlinks the label from the specified node pool. +// @Tags labels +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Label ID (UUID)" +// @Param poolId path string true "Node Pool ID (UUID)" +// @Security BearerAuth +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "detach failed" +// @Router /api/v1/labels/{id}/node_pools/{poolId} [delete] +func RemoveLabelFromNodePool(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + labelID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + poolID, err := uuid.Parse(chi.URLParam(r, "poolId")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var t models.Label + if err := db.DB.Where("id = ? AND organization_id = ?", labelID, ac.OrganizationID). + First(&t).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + var p models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", poolID, ac.OrganizationID). + First(&p).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + if err := db.DB.Model(&t).Association("NodePools").Delete(&p); err != nil { + http.Error(w, "detach failed", http.StatusInternalServerError) + return + } + + response.NoContent(w) +} diff --git a/internal/handlers/nodepools/dto.go b/internal/handlers/nodepools/dto.go index 00979db..e66e30f 100644 --- a/internal/handlers/nodepools/dto.go +++ b/internal/handlers/nodepools/dto.go @@ -41,3 +41,23 @@ type taintBrief struct { type attachTaintsRequest struct { TaintIDs []string `json:"taint_ids"` } + +type labelBrief struct { + ID uuid.UUID `json:"id"` + Key string `json:"key"` + Value string `json:"value"` +} + +type attachLabelsRequest struct { + LabelIDs []string `json:"label_ids"` +} + +type annotationBrief struct { + ID uuid.UUID `json:"id"` + Key string `json:"key"` + Value string `json:"value"` +} + +type attachAnnotationsRequest struct { + AnnotationIDs []string `json:"annotation_ids"` +} diff --git a/internal/handlers/nodepools/funcs.go b/internal/handlers/nodepools/funcs.go index eb98034..6b40877 100644 --- a/internal/handlers/nodepools/funcs.go +++ b/internal/handlers/nodepools/funcs.go @@ -70,3 +70,35 @@ func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { } return nil } + +func ensureLabelsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { + if len(ids) == 0 { + return nil + } + var cnt int64 + if err := db.DB.Model(&models.Label{}). + Where("organization_id = ? AND id IN ?", orgID, ids). + Count(&cnt).Error; err != nil { + return err + } + if cnt != int64(len(ids)) { + return errors.New("one or more labels not in organization") + } + return nil +} + +func ensureAnnotationsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { + if len(ids) == 0 { + return nil + } + var cnt int64 + if err := db.DB.Model(&models.Annotation{}). + Where("organization_id = ? AND id IN ?", orgID, ids). + Count(&cnt).Error; err != nil { + return err + } + if cnt != int64(len(ids)) { + return errors.New("one or more annotations not in organization") + } + return nil +} diff --git a/internal/handlers/nodepools/nodepools.go b/internal/handlers/nodepools/nodepools.go index dc1b2fd..39236e9 100644 --- a/internal/handlers/nodepools/nodepools.go +++ b/internal/handlers/nodepools/nodepools.go @@ -624,3 +624,185 @@ func DetachNodePoolTaint(w http.ResponseWriter, r *http.Request) { } response.NoContent(w) } + +// ListNodePoolLabels godoc +// @Summary List labels attached to a node pool (org scoped) +// @Tags node-pools +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Node Pool ID (UUID)" +// @Security BearerAuth +// @Success 200 {array} labelBrief +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "fetch failed" +// @Router /api/v1/node-pools/{id}/labels [get] +func ListNodePoolLabels(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + ngID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var ng models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID). + Preload("Labels").First(&ng).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + out := make([]labelBrief, 0, len(ng.Labels)) + for _, l := range ng.Labels { + out = append(out, labelBrief{ + ID: l.ID, + Key: l.Key, + Value: l.Value, + }) + } + _ = response.JSON(w, http.StatusOK, out) +} + +// AttachNodePoolLabels godoc +// @Summary Attach labels to a node pool (org scoped) +// @Tags node-pools +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Node Pool ID (UUID)" +// @Param body body attachLabelsRequest true "Label IDs to attach" +// @Security BearerAuth +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "invalid id / invalid label_ids" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "attach failed" +// @Router /api/v1/node-pools/{id}/labels [post] +func AttachNodePoolLabels(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + ngID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var ng models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID). + First(&ng).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + var body attachLabelsRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.LabelIDs) == 0 { + http.Error(w, "invalid label_ids", http.StatusBadRequest) + return + } + + ids, err := parseUUIDs(body.LabelIDs) // already used in this package for servers/taints + if err != nil { + http.Error(w, "invalid label_ids", http.StatusBadRequest) + return + } + if err := ensureLabelsBelongToOrg(ac.OrganizationID, ids); err != nil { + http.Error(w, "invalid label_ids for this organization", http.StatusBadRequest) + return + } + + var labels []models.Label + if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID). + Find(&labels).Error; err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + if err := db.DB.Model(&ng).Association("Labels").Append(&labels); err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// DetachNodePoolLabel godoc +// @Summary Detach one label from a node pool (org scoped) +// @Tags node-pools +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Node Pool ID (UUID)" +// @Param labelId path string true "Label ID (UUID)" +// @Security BearerAuth +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "detach failed" +// @Router /api/v1/node-pools/{id}/labels/{labelId} [delete] +func DetachNodePoolLabel(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + ngID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + lid, err := uuid.Parse(chi.URLParam(r, "labelId")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var ng models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", ngID, ac.OrganizationID). + First(&ng).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + var l models.Label + if err := db.DB.Where("id = ? AND organization_id = ?", lid, ac.OrganizationID). + First(&l).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + if err := db.DB.Model(&ng).Association("Labels").Delete(&l); err != nil { + http.Error(w, "detach failed", http.StatusInternalServerError) + return + } + response.NoContent(w) +} diff --git a/internal/handlers/taints/dto.go b/internal/handlers/taints/dto.go index 7471b1c..1a14d3a 100644 --- a/internal/handlers/taints/dto.go +++ b/internal/handlers/taints/dto.go @@ -31,3 +31,17 @@ type updateTaintRequest struct { type addTaintToPoolRequest struct { NodePoolIDs []string `json:"node_pool_ids"` } + +type nodePoolResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Servers []serverBrief `json:"servers,omitempty"` +} + +type serverBrief struct { + ID uuid.UUID `json:"id"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + Role string `json:"role"` + Status string `json:"status"` +} diff --git a/internal/handlers/taints/taints.go b/internal/handlers/taints/taints.go index 7bfeee7..67f284c 100644 --- a/internal/handlers/taints/taints.go +++ b/internal/handlers/taints/taints.go @@ -17,14 +17,14 @@ import ( // ListTaints godoc // @Summary List node taints (org scoped) -// @Description Returns node taints for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_groups` to include linked node groups. +// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_groups` to include linked node groups. // @Tags taints // @Accept json // @Produce json // @Param X-Org-ID header string true "Organization UUID" -// @Param name query string false "Exact name" +// @Param key query string false "Exact key" // @Param value query string false "Exact value" -// @Param q query string false "Name contains (case-insensitive)" +// @Param q query string false "key contains (case-insensitive)" // @Param include query string false "Optional: node_pools" // @Security BearerAuth // @Success 200 {array} taintResponse @@ -40,6 +40,12 @@ func ListTaints(w http.ResponseWriter, r *http.Request) { } q := db.DB.Where("organization_id = ?", ac.OrganizationID) + if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" { + q = q.Where("key = ?", key) + } + if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" { + q = q.Where("value = ?", val) + } if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { q = q.Where("name ILIKE ?", "%"+needle+"%") } @@ -423,3 +429,67 @@ func RemoveTaintFromNodePool(w http.ResponseWriter, r *http.Request) { response.NoContent(w) } + +// ListNodePoolsWithTaint godoc +// @Summary List node pools linked to a taint (org scoped) +// @Description Returns node pools attached to the taint. Supports `q` (name contains, case-insensitive). +// @Tags taints +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Taint ID (UUID)" +// @Param q query string false "Name contains (case-insensitive)" +// @Security BearerAuth +// @Success 200 {array} nodePoolResponse +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "fetch failed" +// @Router /api/v1/taints/{id}/node_pools [get] +func ListNodePoolsWithTaint(w http.ResponseWriter, r *http.Request) { + ac := middleware.GetAuthContext(r) + if ac == nil || ac.OrganizationID == uuid.Nil { + http.Error(w, "organization required", http.StatusForbidden) + return + } + + taintID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + // Ensure the taint exists and belongs to this org + var t models.Taint + if err := db.DB.Where("id = ? AND organization_id = ?", taintID, ac.OrganizationID). + First(&t).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + // Build query for pools linked via join table + q := db.DB.Model(&models.NodePool{}). + Joins("JOIN taint_node_pools tnp ON tnp.node_pool_id = node_pools.id"). + Where("tnp.taint_id = ? AND node_pools.organization_id = ?", taintID, ac.OrganizationID) + + if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { + q = q.Where("node_pools.name ILIKE ?", "%"+needle+"%") + } + + var pools []models.NodePool + if err := q.Order("node_pools.created_at DESC").Find(&pools).Error; err != nil { + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + // If you have a serializer like toNodePoolResp, use it; otherwise return models with JSON tags. + //out := make([]nodePoolResponse, 0, len(pools)) + //for _, p := range pools { out = append(out, toNodePoolResp(p)) } + + _ = response.JSON(w, http.StatusOK, pools) +} diff --git a/ui/src/pages/core/labels-page.tsx b/ui/src/pages/core/labels-page.tsx index 9647854..8daa834 100644 --- a/ui/src/pages/core/labels-page.tsx +++ b/ui/src/pages/core/labels-page.tsx @@ -1,11 +1,13 @@ -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" -import { PencilIcon, Plus, TrashIcon } from "lucide-react" +import { LinkIcon, PencilIcon, Plus, TrashIcon, UnlinkIcon } from "lucide-react" import { useForm } from "react-hook-form" import { z } from "zod" -import { api } from "@/lib/api.ts" +import { api, ApiError } from "@/lib/api.ts" +import { Badge } from "@/components/ui/badge.tsx" import { Button } from "@/components/ui/button.tsx" +import { Checkbox } from "@/components/ui/checkbox.tsx" import { Dialog, DialogContent, @@ -32,34 +34,89 @@ import { TableRow, } from "@/components/ui/table.tsx" +type NodePoolBrief = { + id: string + name: string +} + type Label = { id: string key: string value: string + node_pools?: NodePoolBrief[] // normalized from API's "node_groups" } const CreateLabelSchema = z.object({ - key: z.string().min(2), - value: z.string().min(2), + key: z.string().trim().min(2, "Key is too short"), + value: z.string().trim().min(2, "Value is too short"), }) type CreateLabelValues = z.infer +const UpdateLabelSchema = z + .object({ + key: z.string().trim().min(2, "Key is too short").optional(), + value: z.string().trim().min(2, "Value is too short").optional(), + }) + .refine((v) => v.key !== undefined || v.value !== undefined, { + message: "Provide a new key or value", + path: ["key"], + }) +type UpdateLabelValues = z.infer + +const AttachPoolsSchema = z.object({ + node_pool_ids: z.array(z.string().uuid()).min(1, "Pick at least one node pool"), +}) +type AttachPoolsValues = z.infer + +function truncateMiddle(str: string, keep = 8) { + if (!str || str.length <= keep * 2 + 3) return str + return `${str.slice(0, keep)}…${str.slice(-keep)}` +} + export const LabelsPage = () => { const [labels, setLabels] = useState([]) - const [loading, setLoading] = useState(false) + const [allPools, setAllPools] = useState([]) + const [loading, setLoading] = useState(false) const [err, setErr] = useState(null) const [createOpen, setCreateOpen] = useState(false) + const [editOpen, setEditOpen] = useState(false) + const [editTarget, setEditTarget] = useState