From 2f432239b8e00b87da34de387b42d3d753ae2c0e Mon Sep 17 00:00:00 2001 From: allanice001 Date: Tue, 16 Sep 2025 22:26:53 +0100 Subject: [PATCH] most of the working app --- atlas.hcl | 16 + docs/docs.go | 1022 +++++++++++++++++- docs/swagger.json | 1022 +++++++++++++++++- docs/swagger.yaml | 670 +++++++++++- internal/api/routes.go | 19 + internal/db/models/annotation.go | 2 +- internal/db/models/cluster.go | 4 + internal/handlers/annotations/annotations.go | 18 +- internal/handlers/clusters/clusters.go | 680 ++++++++++++ internal/handlers/clusters/dto.go | 87 ++ internal/handlers/clusters/funcs.go | 191 ++++ internal/handlers/nodepools/dto.go | 2 +- internal/handlers/nodepools/nodepools.go | 2 +- ui/src/components/ui/textarea.tsx | 18 + ui/src/pages/core/annotations-page.tsx | 34 +- ui/src/pages/core/clusters-page.tsx | 960 +++++++++++++++- 16 files changed, 4698 insertions(+), 49 deletions(-) create mode 100644 atlas.hcl create mode 100644 internal/handlers/clusters/clusters.go create mode 100644 internal/handlers/clusters/dto.go create mode 100644 internal/handlers/clusters/funcs.go create mode 100644 ui/src/components/ui/textarea.tsx diff --git a/atlas.hcl b/atlas.hcl new file mode 100644 index 0000000..86a6304 --- /dev/null +++ b/atlas.hcl @@ -0,0 +1,16 @@ +data "external_schema" "gorm" { + program = [ + "go", + "run", + "-mod=mod", + "ariga.io/atlas-provider-gorm", + "load", + "--path", "./models", + "--dialect", "postgresql", + ] +} + +env "gorm" { + src = data.external_schema.gorm.url + dev = "postgresql://dev" +} \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index dad3aff..f1b0e94 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1424,6 +1424,805 @@ const docTemplate = `{ } } }, + "/api/v1/clusters": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns clusters for the organization in X-Org-ID. Add ` + "`" + `include=node_pools,bastion` + "`" + ` to expand. Filter by ` + "`" + `q` + "`" + ` (name contains).", + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "List clusters (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Name contains (case-insensitive)", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "Optional: node_pools,bastion", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.clusterResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to list clusters", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a cluster and optionally links node pools and a bastion server. If ` + "`" + `kubeconfig` + "`" + ` is provided, it will be encrypted per-organization and stored securely (never returned).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Create cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "description": "payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/clusters.createClusterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/clusters.clusterResponse" + } + }, + "400": { + "description": "invalid json / invalid node_pool_ids / invalid bastion_server_id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "500": { + "description": "create failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/clusters/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns one cluster. Add ` + "`" + `include=node_pools,bastion` + "`" + ` to expand.", + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Get cluster by ID (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Optional: node_pools,bastion", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/clusters.clusterResponse" + } + }, + "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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "clusters" + ], + "summary": "Delete cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "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" + } + }, + "500": { + "description": "delete failed", + "schema": { + "type": "string" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Update cluster (org scoped). If ` + "`" + `kubeconfig` + "`" + ` is provided and non-empty, it will be encrypted per-organization and stored (never returned). Sending an empty string for ` + "`" + `kubeconfig` + "`" + ` is ignored (no change).", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/clusters.updateClusterRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/clusters.clusterResponse" + } + }, + "400": { + "description": "invalid id / invalid json / invalid bastion_server_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": "update failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/clusters/{id}/bastion": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Get cluster bastion (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/clusters.serverBrief" + } + }, + "204": { + "description": "No Content (no bastion set)", + "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": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Set/replace cluster bastion (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "server_id with role=bastion", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/clusters.setBastionRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id / invalid server_id / server not bastion", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "cluster or server not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "update failed", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "clusters" + ], + "summary": "Clear cluster bastion (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "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": "update failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/clusters/{id}/node_pools": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "List node pools attached to a cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster 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/clusters.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": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Attach node pools to cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "node_pool_ids", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/clusters.attachNodePoolsRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id / 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/clusters/{id}/node_pools/{poolId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "clusters" + ], + "summary": "Detach one node pool from a cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster 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/labels": { "get": { "security": [ @@ -5046,7 +5845,7 @@ const docTemplate = `{ "id": { "type": "string" }, - "name": { + "key": { "type": "string" }, "node_pools": { @@ -5063,7 +5862,7 @@ const docTemplate = `{ "annotations.createAnnotationRequest": { "type": "object", "properties": { - "name": { + "key": { "type": "string" }, "node_pool_ids": { @@ -5091,7 +5890,7 @@ const docTemplate = `{ "annotations.updateAnnotationRequest": { "type": "object", "properties": { - "name": { + "key": { "type": "string" }, "value": { @@ -5321,6 +6120,221 @@ const docTemplate = `{ "updated_at": {} } }, + "clusters.annotationBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "clusters.attachNodePoolsRequest": { + "type": "object", + "properties": { + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "clusters.clusterResponse": { + "type": "object", + "properties": { + "bastion_server": { + "$ref": "#/definitions/clusters.serverBrief" + }, + "cluster_load_balancer": { + "type": "string" + }, + "control_load_balancer": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_pools": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.nodePoolBrief" + } + }, + "provider": { + "type": "string" + }, + "region": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "clusters.createClusterRequest": { + "type": "object", + "properties": { + "bastion_server_id": { + "type": "string" + }, + "cluster_load_balancer": { + "type": "string" + }, + "control_load_balancer": { + "type": "string" + }, + "kubeconfig": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider": { + "type": "string" + }, + "region": { + "type": "string" + } + } + }, + "clusters.labelBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "clusters.nodePoolBrief": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.annotationBrief" + } + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.labelBrief" + } + }, + "name": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.serverBrief" + } + }, + "taints": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.taintBrief" + } + } + } + }, + "clusters.serverBrief": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "clusters.setBastionRequest": { + "type": "object", + "properties": { + "server_id": { + "type": "string" + } + } + }, + "clusters.taintBrief": { + "type": "object", + "properties": { + "effect": { + "type": "string" + }, + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "clusters.updateClusterRequest": { + "type": "object", + "properties": { + "bastion_server_id": { + "type": "string" + }, + "cluster_load_balancer": { + "type": "string" + }, + "control_load_balancer": { + "type": "string" + }, + "kubeconfig": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "region": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "labels.addLabelToPoolRequest": { "type": "object", "properties": { @@ -5513,7 +6527,7 @@ const docTemplate = `{ "id": { "type": "string" }, - "name": { + "key": { "type": "string" }, "value": { diff --git a/docs/swagger.json b/docs/swagger.json index 7910ecb..52006d2 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1420,6 +1420,805 @@ } } }, + "/api/v1/clusters": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns clusters for the organization in X-Org-ID. Add `include=node_pools,bastion` to expand. Filter by `q` (name contains).", + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "List clusters (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Name contains (case-insensitive)", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "Optional: node_pools,bastion", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.clusterResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to list clusters", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a cluster and optionally links node pools and a bastion server. If `kubeconfig` is provided, it will be encrypted per-organization and stored securely (never returned).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Create cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "description": "payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/clusters.createClusterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/clusters.clusterResponse" + } + }, + "400": { + "description": "invalid json / invalid node_pool_ids / invalid bastion_server_id", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "500": { + "description": "create failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/clusters/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns one cluster. Add `include=node_pools,bastion` to expand.", + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Get cluster by ID (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Optional: node_pools,bastion", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/clusters.clusterResponse" + } + }, + "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" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "clusters" + ], + "summary": "Delete cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "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" + } + }, + "500": { + "description": "delete failed", + "schema": { + "type": "string" + } + } + } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Update cluster (org scoped). If `kubeconfig` is provided and non-empty, it will be encrypted per-organization and stored (never returned). Sending an empty string for `kubeconfig` is ignored (no change).", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/clusters.updateClusterRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/clusters.clusterResponse" + } + }, + "400": { + "description": "invalid id / invalid json / invalid bastion_server_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": "update failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/clusters/{id}/bastion": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Get cluster bastion (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/clusters.serverBrief" + } + }, + "204": { + "description": "No Content (no bastion set)", + "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": "fetch failed", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Set/replace cluster bastion (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "server_id with role=bastion", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/clusters.setBastionRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id / invalid server_id / server not bastion", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "404": { + "description": "cluster or server not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "update failed", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "clusters" + ], + "summary": "Clear cluster bastion (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "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": "update failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/clusters/{id}/node_pools": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "List node pools attached to a cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster 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/clusters.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": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "clusters" + ], + "summary": "Attach node pools to cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "node_pool_ids", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/clusters.attachNodePoolsRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "invalid id / 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/clusters/{id}/node_pools/{poolId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "clusters" + ], + "summary": "Detach one node pool from a cluster (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Cluster 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/labels": { "get": { "security": [ @@ -5042,7 +5841,7 @@ "id": { "type": "string" }, - "name": { + "key": { "type": "string" }, "node_pools": { @@ -5059,7 +5858,7 @@ "annotations.createAnnotationRequest": { "type": "object", "properties": { - "name": { + "key": { "type": "string" }, "node_pool_ids": { @@ -5087,7 +5886,7 @@ "annotations.updateAnnotationRequest": { "type": "object", "properties": { - "name": { + "key": { "type": "string" }, "value": { @@ -5317,6 +6116,221 @@ "updated_at": {} } }, + "clusters.annotationBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "clusters.attachNodePoolsRequest": { + "type": "object", + "properties": { + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "clusters.clusterResponse": { + "type": "object", + "properties": { + "bastion_server": { + "$ref": "#/definitions/clusters.serverBrief" + }, + "cluster_load_balancer": { + "type": "string" + }, + "control_load_balancer": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_pools": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.nodePoolBrief" + } + }, + "provider": { + "type": "string" + }, + "region": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "clusters.createClusterRequest": { + "type": "object", + "properties": { + "bastion_server_id": { + "type": "string" + }, + "cluster_load_balancer": { + "type": "string" + }, + "control_load_balancer": { + "type": "string" + }, + "kubeconfig": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider": { + "type": "string" + }, + "region": { + "type": "string" + } + } + }, + "clusters.labelBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "clusters.nodePoolBrief": { + "type": "object", + "properties": { + "annotations": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.annotationBrief" + } + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.labelBrief" + } + }, + "name": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.serverBrief" + } + }, + "taints": { + "type": "array", + "items": { + "$ref": "#/definitions/clusters.taintBrief" + } + } + } + }, + "clusters.serverBrief": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "clusters.setBastionRequest": { + "type": "object", + "properties": { + "server_id": { + "type": "string" + } + } + }, + "clusters.taintBrief": { + "type": "object", + "properties": { + "effect": { + "type": "string" + }, + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "clusters.updateClusterRequest": { + "type": "object", + "properties": { + "bastion_server_id": { + "type": "string" + }, + "cluster_load_balancer": { + "type": "string" + }, + "control_load_balancer": { + "type": "string" + }, + "kubeconfig": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "region": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "labels.addLabelToPoolRequest": { "type": "object", "properties": { @@ -5509,7 +6523,7 @@ "id": { "type": "string" }, - "name": { + "key": { "type": "string" }, "value": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5bc372a..d698377 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -11,7 +11,7 @@ definitions: properties: id: type: string - name: + key: type: string node_pools: items: @@ -22,7 +22,7 @@ definitions: type: object annotations.createAnnotationRequest: properties: - name: + key: type: string node_pool_ids: items: @@ -40,7 +40,7 @@ definitions: type: object annotations.updateAnnotationRequest: properties: - name: + key: type: string value: type: string @@ -197,6 +197,146 @@ definitions: type: string updated_at: {} type: object + clusters.annotationBrief: + properties: + id: + type: string + key: + type: string + value: + type: string + type: object + clusters.attachNodePoolsRequest: + properties: + node_pool_ids: + items: + type: string + type: array + type: object + clusters.clusterResponse: + properties: + bastion_server: + $ref: '#/definitions/clusters.serverBrief' + cluster_load_balancer: + type: string + control_load_balancer: + type: string + id: + type: string + name: + type: string + node_pools: + items: + $ref: '#/definitions/clusters.nodePoolBrief' + type: array + provider: + type: string + region: + type: string + status: + type: string + type: object + clusters.createClusterRequest: + properties: + bastion_server_id: + type: string + cluster_load_balancer: + type: string + control_load_balancer: + type: string + kubeconfig: + type: string + name: + type: string + node_pool_ids: + items: + type: string + type: array + provider: + type: string + region: + type: string + type: object + clusters.labelBrief: + properties: + id: + type: string + key: + type: string + value: + type: string + type: object + clusters.nodePoolBrief: + properties: + annotations: + items: + $ref: '#/definitions/clusters.annotationBrief' + type: array + id: + type: string + labels: + items: + $ref: '#/definitions/clusters.labelBrief' + type: array + name: + type: string + servers: + items: + $ref: '#/definitions/clusters.serverBrief' + type: array + taints: + items: + $ref: '#/definitions/clusters.taintBrief' + type: array + type: object + clusters.serverBrief: + properties: + hostname: + type: string + id: + type: string + ip: + type: string + role: + type: string + status: + type: string + type: object + clusters.setBastionRequest: + properties: + server_id: + type: string + type: object + clusters.taintBrief: + properties: + effect: + type: string + id: + type: string + key: + type: string + value: + type: string + type: object + clusters.updateClusterRequest: + properties: + bastion_server_id: + type: string + cluster_load_balancer: + type: string + control_load_balancer: + type: string + kubeconfig: + type: string + name: + type: string + provider: + type: string + region: + type: string + status: + type: string + type: object labels.addLabelToPoolRequest: properties: node_pool_ids: @@ -323,7 +463,7 @@ definitions: properties: id: type: string - name: + key: type: string value: type: string @@ -1529,6 +1669,528 @@ paths: summary: Resend email verification tags: - auth + /api/v1/clusters: + get: + description: Returns clusters for the organization in X-Org-ID. Add `include=node_pools,bastion` + to expand. Filter by `q` (name contains). + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Name contains (case-insensitive) + in: query + name: q + type: string + - description: 'Optional: node_pools,bastion' + in: query + name: include + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/clusters.clusterResponse' + type: array + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "500": + description: failed to list clusters + schema: + type: string + security: + - BearerAuth: [] + summary: List clusters (org scoped) + tags: + - clusters + post: + consumes: + - application/json + description: Creates a cluster and optionally links node pools and a bastion + server. If `kubeconfig` is provided, it will be encrypted per-organization + and stored securely (never returned). + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/clusters.createClusterRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/clusters.clusterResponse' + "400": + description: invalid json / invalid node_pool_ids / invalid bastion_server_id + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "500": + description: create failed + schema: + type: string + security: + - BearerAuth: [] + summary: Create cluster (org scoped) + tags: + - clusters + /api/v1/clusters/{id}: + delete: + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster ID (UUID) + in: path + name: id + required: true + type: string + 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 + "500": + description: delete failed + schema: + type: string + security: + - BearerAuth: [] + summary: Delete cluster (org scoped) + tags: + - clusters + get: + description: Returns one cluster. Add `include=node_pools,bastion` to expand. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster ID (UUID) + in: path + name: id + required: true + type: string + - description: 'Optional: node_pools,bastion' + in: query + name: include + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/clusters.clusterResponse' + "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: Get cluster by ID (org scoped) + tags: + - clusters + patch: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster ID (UUID) + in: path + name: id + required: true + type: string + - description: payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/clusters.updateClusterRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/clusters.clusterResponse' + "400": + description: invalid id / invalid json / invalid bastion_server_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: update failed + schema: + type: string + security: + - BearerAuth: [] + summary: Update cluster (org scoped). If `kubeconfig` is provided and non-empty, + it will be encrypted per-organization and stored (never returned). Sending + an empty string for `kubeconfig` is ignored (no change). + tags: + - clusters + /api/v1/clusters/{id}/bastion: + delete: + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster ID (UUID) + in: path + name: id + required: true + type: string + 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: update failed + schema: + type: string + security: + - BearerAuth: [] + summary: Clear cluster bastion (org scoped) + tags: + - clusters + get: + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/clusters.serverBrief' + "204": + description: No Content (no bastion set) + 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: fetch failed + schema: + type: string + security: + - BearerAuth: [] + summary: Get cluster bastion (org scoped) + tags: + - clusters + post: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster ID (UUID) + in: path + name: id + required: true + type: string + - description: server_id with role=bastion + in: body + name: body + required: true + schema: + $ref: '#/definitions/clusters.setBastionRequest' + responses: + "204": + description: No Content + schema: + type: string + "400": + description: invalid id / invalid server_id / server not bastion + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "404": + description: cluster or server not found + schema: + type: string + "500": + description: update failed + schema: + type: string + security: + - BearerAuth: [] + summary: Set/replace cluster bastion (org scoped) + tags: + - clusters + /api/v1/clusters/{id}/node_pools: + get: + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster 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/clusters.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 attached to a cluster (org scoped) + tags: + - clusters + post: + consumes: + - application/json + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster ID (UUID) + in: path + name: id + required: true + type: string + - description: node_pool_ids + in: body + name: body + required: true + schema: + $ref: '#/definitions/clusters.attachNodePoolsRequest' + responses: + "204": + description: No Content + schema: + type: string + "400": + description: invalid id / 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 node pools to cluster (org scoped) + tags: + - clusters + /api/v1/clusters/{id}/node_pools/{poolId}: + delete: + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Cluster ID (UUID) + in: path + name: id + required: true + type: string + - description: Node Pool ID (UUID) + in: path + name: poolId + required: true + type: string + 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 node pool from a cluster (org scoped) + tags: + - clusters /api/v1/labels: get: consumes: diff --git a/internal/api/routes.go b/internal/api/routes.go index 1dd573b..9a69902 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -6,6 +6,7 @@ import ( "github.com/glueops/autoglue/internal/config" "github.com/glueops/autoglue/internal/handlers/annotations" "github.com/glueops/autoglue/internal/handlers/authn" + "github.com/glueops/autoglue/internal/handlers/clusters" "github.com/glueops/autoglue/internal/handlers/health" "github.com/glueops/autoglue/internal/handlers/labels" "github.com/glueops/autoglue/internal/handlers/nodepools" @@ -148,6 +149,24 @@ func RegisterRoutes(r chi.Router) { l.Post("/{id}/node_pools", labels.AddLabelToNodePool) l.Delete("/{id}/node_pools/{poolId}", labels.RemoveLabelFromNodePool) }) + + v1.Route("/clusters", func(c chi.Router) { + c.Use(authMW) + c.Get("/", clusters.ListClusters) + c.Post("/", clusters.CreateCluster) + + c.Get("/{id}", clusters.GetCluster) + c.Patch("/{id}", clusters.UpdateCluster) + c.Delete("/{id}", clusters.DeleteCluster) + + c.Get("/{id}/node_pools", clusters.ListClusterNodePools) + c.Post("/{id}/node_pools", clusters.AttachNodePools) + c.Delete("/{id}/node_pools/{poolId}", clusters.DetachNodePool) + + c.Get("/{id}/bastion", clusters.GetBastion) + c.Post("/{id}/bastion", clusters.PutBastion) + c.Delete("/{id}/bastion", clusters.DeleteBastion) + }) }) }) diff --git a/internal/db/models/annotation.go b/internal/db/models/annotation.go index 8e6a87a..5efa071 100644 --- a/internal/db/models/annotation.go +++ b/internal/db/models/annotation.go @@ -6,7 +6,7 @@ type Annotation struct { ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"` - Name string `gorm:"not null" json:"name"` + Key string `gorm:"not null" json:"key"` Value string `gorm:"not null" json:"value"` NodePools []NodePool `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"servers,omitempty"` Timestamped diff --git a/internal/db/models/cluster.go b/internal/db/models/cluster.go index f4b6bdc..6e14509 100644 --- a/internal/db/models/cluster.go +++ b/internal/db/models/cluster.go @@ -14,4 +14,8 @@ type Cluster struct { KubeIV string `json:"-"` KubeTag string `json:"-"` NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"` + BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"` + BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"` + ClusterLoadBalancer string `gorm:"type:text" json:"cluster_load_balancer"` + ControlLoadBalancer string `gorm:"type:text" json:"control_load_balancer"` } diff --git a/internal/handlers/annotations/annotations.go b/internal/handlers/annotations/annotations.go index 88afa4d..f5b0d6b 100644 --- a/internal/handlers/annotations/annotations.go +++ b/internal/handlers/annotations/annotations.go @@ -24,19 +24,19 @@ type nodePoolBrief struct { type annotationResponse struct { ID uuid.UUID `json:"id"` - Name string `json:"name"` + Key string `json:"key"` Value string `json:"value"` NodePools []nodePoolBrief `json:"node_pools,omitempty"` } type createAnnotationRequest struct { - Name string `json:"name"` + Key string `json:"key"` Value string `json:"value"` NodePoolIDs []string `json:"node_pool_ids"` } type updateAnnotationRequest struct { - Name *string `json:"name,omitempty"` + Key *string `json:"key,omitempty"` Value *string `json:"value,omitempty"` } @@ -49,7 +49,7 @@ type addAnnotationToNodePool struct { func toResp(a models.Annotation, includePools bool) annotationResponse { out := annotationResponse{ ID: a.ID, - Name: a.Name, + Key: a.Key, Value: a.Value, } if includePools { @@ -216,14 +216,14 @@ func CreateAnnotation(w http.ResponseWriter, r *http.Request) { } var req createAnnotationRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Value) == "" { - http.Error(w, "invalid json or missing name/value", http.StatusBadRequest) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Key) == "" || strings.TrimSpace(req.Value) == "" { + http.Error(w, "invalid json or missing key/value", http.StatusBadRequest) return } a := models.Annotation{ OrganizationID: ac.OrganizationID, - Name: strings.TrimSpace(req.Name), + Key: strings.TrimSpace(req.Key), Value: strings.TrimSpace(req.Value), } @@ -301,8 +301,8 @@ func UpdateAnnotation(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid json", http.StatusBadRequest) return } - if req.Name != nil { - a.Name = strings.TrimSpace(*req.Name) + if req.Key != nil { + a.Key = strings.TrimSpace(*req.Key) } if req.Value != nil { a.Value = strings.TrimSpace(*req.Value) diff --git a/internal/handlers/clusters/clusters.go b/internal/handlers/clusters/clusters.go new file mode 100644 index 0000000..2c04cfa --- /dev/null +++ b/internal/handlers/clusters/clusters.go @@ -0,0 +1,680 @@ +package clusters + +import ( + "net/http" + "strings" + + "github.com/glueops/autoglue/internal/db" + "github.com/glueops/autoglue/internal/db/models" + "github.com/glueops/autoglue/internal/middleware" + "github.com/glueops/autoglue/internal/response" + "github.com/glueops/autoglue/internal/utils" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ListClusters godoc +// @Summary List clusters (org scoped) +// @Description Returns clusters for the organization in X-Org-ID. Add `include=node_pools,bastion` to expand. Filter by `q` (name contains). +// @Tags clusters +// @Security BearerAuth +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param q query string false "Name contains (case-insensitive)" +// @Param include query string false "Optional: node_pools,bastion" +// @Success 200 {array} clusterResponse +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 500 {string} string "failed to list clusters" +// @Router /api/v1/clusters [get] +func ListClusters(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 + } + include := strings.Split(strings.ToLower(r.URL.Query().Get("include")), ",") + withPools := contains(include, "node_pools") + withBastion := contains(include, "bastion") + q := strings.TrimSpace(r.URL.Query().Get("q")) + + var rows []models.Cluster + tx := db.DB.Where("organization_id = ?", ac.OrganizationID) + if q != "" { + tx = tx.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(q)+"%") + } + if withPools { + tx = tx. + Preload("NodePools"). + Preload("NodePools.Labels"). + Preload("NodePools.Annotations"). + Preload("NodePools.Taints"). + Preload("NodePools.Servers") + } + if withBastion { + tx = tx.Preload("BastionServer") + } + if err := tx.Find(&rows).Error; err != nil { + http.Error(w, "failed to list clusters", http.StatusInternalServerError) + return + } + + out := make([]clusterResponse, 0, len(rows)) + for _, c := range rows { + out = append(out, toResp(c, withPools, withBastion)) + } + _ = response.JSON(w, http.StatusOK, out) +} + +// CreateCluster godoc +// @Summary Create cluster (org scoped) +// @Description Creates a cluster and optionally links node pools and a bastion server. If `kubeconfig` is provided, it will be encrypted per-organization and stored securely (never returned). +// @Tags clusters +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param body body clusters.createClusterRequest true "payload" +// @Success 201 {object} clusters.clusterResponse +// @Failure 400 {string} string "invalid json / invalid node_pool_ids / invalid bastion_server_id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 500 {string} string "create failed" +// @Router /api/v1/clusters [post] +func CreateCluster(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 + } + var in createClusterRequest + if !readJSON(w, r, &in) { + return + } + + var poolIDs []uuid.UUID + var err error + if len(in.NodePoolIDs) > 0 { + poolIDs, err = parseUUIDs(in.NodePoolIDs) + if err != nil { + http.Error(w, "invalid node_pool_ids", http.StatusBadRequest) + return + } + if err := ensureNodePoolsBelongToOrg(ac.OrganizationID, poolIDs); err != nil { + http.Error(w, "invalid node_pool_ids", http.StatusBadRequest) + return + } + } + + var bastionID *uuid.UUID + if in.BastionServerID != nil && *in.BastionServerID != "" { + bid, err := uuid.Parse(*in.BastionServerID) + if err != nil { + http.Error(w, "invalid bastion_server_id", http.StatusBadRequest) + return + } + if err := ensureServerBelongsToOrgWithRole(ac.OrganizationID, bid, "bastion"); err != nil { + http.Error(w, "invalid bastion_server_id", http.StatusBadRequest) + return + } + bastionID = &bid + } + + c := models.Cluster{ + OrganizationID: ac.OrganizationID, + Name: in.Name, + Provider: in.Provider, + Region: in.Region, + Status: "pending", + BastionServerID: bastionID, + } + + if in.ClusterLoadBalancer != nil { + c.ClusterLoadBalancer = *in.ClusterLoadBalancer + } + + if in.ControlLoadBalancer != nil { + c.ControlLoadBalancer = *in.ControlLoadBalancer + } + + if in.Kubeconfig != nil { + kc := strings.TrimSpace(*in.Kubeconfig) + if kc != "" { + ct, iv, tag, err := utils.EncryptForOrg(ac.OrganizationID, []byte(kc)) + if err != nil { + http.Error(w, "kubeconfig encrypt failed", http.StatusInternalServerError) + return + } + c.EncryptedKubeconfig = ct + c.KubeIV = iv + c.KubeTag = tag + } + } + + err = db.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&c).Error; err != nil { + return err + } + if len(poolIDs) > 0 { + var pools []models.NodePool + if err := tx.Where("id IN ?", poolIDs).Find(&pools).Error; err != nil { + return err + } + if err := tx.Model(&c).Association("NodePools").Replace(&pools); err != nil { + return err + } + } + return nil + }) + if err != nil { + http.Error(w, "create failed", http.StatusInternalServerError) + return + } + + tx := db.DB.Preload("NodePools").Preload("BastionServer") + if err := tx.First(&c, "id = ?", c.ID).Error; err != nil { + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + _ = response.JSON(w, http.StatusCreated, toResp(c, true, true)) +} + +// GetCluster godoc +// @Summary Get cluster by ID (org scoped) +// @Description Returns one cluster. Add `include=node_pools,bastion` to expand. +// @Tags clusters +// @Security BearerAuth +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @Param include query string false "Optional: node_pools,bastion" +// @Success 200 {object} clusters.clusterResponse +// @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/clusters/{id} [get] +func GetCluster(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 + } + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + include := strings.Split(strings.ToLower(r.URL.Query().Get("include")), ",") + withPools := contains(include, "node_pools") + withBastion := contains(include, "bastion") + + tx := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID) + if withPools { + tx = tx.Preload("NodePools"). + Preload("NodePools.Taints"). + Preload("NodePools.Annotations"). + Preload("NodePools.Labels"). + Preload("NodePools.Servers") + } + if withBastion { + tx = tx.Preload("BastionServer") + } + + var c models.Cluster + if err := tx.First(&c).Error; err != nil { + if errorsIsNotFound(err) { + http.Error(w, "not found", http.StatusNotFound) + } else { + http.Error(w, "fetch failed", http.StatusInternalServerError) + } + return + } + _ = response.JSON(w, http.StatusOK, toResp(c, withPools, withBastion)) +} + +// UpdateCluster godoc +// @Summary Update cluster (org scoped). If `kubeconfig` is provided and non-empty, it will be encrypted per-organization and stored (never returned). Sending an empty string for `kubeconfig` is ignored (no change). +// @Tags clusters +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @Param body body clusters.updateClusterRequest true "payload" +// @Success 200 {object} clusters.clusterResponse +// @Failure 400 {string} string "invalid id / invalid json / invalid bastion_server_id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "update failed" +// @Router /api/v1/clusters/{id} [patch] +func UpdateCluster(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 + } + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var c models.Cluster + if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&c).Error; err != nil { + if errorsIsNotFound(err) { + http.Error(w, "not found", http.StatusNotFound) + } else { + http.Error(w, "fetch failed", http.StatusInternalServerError) + } + return + } + + var in updateClusterRequest + if !readJSON(w, r, &in) { + return + } + + if in.Name != nil { + c.Name = *in.Name + } + if in.Provider != nil { + c.Provider = *in.Provider + } + if in.Region != nil { + c.Region = *in.Region + } + if in.Status != nil { + c.Status = *in.Status + } + if in.ClusterLoadBalancer != nil { + c.ClusterLoadBalancer = *in.ClusterLoadBalancer + } + if in.ControlLoadBalancer != nil { + c.ControlLoadBalancer = *in.ControlLoadBalancer + } + if in.Kubeconfig != nil { + kc := strings.TrimSpace(*in.Kubeconfig) + if kc != "" { + ct, iv, tag, err := utils.EncryptForOrg(ac.OrganizationID, []byte(kc)) + if err != nil { + http.Error(w, "kubeconfig encrypt failed", http.StatusInternalServerError) + return + } + c.EncryptedKubeconfig = ct + c.KubeIV = iv + c.KubeTag = tag + } + } + if in.BastionServerID != nil { + if *in.BastionServerID == "" { + c.BastionServerID = nil + } else { + bid, err := uuid.Parse(*in.BastionServerID) + if err != nil { + http.Error(w, "invalid bastion_server_id", http.StatusBadRequest) + return + } + if err := ensureServerBelongsToOrgWithRole(ac.OrganizationID, bid, "bastion"); err != nil { + http.Error(w, "invalid bastion_server_id", http.StatusBadRequest) + return + } + c.BastionServerID = &bid + } + } + + if err := db.DB.Save(&c).Error; err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + + db.DB.Preload("NodePools").Preload("BastionServer").First(&c, "id = ?", c.ID) + _ = response.JSON(w, http.StatusOK, toResp(c, true, true)) +} + +// DeleteCluster godoc +// @Summary Delete cluster (org scoped) +// @Tags clusters +// @Security BearerAuth +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "invalid id" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 500 {string} string "delete failed" +// @Router /api/v1/clusters/{id} [delete] +func DeleteCluster(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 + } + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).Delete(&models.Cluster{}).Error; err != nil { + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + response.NoContent(w) +} + +// ListClusterNodePools godoc +// @Summary List node pools attached to a cluster (org scoped) +// @Tags clusters +// @Security BearerAuth +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @Param q query string false "Name contains (case-insensitive)" +// @Success 200 {array} clusters.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/clusters/{id}/node_pools [get] +func ListClusterNodePools(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 + } + cid, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + q := strings.TrimSpace(r.URL.Query().Get("q")) + + // ensure cluster exists and belongs to org + var exists int64 + if err := db.DB.Model(&models.Cluster{}). + Where("id = ? AND organization_id = ?", cid, ac.OrganizationID). + Count(&exists).Error; err != nil { + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + if exists == 0 { + http.Error(w, "not found", http.StatusNotFound) + return + } + + var pools []models.NodePool + tx := db.DB. + Model(&models.NodePool{}). + Joins("JOIN cluster_node_pools cnp ON cnp.node_pool_id = node_pools.id"). + Where("cnp.cluster_id = ? AND node_pools.organization_id = ?", cid, ac.OrganizationID) + if q != "" { + tx = tx.Where("LOWER(node_pools.name) LIKE ?", "%"+strings.ToLower(q)+"%") + } + if err := tx.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) +} + +// Attach/Detach NodePools + +// AttachNodePools godoc +// @Summary Attach node pools to cluster (org scoped) +// @Tags clusters +// @Security BearerAuth +// @Accept json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @Param body body clusters.attachNodePoolsRequest true "node_pool_ids" +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "invalid id / 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/clusters/{id}/node_pools [post] +func AttachNodePools(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 + } + cid, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var c models.Cluster + if err := db.DB.Where("id = ? AND organization_id = ?", cid, ac.OrganizationID).First(&c).Error; err != nil { + if errorsIsNotFound(err) { + http.Error(w, "not found", http.StatusNotFound) + } else { + http.Error(w, "fetch failed", http.StatusInternalServerError) + } + return + } + + var in attachNodePoolsRequest + if !readJSON(w, r, &in) { + 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", http.StatusBadRequest) + return + } + + var pools []models.NodePool + if err := db.DB.Where("id IN ?", ids).Find(&pools).Error; err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + if err := db.DB.Model(&c).Association("NodePools").Append(&pools); err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + response.NoContent(w) +} + +// DetachNodePool godoc +// @Summary Detach one node pool from a cluster (org scoped) +// @Tags clusters +// @Security BearerAuth +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @Param poolId path string true "Node Pool ID (UUID)" +// @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/clusters/{id}/node_pools/{poolId} [delete] +func DetachNodePool(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 + } + cid, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + pid, err := uuid.Parse(chi.URLParam(r, "poolId")) + if err != nil { + http.Error(w, "invalid poolId", http.StatusBadRequest) + return + } + + var c models.Cluster + if err := db.DB.Where("id = ? AND organization_id = ?", cid, ac.OrganizationID).First(&c).Error; err != nil { + if errorsIsNotFound(err) { + http.Error(w, "not found", http.StatusNotFound) + } else { + http.Error(w, "fetch failed", http.StatusInternalServerError) + } + return + } + var p models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", pid, ac.OrganizationID).First(&p).Error; err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := db.DB.Model(&c).Association("NodePools").Delete(&p); err != nil { + http.Error(w, "detach failed", http.StatusInternalServerError) + return + } + response.NoContent(w) +} + +// Bastion subresource + +// GetBastion godoc +// @Summary Get cluster bastion (org scoped) +// @Tags clusters +// @Security BearerAuth +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @Success 200 {object} clusters.serverBrief +// @Success 204 {string} string "No Content (no bastion set)" +// @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/clusters/{id}/bastion [get] +func GetBastion(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 + } + cid, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var c models.Cluster + if err := db.DB.Preload("BastionServer"). + Where("id = ? AND organization_id = ?", cid, ac.OrganizationID). + First(&c).Error; err != nil { + if errorsIsNotFound(err) { + http.Error(w, "not found", http.StatusNotFound) + } else { + http.Error(w, "fetch failed", http.StatusInternalServerError) + } + return + } + if c.BastionServer == nil { + w.WriteHeader(http.StatusNoContent) + return + } + _ = response.JSON(w, http.StatusOK, serverBrief{ + ID: c.BastionServer.ID, Hostname: c.BastionServer.Hostname, + IP: c.BastionServer.IPAddress, Role: c.BastionServer.Role, Status: c.BastionServer.Status, + }) +} + +// PutBastion godoc +// @Summary Set/replace cluster bastion (org scoped) +// @Tags clusters +// @Security BearerAuth +// @Accept json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @Param body body clusters.setBastionRequest true "server_id with role=bastion" +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "invalid id / invalid server_id / server not bastion" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "cluster or server not found" +// @Failure 500 {string} string "update failed" +// @Router /api/v1/clusters/{id}/bastion [post] +func PutBastion(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 + } + cid, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var in setBastionRequest + if !readJSON(w, r, &in) { + return + } + sid, err := uuid.Parse(in.ServerID) + if err != nil { + http.Error(w, "invalid server_id", http.StatusBadRequest) + return + } + if err := ensureServerBelongsToOrgWithRole(ac.OrganizationID, sid, "bastion"); err != nil { + http.Error(w, "server must exist in org and have role=bastion", http.StatusBadRequest) + return + } + + if err := db.DB.Model(&models.Cluster{}). + Where("id = ? AND organization_id = ?", cid, ac.OrganizationID). + Updates(map[string]any{"bastion_server_id": sid}).Error; err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + response.NoContent(w) +} + +// DeleteBastion godoc +// @Summary Clear cluster bastion (org scoped) +// @Tags clusters +// @Security BearerAuth +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Cluster ID (UUID)" +// @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 "update failed" +// @Router /api/v1/clusters/{id}/bastion [delete] +func DeleteBastion(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 + } + cid, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + if err := db.DB.Model(&models.Cluster{}). + Where("id = ? AND organization_id = ?", cid, ac.OrganizationID). + Updates(map[string]any{"bastion_server_id": nil}).Error; err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + response.NoContent(w) +} diff --git a/internal/handlers/clusters/dto.go b/internal/handlers/clusters/dto.go new file mode 100644 index 0000000..59f4e4f --- /dev/null +++ b/internal/handlers/clusters/dto.go @@ -0,0 +1,87 @@ +package clusters + +import "github.com/google/uuid" + +// clusterResponse describes a cluster with optional expansions. +// swagger:model clusters.clusterResponse +type clusterResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Region string `json:"region"` + Status string `json:"status"` + ClusterLoadBalancer string `json:"cluster_load_balancer"` + ControlLoadBalancer string `json:"control_load_balancer"` + NodePools []nodePoolBrief `json:"node_pools,omitempty"` + BastionServer *serverBrief `json:"bastion_server,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"` +} + +type nodePoolBrief struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Labels []labelBrief `json:"labels,omitempty"` + Annotations []annotationBrief `json:"annotations,omitempty"` + Taints []taintBrief `json:"taints,omitempty"` + Servers []serverBrief `json:"servers,omitempty"` +} + +type labelBrief struct { + ID uuid.UUID `json:"id"` + Key string `json:"key"` + Value string `json:"value"` +} + +type annotationBrief struct { + ID uuid.UUID `json:"id"` + Key string `json:"key"` + Value string `json:"value"` +} + +type taintBrief struct { + ID uuid.UUID `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Effect string `json:"effect"` +} + +// swagger:model clusters.updateClusterRequest +type updateClusterRequest struct { + Name *string `json:"name"` + Provider *string `json:"provider"` + Region *string `json:"region"` + Status *string `json:"status"` + BastionServerID *string `json:"bastion_server_id"` + ClusterLoadBalancer *string `json:"cluster_load_balancer"` + ControlLoadBalancer *string `json:"control_load_balancer"` + Kubeconfig *string `json:"kubeconfig"` +} + +// swagger:model clusters.attachNodePoolsRequest +type attachNodePoolsRequest struct { + NodePoolIDs []string `json:"node_pool_ids"` +} + +// swagger:model clusters.setBastionRequest +type setBastionRequest struct { + ServerID string `json:"server_id"` +} + +// swagger:model clusters.createClusterRequest +type createClusterRequest struct { + Name string `json:"name"` + Provider string `json:"provider"` + Region string `json:"region"` + NodePoolIDs []string `json:"node_pool_ids"` + BastionServerID *string `json:"bastion_server_id"` + ClusterLoadBalancer *string `json:"cluster_load_balancer"` + ControlLoadBalancer *string `json:"control_load_balancer"` + Kubeconfig *string `json:"kubeconfig"` +} diff --git a/internal/handlers/clusters/funcs.go b/internal/handlers/clusters/funcs.go new file mode 100644 index 0000000..1528adb --- /dev/null +++ b/internal/handlers/clusters/funcs.go @@ -0,0 +1,191 @@ +package clusters + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strings" + + "github.com/glueops/autoglue/internal/db" + "github.com/glueops/autoglue/internal/db/models" + "github.com/google/uuid" + "gorm.io/gorm" +) + +func ensureNodePoolsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { + if len(ids) == 0 { + return errors.New("empty ids") + } + var count int64 + if err := db.DB.Model(&models.NodePool{}). + Where("organization_id = ? AND id IN ?", orgID, ids). + Count(&count).Error; err != nil { + return err + } + if count != int64(len(ids)) { + return errors.New("some node pools do not belong to org") + } + return nil +} + +func ensureServerBelongsToOrgWithRole(orgID uuid.UUID, id uuid.UUID, role string) error { + var count int64 + if err := db.DB.Model(&models.Server{}). + Where("organization_id = ? AND id = ? AND role = ?", orgID, id, role). + Count(&count).Error; err != nil { + return err + } + if count != 1 { + return errors.New("server not found in org or role mismatch") + } + return nil +} + +func toResp(c models.Cluster, includePools, includeBastion bool) clusterResponse { + out := clusterResponse{ + ID: c.ID, + Name: c.Name, + Provider: c.Provider, + Region: c.Region, + Status: c.Status, + ClusterLoadBalancer: c.ClusterLoadBalancer, + ControlLoadBalancer: c.ControlLoadBalancer, + } + if includePools { + out.NodePools = make([]nodePoolBrief, 0, len(c.NodePools)) + for _, p := range c.NodePools { + nb := nodePoolBrief{ID: p.ID, Name: p.Name} + fmt.Printf("node pool %s\n", p.Name) + fmt.Printf("node pool labels %d\n", len(p.Labels)) + if len(p.Labels) > 0 { + nb.Labels = make([]labelBrief, 0, len(p.Labels)) + for _, l := range p.Labels { + nb.Labels = append(nb.Labels, labelBrief{ID: l.ID, Key: l.Key, Value: l.Value}) + } + } + + fmt.Printf("node pool annotations %d\n", len(p.Annotations)) + + if len(p.Annotations) > 0 { + nb.Annotations = make([]annotationBrief, 0, len(p.Annotations)) + for _, a := range p.Annotations { + nb.Annotations = append(nb.Annotations, annotationBrief{ID: a.ID, Key: a.Key, Value: a.Value}) + } + } + + fmt.Printf("node pool taints %d\n", len(p.Taints)) + + if len(p.Taints) > 0 { + nb.Taints = make([]taintBrief, 0, len(p.Taints)) + for _, t := range p.Taints { + nb.Taints = append(nb.Taints, taintBrief{ID: t.ID, Key: t.Key, Value: t.Value, Effect: t.Effect}) + } + } + + if len(p.Servers) > 0 { + nb.Servers = make([]serverBrief, 0, len(p.Servers)) + for _, s := range p.Servers { + nb.Servers = append(nb.Servers, serverBrief{ID: s.ID, Hostname: s.Hostname, Role: s.Role, Status: s.Status, IP: s.IPAddress}) + } + } + + out.NodePools = append(out.NodePools, nb) + } + } + if includeBastion && c.BastionServer != nil { + out.BastionServer = &serverBrief{ + ID: c.BastionServer.ID, + Hostname: c.BastionServer.Hostname, + IP: c.BastionServer.IPAddress, + Role: c.BastionServer.Role, + Status: c.BastionServer.Status, + } + } + return out +} + +func contains(xs []string, want string) bool { + for _, x := range xs { + if strings.TrimSpace(x) == want { + return true + } + } + return false +} + +func errorsIsNotFound(err error) bool { return err == gorm.ErrRecordNotFound } + +func parseUUIDs(ids []string) ([]uuid.UUID, error) { + out := make([]uuid.UUID, 0, len(ids)) + for _, s := range ids { + u, err := uuid.Parse(strings.TrimSpace(s)) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, nil +} + +const maxJSONBytes int64 = 1 << 20 + +func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + if ct := r.Header.Get("Content-Type"); ct != "" { + mt, _, err := mime.ParseMediaType(ct) + if err != nil || mt != "application/json" { + http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) + return false + } + } + + r.Body = http.MaxBytesReader(w, r.Body, maxJSONBytes) + defer r.Body.Close() + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + if err := dec.Decode(dst); err != nil { + var syntaxErr *json.SyntaxError + var typeErr *json.UnmarshalTypeError + var maxErr *http.MaxBytesError + + switch { + case errors.As(err, &maxErr): + http.Error(w, fmt.Sprintf("request body too large (max %d bytes)", maxJSONBytes), http.StatusRequestEntityTooLarge) + case errors.Is(err, io.EOF): + http.Error(w, "request body must not be empty", http.StatusBadRequest) + case errors.As(err, &syntaxErr): + http.Error(w, fmt.Sprintf("malformed JSON at character %d", syntaxErr.Offset), http.StatusBadRequest) + case errors.Is(err, io.ErrUnexpectedEOF): + http.Error(w, "malformed JSON", http.StatusBadRequest) + case errors.As(err, &typeErr): + // Example: expected string but got number for field "name" + field := typeErr.Field + if field == "" && len(typeErr.Struct) > 0 { + field = typeErr.Struct + } + http.Error(w, fmt.Sprintf("invalid value for %q (expected %s)", field, typeErr.Type.String()), http.StatusBadRequest) + case strings.HasPrefix(err.Error(), "json: unknown field "): + // Extract the field name from the error message. + field := strings.Trim(strings.TrimPrefix(err.Error(), "json: unknown field "), "\"") + http.Error(w, fmt.Sprintf("unknown field %q", field), http.StatusBadRequest) + default: + http.Error(w, "invalid json", http.StatusBadRequest) + } + return false + } + + if dec.More() { + // Try to read one more token/value; if not EOF, there was extra content. + var extra any + if err := dec.Decode(&extra); err != io.EOF { + http.Error(w, "body must contain only a single JSON object", http.StatusBadRequest) + return false + } + } + + return true +} diff --git a/internal/handlers/nodepools/dto.go b/internal/handlers/nodepools/dto.go index 8192b51..b277a37 100644 --- a/internal/handlers/nodepools/dto.go +++ b/internal/handlers/nodepools/dto.go @@ -54,7 +54,7 @@ type taintBrief struct { type annotationBrief struct { ID uuid.UUID `json:"id"` - Name string `json:"name"` + Key string `json:"key"` Value string `json:"value"` } diff --git a/internal/handlers/nodepools/nodepools.go b/internal/handlers/nodepools/nodepools.go index 31399e6..f79b266 100644 --- a/internal/handlers/nodepools/nodepools.go +++ b/internal/handlers/nodepools/nodepools.go @@ -851,7 +851,7 @@ func ListNodePoolAnnotations(w http.ResponseWriter, r *http.Request) { for _, a := range ng.Annotations { out = append(out, annotationBrief{ ID: a.ID, - Name: a.Name, + Key: a.Key, Value: a.Value, }) } diff --git a/ui/src/components/ui/textarea.tsx b/ui/src/components/ui/textarea.tsx new file mode 100644 index 0000000..7f21b5e --- /dev/null +++ b/ui/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +