mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
most of the working app
This commit is contained in:
16
atlas.hcl
Normal file
16
atlas.hcl
Normal file
@@ -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"
|
||||||
|
}
|
||||||
1022
docs/docs.go
1022
docs/docs.go
File diff suppressed because it is too large
Load Diff
1022
docs/swagger.json
1022
docs/swagger.json
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
name:
|
key:
|
||||||
type: string
|
type: string
|
||||||
node_pools:
|
node_pools:
|
||||||
items:
|
items:
|
||||||
@@ -22,7 +22,7 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
annotations.createAnnotationRequest:
|
annotations.createAnnotationRequest:
|
||||||
properties:
|
properties:
|
||||||
name:
|
key:
|
||||||
type: string
|
type: string
|
||||||
node_pool_ids:
|
node_pool_ids:
|
||||||
items:
|
items:
|
||||||
@@ -40,7 +40,7 @@ definitions:
|
|||||||
type: object
|
type: object
|
||||||
annotations.updateAnnotationRequest:
|
annotations.updateAnnotationRequest:
|
||||||
properties:
|
properties:
|
||||||
name:
|
key:
|
||||||
type: string
|
type: string
|
||||||
value:
|
value:
|
||||||
type: string
|
type: string
|
||||||
@@ -197,6 +197,146 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
updated_at: {}
|
updated_at: {}
|
||||||
type: object
|
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:
|
labels.addLabelToPoolRequest:
|
||||||
properties:
|
properties:
|
||||||
node_pool_ids:
|
node_pool_ids:
|
||||||
@@ -323,7 +463,7 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
name:
|
key:
|
||||||
type: string
|
type: string
|
||||||
value:
|
value:
|
||||||
type: string
|
type: string
|
||||||
@@ -1529,6 +1669,528 @@ paths:
|
|||||||
summary: Resend email verification
|
summary: Resend email verification
|
||||||
tags:
|
tags:
|
||||||
- auth
|
- 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:
|
/api/v1/labels:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/glueops/autoglue/internal/config"
|
"github.com/glueops/autoglue/internal/config"
|
||||||
"github.com/glueops/autoglue/internal/handlers/annotations"
|
"github.com/glueops/autoglue/internal/handlers/annotations"
|
||||||
"github.com/glueops/autoglue/internal/handlers/authn"
|
"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/health"
|
||||||
"github.com/glueops/autoglue/internal/handlers/labels"
|
"github.com/glueops/autoglue/internal/handlers/labels"
|
||||||
"github.com/glueops/autoglue/internal/handlers/nodepools"
|
"github.com/glueops/autoglue/internal/handlers/nodepools"
|
||||||
@@ -148,6 +149,24 @@ func RegisterRoutes(r chi.Router) {
|
|||||||
l.Post("/{id}/node_pools", labels.AddLabelToNodePool)
|
l.Post("/{id}/node_pools", labels.AddLabelToNodePool)
|
||||||
l.Delete("/{id}/node_pools/{poolId}", labels.RemoveLabelFromNodePool)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type Annotation struct {
|
|||||||
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
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"`
|
Value string `gorm:"not null" json:"value"`
|
||||||
NodePools []NodePool `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
|
NodePools []NodePool `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
|
||||||
Timestamped
|
Timestamped
|
||||||
|
|||||||
@@ -14,4 +14,8 @@ type Cluster struct {
|
|||||||
KubeIV string `json:"-"`
|
KubeIV string `json:"-"`
|
||||||
KubeTag string `json:"-"`
|
KubeTag string `json:"-"`
|
||||||
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,19 +24,19 @@ type nodePoolBrief struct {
|
|||||||
|
|
||||||
type annotationResponse struct {
|
type annotationResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Key string `json:"key"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
NodePools []nodePoolBrief `json:"node_pools,omitempty"`
|
NodePools []nodePoolBrief `json:"node_pools,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createAnnotationRequest struct {
|
type createAnnotationRequest struct {
|
||||||
Name string `json:"name"`
|
Key string `json:"key"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
NodePoolIDs []string `json:"node_pool_ids"`
|
NodePoolIDs []string `json:"node_pool_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateAnnotationRequest struct {
|
type updateAnnotationRequest struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Key *string `json:"key,omitempty"`
|
||||||
Value *string `json:"value,omitempty"`
|
Value *string `json:"value,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ type addAnnotationToNodePool struct {
|
|||||||
func toResp(a models.Annotation, includePools bool) annotationResponse {
|
func toResp(a models.Annotation, includePools bool) annotationResponse {
|
||||||
out := annotationResponse{
|
out := annotationResponse{
|
||||||
ID: a.ID,
|
ID: a.ID,
|
||||||
Name: a.Name,
|
Key: a.Key,
|
||||||
Value: a.Value,
|
Value: a.Value,
|
||||||
}
|
}
|
||||||
if includePools {
|
if includePools {
|
||||||
@@ -216,14 +216,14 @@ func CreateAnnotation(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req createAnnotationRequest
|
var req createAnnotationRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Value) == "" {
|
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 name/value", http.StatusBadRequest)
|
http.Error(w, "invalid json or missing key/value", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a := models.Annotation{
|
a := models.Annotation{
|
||||||
OrganizationID: ac.OrganizationID,
|
OrganizationID: ac.OrganizationID,
|
||||||
Name: strings.TrimSpace(req.Name),
|
Key: strings.TrimSpace(req.Key),
|
||||||
Value: strings.TrimSpace(req.Value),
|
Value: strings.TrimSpace(req.Value),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,8 +301,8 @@ func UpdateAnnotation(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Name != nil {
|
if req.Key != nil {
|
||||||
a.Name = strings.TrimSpace(*req.Name)
|
a.Key = strings.TrimSpace(*req.Key)
|
||||||
}
|
}
|
||||||
if req.Value != nil {
|
if req.Value != nil {
|
||||||
a.Value = strings.TrimSpace(*req.Value)
|
a.Value = strings.TrimSpace(*req.Value)
|
||||||
|
|||||||
680
internal/handlers/clusters/clusters.go
Normal file
680
internal/handlers/clusters/clusters.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
87
internal/handlers/clusters/dto.go
Normal file
87
internal/handlers/clusters/dto.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
191
internal/handlers/clusters/funcs.go
Normal file
191
internal/handlers/clusters/funcs.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ type taintBrief struct {
|
|||||||
|
|
||||||
type annotationBrief struct {
|
type annotationBrief struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Key string `json:"key"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -851,7 +851,7 @@ func ListNodePoolAnnotations(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, a := range ng.Annotations {
|
for _, a := range ng.Annotations {
|
||||||
out = append(out, annotationBrief{
|
out = append(out, annotationBrief{
|
||||||
ID: a.ID,
|
ID: a.ID,
|
||||||
Name: a.Name,
|
Key: a.Key,
|
||||||
Value: a.Value,
|
Value: a.Value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
18
ui/src/components/ui/textarea.tsx
Normal file
18
ui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -58,13 +58,13 @@ type NodePoolBrief = {
|
|||||||
|
|
||||||
type Annotation = {
|
type Annotation = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
key: string
|
||||||
value: string
|
value: string
|
||||||
node_pools?: NodePoolBrief[]
|
node_pools?: NodePoolBrief[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateSchema = z.object({
|
const CreateSchema = z.object({
|
||||||
name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"),
|
key: z.string().trim().min(1, "Key is required").max(120, "Max 120 chars"),
|
||||||
value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"),
|
value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"),
|
||||||
node_pool_ids: z.array(z.string().uuid()).optional().default([]),
|
node_pool_ids: z.array(z.string().uuid()).optional().default([]),
|
||||||
})
|
})
|
||||||
@@ -72,7 +72,7 @@ type CreateInput = z.input<typeof CreateSchema>
|
|||||||
type CreateValues = z.output<typeof CreateSchema>
|
type CreateValues = z.output<typeof CreateSchema>
|
||||||
|
|
||||||
const UpdateSchema = z.object({
|
const UpdateSchema = z.object({
|
||||||
name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"),
|
key: z.string().trim().min(1, "Key is required").max(120, "Max 120 chars"),
|
||||||
value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"),
|
value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"),
|
||||||
})
|
})
|
||||||
type UpdateValues = z.output<typeof UpdateSchema>
|
type UpdateValues = z.output<typeof UpdateSchema>
|
||||||
@@ -171,7 +171,7 @@ export const AnnotationsPage = () => {
|
|||||||
if (!needle) return annotations
|
if (!needle) return annotations
|
||||||
return annotations.filter(
|
return annotations.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.name.toLowerCase().includes(needle) ||
|
a.key.toLowerCase().includes(needle) ||
|
||||||
a.value.toLowerCase().includes(needle) ||
|
a.value.toLowerCase().includes(needle) ||
|
||||||
(a.node_pools || []).some((p) => p.name.toLowerCase().includes(needle))
|
(a.node_pools || []).some((p) => p.name.toLowerCase().includes(needle))
|
||||||
)
|
)
|
||||||
@@ -188,35 +188,35 @@ export const AnnotationsPage = () => {
|
|||||||
// Create
|
// Create
|
||||||
const createForm = useForm<CreateInput, any, CreateValues>({
|
const createForm = useForm<CreateInput, any, CreateValues>({
|
||||||
resolver: zodResolver(CreateSchema),
|
resolver: zodResolver(CreateSchema),
|
||||||
defaultValues: { name: "", value: "", node_pool_ids: [] },
|
defaultValues: { key: "", value: "", node_pool_ids: [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitCreate = async (values: CreateValues) => {
|
const submitCreate = async (values: CreateValues) => {
|
||||||
const payload: any = { name: values.name.trim(), value: values.value.trim() }
|
const payload: any = { key: values.key.trim(), value: values.value.trim() }
|
||||||
if (values.node_pool_ids && values.node_pool_ids.length > 0) {
|
if (values.node_pool_ids && values.node_pool_ids.length > 0) {
|
||||||
payload.node_pool_ids = values.node_pool_ids
|
payload.node_pool_ids = values.node_pool_ids
|
||||||
}
|
}
|
||||||
await api.post("/api/v1/annotations", payload)
|
await api.post("/api/v1/annotations", payload)
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
createForm.reset({ name: "", value: "", node_pool_ids: [] })
|
createForm.reset({ key: "", value: "", node_pool_ids: [] })
|
||||||
await loadAll()
|
await loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit
|
// Edit
|
||||||
const editForm = useForm<UpdateValues>({
|
const editForm = useForm<UpdateValues>({
|
||||||
resolver: zodResolver(UpdateSchema),
|
resolver: zodResolver(UpdateSchema),
|
||||||
defaultValues: { name: "", value: "" },
|
defaultValues: { key: "", value: "" },
|
||||||
})
|
})
|
||||||
|
|
||||||
function openEdit(a: Annotation) {
|
function openEdit(a: Annotation) {
|
||||||
setEditTarget(a)
|
setEditTarget(a)
|
||||||
editForm.reset({ name: a.name, value: a.value })
|
editForm.reset({ key: a.key, value: a.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitEdit = async (values: UpdateValues) => {
|
const submitEdit = async (values: UpdateValues) => {
|
||||||
if (!editTarget) return
|
if (!editTarget) return
|
||||||
await api.patch(`/api/v1/annotations/${editTarget.id}`, {
|
await api.patch(`/api/v1/annotations/${editTarget.id}`, {
|
||||||
name: values.name.trim(),
|
name: values.key.trim(),
|
||||||
value: values.value.trim(),
|
value: values.value.trim(),
|
||||||
})
|
})
|
||||||
setEditTarget(null)
|
setEditTarget(null)
|
||||||
@@ -293,10 +293,10 @@ export const AnnotationsPage = () => {
|
|||||||
<form onSubmit={createForm.handleSubmit(submitCreate)} className="space-y-4">
|
<form onSubmit={createForm.handleSubmit(submitCreate)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={createForm.control}
|
control={createForm.control}
|
||||||
name="name"
|
name="key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Key</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="cluster-autoscaler.kubernetes.io/safe-to-evict"
|
placeholder="cluster-autoscaler.kubernetes.io/safe-to-evict"
|
||||||
@@ -383,7 +383,7 @@ export const AnnotationsPage = () => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Key</TableHead>
|
||||||
<TableHead>Value</TableHead>
|
<TableHead>Value</TableHead>
|
||||||
<TableHead>Node Pools</TableHead>
|
<TableHead>Node Pools</TableHead>
|
||||||
<TableHead className="w-[180px] text-right">Actions</TableHead>
|
<TableHead className="w-[180px] text-right">Actions</TableHead>
|
||||||
@@ -394,7 +394,7 @@ export const AnnotationsPage = () => {
|
|||||||
const pools = a.node_pools || []
|
const pools = a.node_pools || []
|
||||||
return (
|
return (
|
||||||
<TableRow key={a.id}>
|
<TableRow key={a.id}>
|
||||||
<TableCell className="font-mono text-sm">{a.name}</TableCell>
|
<TableCell className="font-mono text-sm">{a.key}</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{a.value}</TableCell>
|
<TableCell className="font-mono text-sm">{a.value}</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -462,10 +462,10 @@ export const AnnotationsPage = () => {
|
|||||||
<form onSubmit={editForm.handleSubmit(submitEdit)} className="space-y-4">
|
<form onSubmit={editForm.handleSubmit(submitEdit)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
name="name"
|
name="key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Key</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="example.com/some" {...field} />
|
<Input placeholder="example.com/some" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -505,7 +505,7 @@ export const AnnotationsPage = () => {
|
|||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Manage node pools for <span className="font-mono">{managePoolsTarget?.name}</span>
|
Manage node pools for <span className="font-mono">{managePoolsTarget?.key}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,953 @@
|
|||||||
export const ClustersPage = () => {
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import {
|
||||||
|
LinkIcon,
|
||||||
|
PencilIcon,
|
||||||
|
Plus,
|
||||||
|
RefreshCcw,
|
||||||
|
Server as ServerIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UnlinkIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
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,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog.tsx"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form.tsx"
|
||||||
|
import { Input } from "@/components/ui/input.tsx"
|
||||||
|
import { Textarea } from "@/components/ui/textarea.tsx"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table.tsx"
|
||||||
|
|
||||||
|
// ---- Types ----
|
||||||
|
export type NodePoolBrief = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerBrief = {
|
||||||
|
id: string
|
||||||
|
hostname: string
|
||||||
|
ip: string
|
||||||
|
role: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Cluster = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
region: string
|
||||||
|
status: string
|
||||||
|
cluster_load_balancer?: string
|
||||||
|
control_load_balancer?: string
|
||||||
|
node_pools?: NodePoolBrief[]
|
||||||
|
bastion_server?: ServerBrief | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Schemas ----
|
||||||
|
const CreateClusterSchema = z.object({
|
||||||
|
name: z.string().trim().min(2, "Name is too short"),
|
||||||
|
provider: z.string().trim().min(2, "Provider is too short"),
|
||||||
|
region: z.string().trim().min(1, "Region is required"),
|
||||||
|
node_pool_ids: z.array(z.string().uuid()).optional().default([]),
|
||||||
|
bastion_server_id: z.string().uuid().optional(),
|
||||||
|
cluster_load_balancer: z.string().optional(),
|
||||||
|
control_load_balancer: z.string().optional(),
|
||||||
|
kubeconfig: z.string().optional(),
|
||||||
|
})
|
||||||
|
export type CreateClusterValues = z.infer<typeof CreateClusterSchema>
|
||||||
|
|
||||||
|
const UpdateClusterSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(2, "Name is too short").optional(),
|
||||||
|
provider: z.string().trim().min(2, "Provider is too short").optional(),
|
||||||
|
region: z.string().trim().min(1, "Region is required").optional(),
|
||||||
|
status: z.string().trim().min(1, "Status is required").optional(),
|
||||||
|
bastion_server_id: z.string().uuid().or(z.literal("")).optional(),
|
||||||
|
cluster_load_balancer: z.string().optional(),
|
||||||
|
control_load_balancer: z.string().optional(),
|
||||||
|
kubeconfig: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(v) =>
|
||||||
|
v.name !== undefined ||
|
||||||
|
v.provider !== undefined ||
|
||||||
|
v.region !== undefined ||
|
||||||
|
v.status !== undefined ||
|
||||||
|
v.bastion_server_id !== undefined ||
|
||||||
|
v.cluster_load_balancer !== undefined ||
|
||||||
|
v.control_load_balancer !== undefined ||
|
||||||
|
v.kubeconfig !== undefined,
|
||||||
|
{ message: "Provide at least one change", path: ["name"] }
|
||||||
|
)
|
||||||
|
export type UpdateClusterValues = z.infer<typeof UpdateClusterSchema>
|
||||||
|
|
||||||
|
const AttachPoolsSchema = z.object({
|
||||||
|
node_pool_ids: z.array(z.string().uuid()).min(1, "Pick at least one node pool"),
|
||||||
|
})
|
||||||
|
export type AttachPoolsValues = z.infer<typeof AttachPoolsSchema>
|
||||||
|
|
||||||
|
const SetBastionSchema = z.object({
|
||||||
|
server_id: z.string().uuid({ message: "Enter a valid Server UUID" }),
|
||||||
|
})
|
||||||
|
export type SetBastionValues = z.infer<typeof SetBastionSchema>
|
||||||
|
|
||||||
|
// ---- Utils ----
|
||||||
|
function truncateMiddle(str: string, keep = 8) {
|
||||||
|
if (!str || str.length <= keep * 2 + 3) return str
|
||||||
|
return `${str.slice(0, keep)}…${str.slice(-keep)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Component ----
|
||||||
|
export function ClustersPage() {
|
||||||
|
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||||
|
const [allPools, setAllPools] = useState<NodePoolBrief[]>([])
|
||||||
|
const [bastionCandidates, setBastionCandidates] = useState<ServerBrief[]>([])
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [q, setQ] = useState("")
|
||||||
|
|
||||||
|
// dialogs
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<Cluster | null>(null)
|
||||||
|
|
||||||
|
const [managePoolsTarget, setManagePoolsTarget] = useState<Cluster | null>(null)
|
||||||
|
const [manageBastionTarget, setManageBastionTarget] = useState<Cluster | null>(null)
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
setLoading(true)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
const url = `/api/v1/clusters?include=node_pools,bastion${q ? `&q=${encodeURIComponent(q)}` : ""}`
|
||||||
|
const [clustersRaw, poolsRaw, serversRaw] = await Promise.all([
|
||||||
|
api.get<any[]>(url),
|
||||||
|
api.get<NodePoolBrief[]>("/api/v1/node-pools"),
|
||||||
|
// Best-effort; if this endpoint doesn't exist, we'll just fall back to manual input
|
||||||
|
api.get<ServerBrief[]>("/api/v1/servers?role=bastion").catch(() => [] as any),
|
||||||
|
])
|
||||||
|
|
||||||
|
const normalized: Cluster[] = (clustersRaw || []).map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
provider: c.provider,
|
||||||
|
region: c.region,
|
||||||
|
status: c.status,
|
||||||
|
cluster_load_balancer: c.cluster_load_balancer,
|
||||||
|
control_load_balancer: c.control_load_balancer,
|
||||||
|
node_pools: c.node_pools ?? [],
|
||||||
|
bastion_server: c.bastion_server ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setClusters(normalized)
|
||||||
|
setAllPools(poolsRaw || [])
|
||||||
|
setBastionCandidates(serversRaw || [])
|
||||||
|
|
||||||
|
// keep dialogs in sync after refresh
|
||||||
|
if (managePoolsTarget) {
|
||||||
|
const refreshed = normalized.find((x) => x.id === managePoolsTarget.id) || null
|
||||||
|
setManagePoolsTarget(refreshed)
|
||||||
|
}
|
||||||
|
if (manageBastionTarget) {
|
||||||
|
const refreshed = normalized.find((x) => x.id === manageBastionTarget.id) || null
|
||||||
|
setManageBastionTarget(refreshed)
|
||||||
|
}
|
||||||
|
if (editTarget) {
|
||||||
|
const refreshed = normalized.find((x) => x.id === editTarget.id) || null
|
||||||
|
setEditTarget(refreshed)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
const msg = e instanceof ApiError ? e.message : "Failed to load clusters"
|
||||||
|
setErr(msg)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAll()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ---- CREATE ----
|
||||||
|
const createForm = useForm<CreateClusterValues>({
|
||||||
|
resolver: zodResolver(CreateClusterSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
provider: "",
|
||||||
|
region: "",
|
||||||
|
node_pool_ids: [],
|
||||||
|
bastion_server_id: undefined,
|
||||||
|
cluster_load_balancer: "",
|
||||||
|
control_load_balancer: "",
|
||||||
|
kubeconfig: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitCreate = async (values: CreateClusterValues) => {
|
||||||
|
const payload: any = {
|
||||||
|
name: values.name.trim(),
|
||||||
|
provider: values.provider.trim(),
|
||||||
|
region: values.region.trim(),
|
||||||
|
node_pool_ids: values.node_pool_ids || [],
|
||||||
|
}
|
||||||
|
if (values.bastion_server_id) payload.bastion_server_id = values.bastion_server_id
|
||||||
|
if (values.cluster_load_balancer) payload.cluster_load_balancer = values.cluster_load_balancer
|
||||||
|
if (values.control_load_balancer) payload.control_load_balancer = values.control_load_balancer
|
||||||
|
if (values.kubeconfig && values.kubeconfig.trim()) payload.kubeconfig = values.kubeconfig.trim()
|
||||||
|
|
||||||
|
await api.post<Cluster>("/api/v1/clusters", payload)
|
||||||
|
setCreateOpen(false)
|
||||||
|
createForm.reset()
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- EDIT ----
|
||||||
|
const editForm = useForm<UpdateClusterValues>({
|
||||||
|
resolver: zodResolver(UpdateClusterSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: undefined,
|
||||||
|
provider: undefined,
|
||||||
|
region: undefined,
|
||||||
|
status: undefined,
|
||||||
|
bastion_server_id: undefined,
|
||||||
|
cluster_load_balancer: undefined,
|
||||||
|
control_load_balancer: undefined,
|
||||||
|
kubeconfig: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function openEdit(c: Cluster) {
|
||||||
|
setEditTarget(c)
|
||||||
|
editForm.reset({
|
||||||
|
name: undefined,
|
||||||
|
provider: undefined,
|
||||||
|
region: undefined,
|
||||||
|
status: undefined,
|
||||||
|
bastion_server_id: undefined,
|
||||||
|
cluster_load_balancer: undefined,
|
||||||
|
control_load_balancer: undefined,
|
||||||
|
})
|
||||||
|
setEditOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitEdit = async (values: UpdateClusterValues) => {
|
||||||
|
if (!editTarget) return
|
||||||
|
const payload: any = {}
|
||||||
|
if (values.name !== undefined) payload.name = values.name.trim()
|
||||||
|
if (values.provider !== undefined) payload.provider = values.provider.trim()
|
||||||
|
if (values.region !== undefined) payload.region = values.region.trim()
|
||||||
|
if (values.status !== undefined) payload.status = values.status.trim()
|
||||||
|
if (values.bastion_server_id !== undefined) payload.bastion_server_id = values.bastion_server_id || ""
|
||||||
|
if (values.cluster_load_balancer !== undefined)
|
||||||
|
payload.cluster_load_balancer = values.cluster_load_balancer
|
||||||
|
if (values.control_load_balancer !== undefined)
|
||||||
|
payload.control_load_balancer = values.control_load_balancer
|
||||||
|
if (values.kubeconfig !== undefined && values.kubeconfig.trim())
|
||||||
|
payload.kubeconfig = values.kubeconfig.trim()
|
||||||
|
|
||||||
|
await api.patch<Cluster>(`/api/v1/clusters/${editTarget.id}`, payload)
|
||||||
|
setEditOpen(false)
|
||||||
|
setEditTarget(null)
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DELETE ----
|
||||||
|
async function deleteCluster(id: string) {
|
||||||
|
if (!confirm("Delete this cluster? This cannot be undone.")) return
|
||||||
|
await api.delete(`/api/v1/clusters/${id}`)
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MANAGE NODE POOLS ----
|
||||||
|
const attachForm = useForm<AttachPoolsValues>({
|
||||||
|
resolver: zodResolver(AttachPoolsSchema),
|
||||||
|
defaultValues: { node_pool_ids: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
function openManagePools(c: Cluster) {
|
||||||
|
setManagePoolsTarget(c)
|
||||||
|
attachForm.reset({ node_pool_ids: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitAttachPools = async (values: AttachPoolsValues) => {
|
||||||
|
if (!managePoolsTarget) return
|
||||||
|
await api.post(`/api/v1/clusters/${managePoolsTarget.id}/node_pools`, {
|
||||||
|
node_pool_ids: values.node_pool_ids,
|
||||||
|
})
|
||||||
|
attachForm.reset({ node_pool_ids: [] })
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detachPool(clusterId: string, poolId: string) {
|
||||||
|
if (!confirm("Detach selected node pool?")) return
|
||||||
|
await api.delete(`/api/v1/clusters/${clusterId}/node_pools/${poolId}`)
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachablePools = useMemo(() => {
|
||||||
|
if (!managePoolsTarget) return [] as NodePoolBrief[]
|
||||||
|
const attached = new Set((managePoolsTarget.node_pools || []).map((p) => p.id))
|
||||||
|
return allPools.filter((p) => !attached.has(p.id))
|
||||||
|
}, [managePoolsTarget, allPools])
|
||||||
|
|
||||||
|
// ---- MANAGE BASTION ----
|
||||||
|
const setBastionForm = useForm<SetBastionValues>({
|
||||||
|
resolver: zodResolver(SetBastionSchema),
|
||||||
|
defaultValues: { server_id: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
function openManageBastion(c: Cluster) {
|
||||||
|
setManageBastionTarget(c)
|
||||||
|
setBastionForm.reset({ server_id: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitSetBastion = async (values: SetBastionValues) => {
|
||||||
|
if (!manageBastionTarget) return
|
||||||
|
await api.post(`/api/v1/clusters/${manageBastionTarget.id}/bastion`, {
|
||||||
|
server_id: values.server_id,
|
||||||
|
})
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearBastion() {
|
||||||
|
if (!manageBastionTarget) return
|
||||||
|
if (!confirm("Clear bastion for this cluster?")) return
|
||||||
|
await api.delete(`/api/v1/clusters/${manageBastionTarget.id}/bastion`)
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UI ----
|
||||||
|
if (loading) return <div className="p-6">Loading clusters…</div>
|
||||||
|
if (err) return <div className="p-6 text-red-500">{err}</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-6">
|
<div className="space-y-4 p-6">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<h1 className="mb-4 text-2xl font-bold">Clusters</h1>
|
<h1 className="mb-4 text-2xl font-bold">Clusters</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center gap-2 md:justify-end">
|
||||||
|
<Input
|
||||||
|
className="max-w-xs"
|
||||||
|
placeholder="Filter by name…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void loadAll()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={() => void loadAll()}>
|
||||||
|
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Cluster
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Cluster</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...createForm}>
|
||||||
|
<form onSubmit={createForm.handleSubmit(submitCreate)} className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-eks-prod" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="provider"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Provider</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="aws|gcp|azure|onprem" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="region"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Region</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="eu-west-1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="bastion_server_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Bastion server (UUID, optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="paste server UUID" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{bastionCandidates.length > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Suggestions:
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
{bastionCandidates.slice(0, 6).map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={field.value === s.id ? "default" : "outline"}
|
||||||
|
onClick={() => field.onChange(s.id)}
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
<ServerIcon className="mr-1 h-3 w-3" /> {s.hostname || truncateMiddle(s.id, 6)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="cluster_load_balancer"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<FormLabel>Cluster Load Balancer (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="e.g. JSON or URL or ARN" rows={2} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="control_load_balancer"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<FormLabel>Control Load Balancer (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="e.g. JSON or URL or ARN" rows={2} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Node pools */}
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="node_pool_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<FormLabel>Attach node pools (optional)</FormLabel>
|
||||||
|
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
|
||||||
|
{allPools.length === 0 && (
|
||||||
|
<div className="text-muted-foreground p-2 text-sm">No node pools available</div>
|
||||||
|
)}
|
||||||
|
{allPools.map((p) => {
|
||||||
|
const checked = field.value?.includes(p.id) || false
|
||||||
|
return (
|
||||||
|
<label key={p.id} className="hover:bg-accent flex cursor-pointer items-start gap-2 rounded p-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const next = new Set(field.value || [])
|
||||||
|
if (v === true) next.add(p.id)
|
||||||
|
else next.delete(p.id)
|
||||||
|
field.onChange(Array.from(next))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-medium">{p.name}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{truncateMiddle(p.id, 8)}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* KubeConfig */}
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="kubeconfig"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem className='md:colspan-2'>
|
||||||
|
<FormLabel>Kubeconfig (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Paste full kubeconfig YAML here. It will be encrypted and never returned by the API."
|
||||||
|
rows={8}
|
||||||
|
className="font-mono"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="md:col-span-2 gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createForm.formState.isSubmitting}>
|
||||||
|
{createForm.formState.isSubmitting ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Provider / Region</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Node Pools</TableHead>
|
||||||
|
<TableHead>Bastion</TableHead>
|
||||||
|
<TableHead className="w-[360px] text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{clusters.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm font-medium">{c.provider || "—"}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{c.region || "—"}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={c.status === "ready" ? "default" : c.status === "error" ? "destructive" : "secondary"}>
|
||||||
|
{c.status || "unknown"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex max-w-[280px] flex-wrap gap-2">
|
||||||
|
{(c.node_pools || []).slice(0, 4).map((p) => (
|
||||||
|
<Badge key={p.id} variant="secondary">{p.name}</Badge>
|
||||||
|
))}
|
||||||
|
{(c.node_pools || []).length === 0 && (
|
||||||
|
<span className="text-muted-foreground">No pools</span>
|
||||||
|
)}
|
||||||
|
{(c.node_pools || []).length > 4 && (
|
||||||
|
<span className="text-muted-foreground">+{(c.node_pools || []).length - 4} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{c.bastion_server ? (
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-medium">{c.bastion_server.hostname || truncateMiddle(c.bastion_server.id, 6)}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{c.bastion_server.ip}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">None</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openManagePools(c)}>
|
||||||
|
<LinkIcon className="mr-2 h-4 w-4" /> Manage pools
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openManageBastion(c)}>
|
||||||
|
<ServerIcon className="mr-2 h-4 w-4" /> Bastion
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(c)}>
|
||||||
|
<PencilIcon className="mr-2 h-4 w-4" /> Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => deleteCluster(c.id)}>
|
||||||
|
<TrashIcon className="mr-2 h-4 w-4" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{clusters.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-muted-foreground py-10 text-center">
|
||||||
|
No clusters yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit cluster */}
|
||||||
|
<Dialog open={editOpen} onOpenChange={(o) => !o && setEditOpen(false)}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Edit Cluster
|
||||||
|
{editTarget ? (
|
||||||
|
<span className="text-muted-foreground ml-2 font-mono text-sm">({editTarget.name})</span>
|
||||||
|
) : null}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...editForm}>
|
||||||
|
<form onSubmit={editForm.handleSubmit(submitEdit)} className="grid gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>New name (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={editTarget?.name || "e.g. my-eks-prod"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="provider"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>New provider (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={editTarget?.provider || "aws"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="region"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>New region (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={editTarget?.region || "eu-west-1"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>New status (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={editTarget?.status || "pending|ready|error"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="bastion_server_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<FormLabel>Replace/clear bastion (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="paste new server UUID or leave blank to clear" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="cluster_load_balancer"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<FormLabel>Cluster Load Balancer (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder={editTarget?.cluster_load_balancer || "e.g. JSON or URL or ARN"} rows={2} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="control_load_balancer"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<FormLabel>Control Load Balancer (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder={editTarget?.control_load_balancer || "e.g. JSON or URL or ARN"} rows={2} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* KubeConfig */}
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="kubeconfig"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem className='md:colspan-2'>
|
||||||
|
<FormLabel>Replace Kubeconfig (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Paste NEW kubeconfig YAML to replace the stored one. Leave empty for no change."
|
||||||
|
rows={8}
|
||||||
|
className="font-mono"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="md:col-span-2 gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={editForm.formState.isSubmitting}>
|
||||||
|
{editForm.formState.isSubmitting ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Manage node pools */}
|
||||||
|
<Dialog open={!!managePoolsTarget} onOpenChange={(o) => !o && setManagePoolsTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Manage node pools for <span className="font-mono">{managePoolsTarget?.name}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Attached */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm font-medium">Attached node pools</div>
|
||||||
|
<div className="overflow-hidden rounded-xl border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="w-[120px] text-right">Detach</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(managePoolsTarget?.node_pools || []).map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">{p.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => detachPool(managePoolsTarget!.id, p.id)}>
|
||||||
|
<UnlinkIcon className="mr-2 h-4 w-4" /> Detach
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(managePoolsTarget?.node_pools || []).length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} className="text-muted-foreground py-8 text-center">
|
||||||
|
No pools attached yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attach more */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<Form {...attachForm}>
|
||||||
|
<form onSubmit={attachForm.handleSubmit(submitAttachPools)} className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={attachForm.control}
|
||||||
|
name="node_pool_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Attach more node pools</FormLabel>
|
||||||
|
<div className="grid max-h-64 grid-cols-1 gap-2 overflow-auto rounded-xl border p-2 md:grid-cols-2">
|
||||||
|
{attachablePools.length === 0 && (
|
||||||
|
<div className="text-muted-foreground p-2 text-sm">No more node pools available to attach</div>
|
||||||
|
)}
|
||||||
|
{attachablePools.map((p) => {
|
||||||
|
const checked = field.value?.includes(p.id) || false
|
||||||
|
return (
|
||||||
|
<label key={p.id} className="hover:bg-accent flex cursor-pointer items-start gap-2 rounded p-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const next = new Set(field.value || [])
|
||||||
|
if (v === true) next.add(p.id)
|
||||||
|
else next.delete(p.id)
|
||||||
|
field.onChange(Array.from(next))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-sm font-medium">{p.name}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{truncateMiddle(p.id, 8)}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="submit" disabled={attachForm.formState.isSubmitting}>
|
||||||
|
<LinkIcon className="mr-2 h-4 w-4" />
|
||||||
|
{attachForm.formState.isSubmitting ? "Attaching…" : "Attach selected"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Manage bastion */}
|
||||||
|
<Dialog open={!!manageBastionTarget} onOpenChange={(o) => !o && setManageBastionTarget(null)}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Manage bastion for <span className="font-mono">{manageBastionTarget?.name}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Current</div>
|
||||||
|
<div className="rounded-xl border p-3 text-sm">
|
||||||
|
{manageBastionTarget?.bastion_server ? (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{manageBastionTarget.bastion_server.hostname}</div>
|
||||||
|
<div className="text-muted-foreground">{manageBastionTarget.bastion_server.ip}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground">None</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...setBastionForm}>
|
||||||
|
<form onSubmit={setBastionForm.handleSubmit(submitSetBastion)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={setBastionForm.control}
|
||||||
|
name="server_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>New bastion server (UUID)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="paste server UUID" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{bastionCandidates.length > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Suggestions:
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
{bastionCandidates.slice(0, 8).map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={field.value === s.id ? "default" : "outline"}
|
||||||
|
onClick={() => field.onChange(s.id)}
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
<ServerIcon className="mr-1 h-3 w-3" /> {s.hostname || truncateMiddle(s.id, 6)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => void clearBastion()}>
|
||||||
|
Clear bastion
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={setBastionForm.formState.isSubmitting}>
|
||||||
|
{setBastionForm.formState.isSubmitting ? "Saving…" : "Set bastion"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<pre>{JSON.stringify(clusters, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user