mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 21:00:06 +01:00
Compare commits
161 Commits
v0.8.1
...
renovate/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32fdd6464e | ||
|
|
a40da0c23d | ||
|
|
1c740da56d | ||
|
|
0136c8ef78 | ||
|
|
d4fdd85671 | ||
|
|
5d3f0bfaf6 | ||
|
|
3926bafa5c | ||
|
|
210a056fe6 | ||
|
|
b864187af5 | ||
|
|
10725d9263 | ||
|
|
84d5575ea8 | ||
|
|
b7b2c3c65d | ||
|
|
94cd1ca2df | ||
|
|
9379e8dd05 | ||
|
|
e0b15f6b9e | ||
|
|
accf590d24 | ||
|
|
b910ff52ab | ||
|
|
1e498b309b | ||
|
|
85601bcb84 | ||
|
|
ce348a65ea | ||
|
|
37153a6eed | ||
|
|
936891d110 | ||
|
|
965a5ed88b | ||
|
|
c2b76f1dea | ||
|
|
5c9768b0b2 | ||
|
|
a02c29f333 | ||
|
|
223d89bdd1 | ||
|
|
411c98174a | ||
|
|
a7657368ed | ||
|
|
134153c197 | ||
|
|
b4905b8c59 | ||
|
|
f9dc0d4db5 | ||
|
|
3d6e53d24d | ||
|
|
2f6a382733 | ||
|
|
8261b5d702 | ||
|
|
23415ead6d | ||
|
|
ba8098acac | ||
|
|
da5801cc6b | ||
|
|
809c0930ae | ||
|
|
17af4e8da4 | ||
|
|
039fb04c97 | ||
|
|
335fd10fab | ||
|
|
a3ec862e12 | ||
|
|
66595daa4b | ||
|
|
d6d1806907 | ||
|
|
3c61f66c9a | ||
|
|
0fb6fa3389 | ||
|
|
4cd9f77694 | ||
|
|
f6af0d970f | ||
|
|
1138b346e3 | ||
|
|
4e612652a2 | ||
|
|
4127c0dd06 | ||
|
|
8e97df134a | ||
|
|
636b500108 | ||
|
|
2e3fd44b34 | ||
|
|
2bfd8b0a78 | ||
|
|
c7fb0bcf18 | ||
|
|
c4a0fbd7b8 | ||
|
|
8616336279 | ||
|
|
d1ea3667e6 | ||
|
|
0f562ac5f4 | ||
|
|
810218124e | ||
|
|
5aff256377 | ||
|
|
bdd6b61859 | ||
|
|
42a86b22dd | ||
|
|
b8cb1e1a2a | ||
|
|
5a4ae19900 | ||
|
|
d9db293894 | ||
|
|
19d5ee7251 | ||
|
|
6b91a5760b | ||
|
|
bbd4c86013 | ||
|
|
99ebcb11a3 | ||
|
|
be1b35da3c | ||
|
|
a6a296f283 | ||
|
|
341ecf8b0a | ||
|
|
92998015ec | ||
|
|
9345c2761c | ||
|
|
6944e5d027 | ||
|
|
48b3bf5d3c | ||
|
|
4c595db85e | ||
|
|
79f3259bd6 | ||
|
|
e0d163181a | ||
|
|
e8d568eba7 | ||
|
|
c21a766dab | ||
|
|
495c1551b4 | ||
|
|
4a5c0df481 | ||
|
|
c92bba5518 | ||
|
|
823088e294 | ||
|
|
938689fda3 | ||
|
|
77332f208f | ||
|
|
711488492c | ||
|
|
27b89722a4 | ||
|
|
c8a537e30f | ||
|
|
98de70b96b | ||
|
|
63c4574f9c | ||
|
|
5e85cad5b7 | ||
|
|
53725bb834 | ||
|
|
6611dc4950 | ||
|
|
49665ffc9c | ||
|
|
ac14ef8fff | ||
|
|
f8e543b595 | ||
|
|
8cc81e52b7 | ||
|
|
d6e28c7fa2 | ||
|
|
9832229194 | ||
|
|
da82998754 | ||
|
|
bca32fe784 | ||
|
|
848e8d5179 | ||
|
|
d3ee38881c | ||
|
|
d39db44aa7 | ||
|
|
01b29a4706 | ||
|
|
bc3bd92d54 | ||
|
|
2057f92b82 | ||
|
|
169283b6c7 | ||
|
|
865270312c | ||
|
|
7cc447c0f5 | ||
|
|
8a0345f7f5 | ||
|
|
bb7114efe9 | ||
|
|
9dd0148764 | ||
|
|
bcc69e1c86 | ||
|
|
a7bf6b43b4 | ||
|
|
ced0a0663f | ||
|
|
dac28d3ea5 | ||
|
|
dd0cefc08a | ||
|
|
842f7c9be6 | ||
|
|
c15311a5a1 | ||
|
|
25ced343c4 | ||
|
|
b72a8d384d | ||
|
|
c786a79b60 | ||
|
|
01b1434842 | ||
|
|
e8c9cde474 | ||
|
|
ae92d05cd4 | ||
|
|
67d50d2b15 | ||
|
|
e5a664b812 | ||
|
|
f722ba8dca | ||
|
|
20e6d8d186 | ||
|
|
85f37cd113 | ||
|
|
fd1a81ecd8 | ||
|
|
793daf3ac3 | ||
|
|
7bef4ef6f1 | ||
|
|
9fa9cd169b | ||
|
|
8812b43346 | ||
|
|
21a6d7d5a1 | ||
|
|
da332c89dd | ||
|
|
fd25825f34 | ||
|
|
de3740e974 | ||
|
|
21dd26503f | ||
|
|
e1da229c30 | ||
|
|
5377e521e9 | ||
|
|
a929561bc8 | ||
|
|
c63f9f1cf3 | ||
|
|
4c02179b70 | ||
|
|
c8289c6936 | ||
|
|
c17caf22a2 | ||
|
|
986eeb9bf9 | ||
|
|
b0bbc13946 | ||
|
|
f50dcae823 | ||
|
|
ab9a77e1f5 | ||
|
|
416b2ff4e2 | ||
|
|
20bef7545c | ||
|
|
15e101439b | ||
|
|
fb0901a812 |
6
.github/workflows/docker-publish.yml
vendored
6
.github/workflows/docker-publish.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
# Install the cosign tool except on PR
|
||||||
# https://github.com/sigstore/cosign-installer
|
# https://github.com/sigstore/cosign-installer
|
||||||
@@ -47,13 +47,13 @@ jobs:
|
|||||||
# multi-platform images and export cache
|
# multi-platform images and export cache
|
||||||
# https://github.com/docker/setup-buildx-action
|
# https://github.com/docker/setup-buildx-action
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
# Login against a Docker registry except on PR
|
# Login against a Docker registry except on PR
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#################################
|
#################################
|
||||||
# Builder: Go + Node in one
|
# Builder: Go + Node in one
|
||||||
#################################
|
#################################
|
||||||
FROM golang:1.25.5-alpine@sha256:26111811bc967321e7b6f852e914d14bede324cd1accb7f81811929a6a57fea9 AS builder
|
FROM golang:1.25.7-alpine@sha256:81d49e1de26fa223b9ae0b4d5a4065ff8176a7d80aa5ef0bd9f2eee430afe4d7 AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
bash git ca-certificates tzdata \
|
bash git ca-certificates tzdata \
|
||||||
@@ -24,7 +24,7 @@ RUN make build
|
|||||||
#################################
|
#################################
|
||||||
# Runtime
|
# Runtime
|
||||||
#################################
|
#################################
|
||||||
FROM alpine:3.23@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375
|
FROM alpine:3.23@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata postgresql17-client \
|
RUN apk add --no-cache ca-certificates tzdata postgresql17-client \
|
||||||
&& addgroup -S app && adduser -S app -G app
|
&& addgroup -S app && adduser -S app -G app
|
||||||
|
|||||||
1
Makefile
1
Makefile
@@ -204,6 +204,7 @@ swagger: $(DOCS_JSON) ## Generate Swagger docs if stale
|
|||||||
# --- build ---
|
# --- build ---
|
||||||
build: prepare ui swagger sdk-all ## Build everything: Go hygiene, UI, Swagger, SDKs, then Go binary
|
build: prepare ui swagger sdk-all ## Build everything: Go hygiene, UI, Swagger, SDKs, then Go binary
|
||||||
@echo ">> Building Go binary: $(BIN)"
|
@echo ">> Building Go binary: $(BIN)"
|
||||||
|
@$(GOCMD) get github.com/swaggo/swag/v2@v2.0.0-rc4
|
||||||
@$(GOCMD) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) $(MAIN)
|
@$(GOCMD) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) $(MAIN)
|
||||||
|
|
||||||
# Handy: print resolved version metadata
|
# Handy: print resolved version metadata
|
||||||
|
|||||||
81
cmd/serve.go
81
cmd/serve.go
@@ -116,45 +116,62 @@ var serveCmd = &cobra.Command{
|
|||||||
log.Printf("failed to enqueue bootstrap_bastion: %v", err)
|
log.Printf("failed to enqueue bootstrap_bastion: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = jobs.Enqueue(
|
/*
|
||||||
context.Background(),
|
_, err = jobs.Enqueue(
|
||||||
uuid.NewString(),
|
context.Background(),
|
||||||
"prepare_cluster",
|
uuid.NewString(),
|
||||||
bg.ClusterPrepareArgs{IntervalS: 120},
|
"prepare_cluster",
|
||||||
archer.WithMaxRetries(3),
|
bg.ClusterPrepareArgs{IntervalS: 120},
|
||||||
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
|
archer.WithMaxRetries(3),
|
||||||
)
|
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
|
||||||
if err != nil {
|
)
|
||||||
log.Printf("failed to enqueue prepare_cluster: %v", err)
|
if err != nil {
|
||||||
}
|
log.Printf("failed to enqueue prepare_cluster: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = jobs.Enqueue(
|
||||||
|
context.Background(),
|
||||||
|
uuid.NewString(),
|
||||||
|
"cluster_setup",
|
||||||
|
bg.ClusterSetupArgs{
|
||||||
|
IntervalS: 120,
|
||||||
|
},
|
||||||
|
archer.WithMaxRetries(3),
|
||||||
|
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to enqueue cluster setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = jobs.Enqueue(
|
||||||
|
context.Background(),
|
||||||
|
uuid.NewString(),
|
||||||
|
"cluster_bootstrap",
|
||||||
|
bg.ClusterBootstrapArgs{
|
||||||
|
IntervalS: 120,
|
||||||
|
},
|
||||||
|
archer.WithMaxRetries(3),
|
||||||
|
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to enqueue cluster bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
_, err = jobs.Enqueue(
|
_, err = jobs.Enqueue(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
uuid.NewString(),
|
uuid.NewString(),
|
||||||
"cluster_setup",
|
"org_key_sweeper",
|
||||||
bg.ClusterSetupArgs{
|
bg.OrgKeySweeperArgs{
|
||||||
IntervalS: 120,
|
IntervalS: 3600,
|
||||||
|
RetentionDays: 10,
|
||||||
},
|
},
|
||||||
archer.WithMaxRetries(3),
|
archer.WithMaxRetries(1),
|
||||||
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
|
archer.WithScheduleTime(time.Now()),
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("failed to enqueue cluster setup: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = jobs.Enqueue(
|
|
||||||
context.Background(),
|
|
||||||
uuid.NewString(),
|
|
||||||
"cluster_bootstrap",
|
|
||||||
bg.ClusterBootstrapArgs{
|
|
||||||
IntervalS: 120,
|
|
||||||
},
|
|
||||||
archer.WithMaxRetries(3),
|
|
||||||
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to enqueue cluster bootstrap: %v", err)
|
log.Printf("failed to enqueue org_key_sweeper: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit@sha256:e22dce5b36f93c77082e204a3942fb6b283b7896e057458400a4c88344c3df68
|
image: axllent/mailpit@sha256:c076638db1e15662150be4fb62b8a6e96ef6ba5bde90c838a0239225854830f7
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "1025:1025"
|
- "1025:1025"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,23 @@
|
|||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
|
dto.ActionResponse:
|
||||||
|
properties:
|
||||||
|
created_at:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
format: uuid
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
make_target:
|
||||||
|
type: string
|
||||||
|
updated_at:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
dto.AnnotationResponse:
|
dto.AnnotationResponse:
|
||||||
properties:
|
properties:
|
||||||
created_at:
|
created_at:
|
||||||
@@ -104,6 +122,8 @@ components:
|
|||||||
$ref: '#/components/schemas/dto.LoadBalancerResponse'
|
$ref: '#/components/schemas/dto.LoadBalancerResponse'
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
kubeconfig:
|
||||||
|
type: string
|
||||||
last_error:
|
last_error:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
@@ -113,6 +133,10 @@ components:
|
|||||||
$ref: '#/components/schemas/dto.NodePoolResponse'
|
$ref: '#/components/schemas/dto.NodePoolResponse'
|
||||||
type: array
|
type: array
|
||||||
uniqueItems: false
|
uniqueItems: false
|
||||||
|
org_key:
|
||||||
|
type: string
|
||||||
|
org_secret:
|
||||||
|
type: string
|
||||||
random_token:
|
random_token:
|
||||||
type: string
|
type: string
|
||||||
region:
|
region:
|
||||||
@@ -122,6 +146,42 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
dto.ClusterRunResponse:
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
cluster_id:
|
||||||
|
format: uuid
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
finished_at:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
format: uuid
|
||||||
|
type: string
|
||||||
|
organization_id:
|
||||||
|
format: uuid
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
updated_at:
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
dto.CreateActionRequest:
|
||||||
|
properties:
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
make_target:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
dto.CreateAnnotationRequest:
|
dto.CreateAnnotationRequest:
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
@@ -710,6 +770,15 @@ components:
|
|||||||
example: Bearer
|
example: Bearer
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
dto.UpdateActionRequest:
|
||||||
|
properties:
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
make_target:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
dto.UpdateAnnotationRequest:
|
dto.UpdateAnnotationRequest:
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
@@ -1037,6 +1106,8 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
models.APIKey:
|
models.APIKey:
|
||||||
properties:
|
properties:
|
||||||
|
cluster_id:
|
||||||
|
type: string
|
||||||
created_at:
|
created_at:
|
||||||
format: date-time
|
format: date-time
|
||||||
type: string
|
type: string
|
||||||
@@ -1046,6 +1117,8 @@ components:
|
|||||||
id:
|
id:
|
||||||
format: uuid
|
format: uuid
|
||||||
type: string
|
type: string
|
||||||
|
is_ephemeral:
|
||||||
|
type: boolean
|
||||||
last_used_at:
|
last_used_at:
|
||||||
format: date-time
|
format: date-time
|
||||||
type: string
|
type: string
|
||||||
@@ -1056,6 +1129,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
prefix:
|
prefix:
|
||||||
type: string
|
type: string
|
||||||
|
purpose:
|
||||||
|
type: string
|
||||||
revoked:
|
revoked:
|
||||||
type: boolean
|
type: boolean
|
||||||
scope:
|
scope:
|
||||||
@@ -1190,6 +1265,222 @@ paths:
|
|||||||
summary: Get JWKS
|
summary: Get JWKS
|
||||||
tags:
|
tags:
|
||||||
- Auth
|
- Auth
|
||||||
|
/admin/actions:
|
||||||
|
get:
|
||||||
|
description: Returns all admin-configured actions.
|
||||||
|
operationId: ListActions
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/dto.ActionResponse'
|
||||||
|
type: array
|
||||||
|
description: OK
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unauthorized
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: db error
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: List available actions
|
||||||
|
tags:
|
||||||
|
- Actions
|
||||||
|
post:
|
||||||
|
description: Creates a new admin-configured action.
|
||||||
|
operationId: CreateAction
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/dto.CreateActionRequest'
|
||||||
|
description: payload
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/dto.ActionResponse'
|
||||||
|
description: Created
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: bad request
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unauthorized
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: db error
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Create an action
|
||||||
|
tags:
|
||||||
|
- Actions
|
||||||
|
/admin/actions/{actionID}:
|
||||||
|
delete:
|
||||||
|
description: Deletes an action.
|
||||||
|
operationId: DeleteAction
|
||||||
|
parameters:
|
||||||
|
- description: Action ID
|
||||||
|
in: path
|
||||||
|
name: actionID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: deleted
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: bad request
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unauthorized
|
||||||
|
"404":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: not found
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: db error
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Delete an action
|
||||||
|
tags:
|
||||||
|
- Actions
|
||||||
|
get:
|
||||||
|
description: Returns a single action.
|
||||||
|
operationId: GetAction
|
||||||
|
parameters:
|
||||||
|
- description: Action ID
|
||||||
|
in: path
|
||||||
|
name: actionID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/dto.ActionResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: bad request
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unauthorized
|
||||||
|
"404":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: not found
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: db error
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get a single action by ID
|
||||||
|
tags:
|
||||||
|
- Actions
|
||||||
|
patch:
|
||||||
|
description: Updates an action. Only provided fields are modified.
|
||||||
|
operationId: UpdateAction
|
||||||
|
parameters:
|
||||||
|
- description: Action ID
|
||||||
|
in: path
|
||||||
|
name: actionID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/dto.UpdateActionRequest'
|
||||||
|
description: payload
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/dto.ActionResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: bad request
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unauthorized
|
||||||
|
"404":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: not found
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: db error
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Update an action
|
||||||
|
tags:
|
||||||
|
- Actions
|
||||||
/admin/archer/jobs:
|
/admin/archer/jobs:
|
||||||
get:
|
get:
|
||||||
description: Paginated background jobs with optional filters. Search `q` may
|
description: Paginated background jobs with optional filters. Search `q` may
|
||||||
@@ -2112,6 +2403,73 @@ paths:
|
|||||||
summary: Update basic cluster details (org scoped)
|
summary: Update basic cluster details (org scoped)
|
||||||
tags:
|
tags:
|
||||||
- Clusters
|
- Clusters
|
||||||
|
/clusters/{clusterID}/actions/{actionID}/runs:
|
||||||
|
post:
|
||||||
|
description: Creates a ClusterRun record for the cluster/action. Execution is
|
||||||
|
handled asynchronously by workers.
|
||||||
|
operationId: RunClusterAction
|
||||||
|
parameters:
|
||||||
|
- description: Organization UUID
|
||||||
|
in: header
|
||||||
|
name: X-Org-ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: Cluster ID
|
||||||
|
in: path
|
||||||
|
name: clusterID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: Action ID
|
||||||
|
in: path
|
||||||
|
name: actionID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/dto.ClusterRunResponse'
|
||||||
|
description: Created
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: bad request
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unauthorized
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: organization required
|
||||||
|
"404":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: cluster or action not found
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: db error
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
- OrgKeyAuth: []
|
||||||
|
- OrgSecretAuth: []
|
||||||
|
summary: Run an admin-configured action on a cluster (org scoped)
|
||||||
|
tags:
|
||||||
|
- ClusterRuns
|
||||||
/clusters/{clusterID}/apps-load-balancer:
|
/clusters/{clusterID}/apps-load-balancer:
|
||||||
delete:
|
delete:
|
||||||
description: Clears apps_load_balancer_id on the cluster.
|
description: Clears apps_load_balancer_id on the cluster.
|
||||||
@@ -3005,6 +3363,128 @@ paths:
|
|||||||
summary: Detach a node pool from a cluster
|
summary: Detach a node pool from a cluster
|
||||||
tags:
|
tags:
|
||||||
- Clusters
|
- Clusters
|
||||||
|
/clusters/{clusterID}/runs:
|
||||||
|
get:
|
||||||
|
description: Returns runs for a cluster within the organization in X-Org-ID.
|
||||||
|
operationId: ListClusterRuns
|
||||||
|
parameters:
|
||||||
|
- description: Organization UUID
|
||||||
|
in: header
|
||||||
|
name: X-Org-ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: Cluster ID
|
||||||
|
in: path
|
||||||
|
name: clusterID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/dto.ClusterRunResponse'
|
||||||
|
type: array
|
||||||
|
description: OK
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unauthorized
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: organization required
|
||||||
|
"404":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: cluster not found
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: db error
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
- OrgKeyAuth: []
|
||||||
|
- OrgSecretAuth: []
|
||||||
|
summary: List cluster runs (org scoped)
|
||||||
|
tags:
|
||||||
|
- ClusterRuns
|
||||||
|
/clusters/{clusterID}/runs/{runID}:
|
||||||
|
get:
|
||||||
|
description: Returns a single run for a cluster within the organization in X-Org-ID.
|
||||||
|
operationId: GetClusterRun
|
||||||
|
parameters:
|
||||||
|
- description: Organization UUID
|
||||||
|
in: header
|
||||||
|
name: X-Org-ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: Cluster ID
|
||||||
|
in: path
|
||||||
|
name: clusterID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: Run ID
|
||||||
|
in: path
|
||||||
|
name: runID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/dto.ClusterRunResponse'
|
||||||
|
description: OK
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: bad request
|
||||||
|
"401":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Unauthorized
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: organization required
|
||||||
|
"404":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: not found
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: db error
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
- OrgKeyAuth: []
|
||||||
|
- OrgSecretAuth: []
|
||||||
|
summary: Get a cluster run (org scoped)
|
||||||
|
tags:
|
||||||
|
- ClusterRuns
|
||||||
/credentials:
|
/credentials:
|
||||||
get:
|
get:
|
||||||
description: Returns credential metadata for the current org. Secrets are never
|
description: Returns credential metadata for the current org. Secrets are never
|
||||||
@@ -3684,6 +4164,42 @@ paths:
|
|||||||
external deletion policy)
|
external deletion policy)
|
||||||
tags:
|
tags:
|
||||||
- DNS
|
- DNS
|
||||||
|
get:
|
||||||
|
operationId: GetRecordSet
|
||||||
|
parameters:
|
||||||
|
- description: Organization UUID
|
||||||
|
in: header
|
||||||
|
name: X-Org-ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: Record Set ID (UUID)
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/dto.RecordSetResponse'
|
||||||
|
description: OK
|
||||||
|
"403":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: organization required
|
||||||
|
"404":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: not found
|
||||||
|
summary: Get a record set (org scoped)
|
||||||
|
tags:
|
||||||
|
- DNS
|
||||||
patch:
|
patch:
|
||||||
operationId: UpdateRecordSet
|
operationId: UpdateRecordSet
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
60
go.mod
60
go.mod
@@ -4,28 +4,29 @@ go 1.25.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexedwards/argon2id v1.0.0
|
github.com/alexedwards/argon2id v1.0.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.40.1
|
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.3
|
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.3
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.61.1
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||||
|
github.com/aws/smithy-go v1.24.0
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/dyaksa/archer v1.1.5
|
github.com/dyaksa/archer v1.1.5
|
||||||
github.com/fergusstrange/embedded-postgres v1.33.0
|
github.com/fergusstrange/embedded-postgres v1.33.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/go-chi/httprate v0.15.0
|
github.com/go-chi/httprate v0.15.0
|
||||||
github.com/go-playground/validator/v10 v10.28.0
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/sosedoff/pgweb v0.17.0
|
github.com/sosedoff/pgweb v0.17.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/swaggo/swag/v2 v2.0.0-rc4
|
github.com/swaggo/swag/v2 v2.0.0-rc5
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/oauth2 v0.34.0
|
golang.org/x/oauth2 v0.34.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/datatypes v1.2.7
|
gorm.io/datatypes v1.2.7
|
||||||
@@ -39,20 +40,19 @@ require (
|
|||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 // indirect
|
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 // indirect
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||||
github.com/aws/smithy-go v1.24.0 // indirect
|
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
@@ -61,7 +61,7 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
|
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||||
@@ -113,7 +113,7 @@ require (
|
|||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/sv-tools/openapi v0.2.1 // indirect
|
github.com/sv-tools/openapi v0.4.0 // indirect
|
||||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 // indirect
|
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
@@ -122,12 +122,12 @@ require (
|
|||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gorm.io/driver/mysql v1.5.7 // indirect
|
gorm.io/driver/mysql v1.5.7 // indirect
|
||||||
|
|||||||
170
go.sum
170
go.sum
@@ -10,44 +10,78 @@ github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 h1:VauE2GcJNZFun2O
|
|||||||
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o=
|
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o=
|
||||||
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc=
|
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.3 h1:cpz7H2uMNTDa0h/5CYL5dLUEzPSLo2g0NkbxTRJtSSU=
|
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.3/go.mod h1:srtPKaJJe3McW6T/+GMBZyIPc+SeqJsNPJsd4mOYZ6s=
|
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.3 h1:01Ym72hK43hjwDeJUfi1l2oYLXBAOR8gNSZNmXmvuas=
|
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.3/go.mod h1:55nWF/Sr9Zvls0bGnWkRxUdhzKqj9uRNlPvgV1vgxKc=
|
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 h1:utxLraaifrSBkeyII9mIbVwXXWrZdlPO7FIKmyLCEcY=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15/go.mod h1:hW6zjYUDQwfz3icf4g2O41PHi77u10oAzJ84iSzR/lo=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 h1:Y5YXgygXwDI5P4RkteB5yF7v35neH7LfJKBG+hzIons=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15/go.mod h1:K+/1EpG42dFSY7CBj+Fruzm8PsCGWTXJ3jdeJ659oGQ=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 h1:AvltKnW9ewxX2hFmQS0FyJH93aSvJVUEFvXfU+HWtSE=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15/go.mod h1:3I4oCdZdmgrREhU74qS1dK9yZ62yumob+58AbFR4cQA=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 h1:P1MU/SuhadGvg2jtviDXPEejU3jBNhoeeAlRadHzvHI=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6/go.mod h1:5KYaMG6wmVKMFBSfWoyG/zH8pWwzQFnKgpoSRlXHKdQ=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 h1:3/u/4yZOffg5jdNk1sDpOQ4Y+R6Xbh+GzpDrSZjuy3U=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15/go.mod h1:4Zkjq0FKjE78NKjabuM4tRXKFzUJWXgP0ItEZK8l7JU=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1crz0Ap7VBZrV4nNqZt4CIBBT8mnwoNc=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.61.1 h1:ik9tMw+xWZqzffOtGH3PfV0Yy/V+QsCb1XYXXXjUskk=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.61.1/go.mod h1:JRqmldxIPU6uck5bcFS8ExwwG2mUwfy+jiUmismOxJs=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 h1:d/6xOGIllc/XW1lzG9a4AUBMmpLA9PXcQnVPTuHHcik=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3/go.mod h1:fQ7E7Qj9GiW8y0ClD7cUJk3Bz5Iw8wZkWDHsTe8vDKs=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 h1:8sTTiw+9yuNXcfWeqKF2x01GqCF49CpP4Z9nKrrk/ts=
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 h1:80pDB3Tpmb2RCSZORrK9/3iQxsd+w6vSzVqpT1FGiwE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6/go.mod h1:8WYg+Y40Sn3X2hioaaWAAIngndR8n1XFdRPPX+7QBaM=
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0/go.mod h1:6EZUGGNLPLh5Unt30uEoA+KQcByERfXIkax9qrc80nA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 h1:E+KqWoVsSrj1tJ6I/fjDIu5xoS2Zacuu1zT+H7KtiIk=
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11/go.mod h1:qyWHz+4lvkXcr3+PoGlGHEI+3DLLiU6/GdrFfMaAhB0=
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 h1:tzMkjh0yTChUqJDgGkcDdxvZDSrJ/WB6R6ymI5ehqJI=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3/go.mod h1:T270C0R5sZNLbWUe8ueiAF42XSZxxPocTaGSgs5c/60=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -79,14 +113,18 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||||
@@ -112,8 +150,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
|||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
@@ -127,6 +165,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
|||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
@@ -263,8 +303,12 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
|
|||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/sv-tools/openapi v0.2.1 h1:ES1tMQMJFGibWndMagvdoo34T1Vllxr1Nlm5wz6b1aA=
|
github.com/sv-tools/openapi v0.2.1 h1:ES1tMQMJFGibWndMagvdoo34T1Vllxr1Nlm5wz6b1aA=
|
||||||
github.com/sv-tools/openapi v0.2.1/go.mod h1:k5VuZamTw1HuiS9p2Wl5YIDWzYnHG6/FgPOSFXLAhGg=
|
github.com/sv-tools/openapi v0.2.1/go.mod h1:k5VuZamTw1HuiS9p2Wl5YIDWzYnHG6/FgPOSFXLAhGg=
|
||||||
|
github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4=
|
||||||
|
github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE=
|
||||||
github.com/swaggo/swag/v2 v2.0.0-rc4 h1:SZ8cK68gcV6cslwrJMIOqPkJELRwq4gmjvk77MrvHvY=
|
github.com/swaggo/swag/v2 v2.0.0-rc4 h1:SZ8cK68gcV6cslwrJMIOqPkJELRwq4gmjvk77MrvHvY=
|
||||||
github.com/swaggo/swag/v2 v2.0.0-rc4/go.mod h1:Ow7Y8gF16BTCDn8YxZbyKn8FkMLRUHekv1kROJZpbvE=
|
github.com/swaggo/swag/v2 v2.0.0-rc4/go.mod h1:Ow7Y8gF16BTCDn8YxZbyKn8FkMLRUHekv1kROJZpbvE=
|
||||||
|
github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk=
|
||||||
|
github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI=
|
||||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u242MzDP6D0B5vGC+wxm5WRY+alQZy+dJk3bFI=
|
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u242MzDP6D0B5vGC+wxm5WRY+alQZy+dJk3bFI=
|
||||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/go.mod h1:a06d/M1pxWi51qiSrfGMHaEydtuXT06nha8N2aNQuXk=
|
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/go.mod h1:a06d/M1pxWi51qiSrfGMHaEydtuXT06nha8N2aNQuXk=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
@@ -290,12 +334,20 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||||||
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
@@ -304,15 +356,17 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -328,29 +382,41 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
|||||||
@@ -22,5 +22,16 @@ func mountAdminRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authUser func(ht
|
|||||||
archer.Post("/jobs/{id}/cancel", handlers.AdminCancelArcherJob(db))
|
archer.Post("/jobs/{id}/cancel", handlers.AdminCancelArcherJob(db))
|
||||||
archer.Get("/queues", handlers.AdminListArcherQueues(db))
|
archer.Get("/queues", handlers.AdminListArcherQueues(db))
|
||||||
})
|
})
|
||||||
|
admin.Route("/actions", func(action chi.Router) {
|
||||||
|
action.Use(authUser)
|
||||||
|
action.Use(httpmiddleware.RequirePlatformAdmin())
|
||||||
|
|
||||||
|
action.Get("/", handlers.ListActions(db))
|
||||||
|
action.Post("/", handlers.CreateAction(db))
|
||||||
|
|
||||||
|
action.Get("/{actionID}", handlers.GetAction(db))
|
||||||
|
action.Patch("/{actionID}", handlers.UpdateAction(db))
|
||||||
|
action.Delete("/{actionID}", handlers.DeleteAction(db))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
|
|||||||
mountNodePoolRoutes(v1, db, authOrg)
|
mountNodePoolRoutes(v1, db, authOrg)
|
||||||
mountDNSRoutes(v1, db, authOrg)
|
mountDNSRoutes(v1, db, authOrg)
|
||||||
mountLoadBalancerRoutes(v1, db, authOrg)
|
mountLoadBalancerRoutes(v1, db, authOrg)
|
||||||
mountClusterRoutes(v1, db, authOrg)
|
mountClusterRoutes(v1, db, jobs, authOrg)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/glueops/autoglue/internal/bg"
|
||||||
"github.com/glueops/autoglue/internal/handlers"
|
"github.com/glueops/autoglue/internal/handlers"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mountClusterRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
|
func mountClusterRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authOrg func(http.Handler) http.Handler) {
|
||||||
r.Route("/clusters", func(c chi.Router) {
|
r.Route("/clusters", func(c chi.Router) {
|
||||||
c.Use(authOrg)
|
c.Use(authOrg)
|
||||||
c.Get("/", handlers.ListClusters(db))
|
c.Get("/", handlers.ListClusters(db))
|
||||||
@@ -36,6 +37,10 @@ func mountClusterRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) ht
|
|||||||
c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
|
c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
|
||||||
|
|
||||||
c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db))
|
c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db))
|
||||||
c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DeleteNodePool(db))
|
c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(db))
|
||||||
|
|
||||||
|
c.Get("/{clusterID}/runs", handlers.ListClusterRuns(db))
|
||||||
|
c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db))
|
||||||
|
c.Post("/{clusterID}/actions/{actionID}/runs", handlers.RunClusterAction(db, jobs))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func mountDNSRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.H
|
|||||||
|
|
||||||
d.Get("/domains/{domain_id}/records", handlers.ListRecordSets(db))
|
d.Get("/domains/{domain_id}/records", handlers.ListRecordSets(db))
|
||||||
d.Post("/domains/{domain_id}/records", handlers.CreateRecordSet(db))
|
d.Post("/domains/{domain_id}/records", handlers.CreateRecordSet(db))
|
||||||
|
d.Get("/records/{id}", handlers.GetRecordSet(db))
|
||||||
d.Patch("/records/{id}", handlers.UpdateRecordSet(db))
|
d.Patch("/records/{id}", handlers.UpdateRecordSet(db))
|
||||||
d.Delete("/records/{id}", handlers.DeleteRecordSet(db))
|
d.Delete("/records/{id}", handlers.DeleteRecordSet(db))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
|
|||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(SecurityHeaders)
|
r.Use(SecurityHeaders)
|
||||||
r.Use(requestBodyLimit(10 << 20))
|
r.Use(requestBodyLimit(10 << 20))
|
||||||
r.Use(httprate.LimitByIP(100, 1*time.Minute))
|
r.Use(httprate.LimitByIP(1000, 1*time.Minute))
|
||||||
r.Use(middleware.StripSlashes)
|
r.Use(middleware.StripSlashes)
|
||||||
|
|
||||||
allowed := getAllowedOrigins()
|
allowed := getAllowedOrigins()
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ func NewRuntime() *Runtime {
|
|||||||
&models.RecordSet{},
|
&models.RecordSet{},
|
||||||
&models.LoadBalancer{},
|
&models.LoadBalancer{},
|
||||||
&models.Cluster{},
|
&models.Cluster{},
|
||||||
|
&models.Action{},
|
||||||
|
&models.Cluster{},
|
||||||
|
&models.ClusterRun{},
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -107,28 +107,42 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
|
|||||||
archer.WithInstances(1),
|
archer.WithInstances(1),
|
||||||
archer.WithTimeout(2*time.Minute),
|
archer.WithTimeout(2*time.Minute),
|
||||||
)
|
)
|
||||||
|
/*
|
||||||
|
c.Register(
|
||||||
|
"prepare_cluster",
|
||||||
|
ClusterPrepareWorker(gdb, jobs),
|
||||||
|
archer.WithInstances(1),
|
||||||
|
archer.WithTimeout(2*time.Minute),
|
||||||
|
)
|
||||||
|
|
||||||
|
c.Register(
|
||||||
|
"cluster_setup",
|
||||||
|
ClusterSetupWorker(gdb, jobs),
|
||||||
|
archer.WithInstances(1),
|
||||||
|
archer.WithTimeout(2*time.Minute),
|
||||||
|
)
|
||||||
|
|
||||||
|
c.Register(
|
||||||
|
"cluster_bootstrap",
|
||||||
|
ClusterBootstrapWorker(gdb, jobs),
|
||||||
|
archer.WithInstances(1),
|
||||||
|
archer.WithTimeout(60*time.Minute),
|
||||||
|
)
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
c.Register(
|
c.Register(
|
||||||
"prepare_cluster",
|
"org_key_sweeper",
|
||||||
ClusterPrepareWorker(gdb, jobs),
|
OrgKeySweeperWorker(gdb, jobs),
|
||||||
archer.WithInstances(1),
|
archer.WithInstances(1),
|
||||||
archer.WithTimeout(2*time.Minute),
|
archer.WithTimeout(5*time.Minute),
|
||||||
)
|
)
|
||||||
|
|
||||||
c.Register(
|
c.Register(
|
||||||
"cluster_setup",
|
"cluster_action",
|
||||||
ClusterSetupWorker(gdb, jobs),
|
ClusterActionWorker(gdb),
|
||||||
archer.WithInstances(1),
|
archer.WithInstances(1),
|
||||||
archer.WithTimeout(2*time.Minute),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
c.Register(
|
|
||||||
"cluster_bootstrap",
|
|
||||||
ClusterBootstrapWorker(gdb, jobs),
|
|
||||||
archer.WithInstances(1),
|
|
||||||
archer.WithTimeout(60*time.Minute),
|
|
||||||
)
|
|
||||||
|
|
||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
195
internal/bg/cluster_action.go
Normal file
195
internal/bg/cluster_action.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package bg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dyaksa/archer"
|
||||||
|
"github.com/dyaksa/archer/job"
|
||||||
|
"github.com/glueops/autoglue/internal/mapper"
|
||||||
|
"github.com/glueops/autoglue/internal/models"
|
||||||
|
"github.com/glueops/autoglue/internal/utils"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClusterActionArgs struct {
|
||||||
|
OrgID uuid.UUID `json:"org_id"`
|
||||||
|
ClusterID uuid.UUID `json:"cluster_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
MakeTarget string `json:"make_target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClusterActionResult struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ClusterID string `json:"cluster_id"`
|
||||||
|
ElapsedMs int `json:"elapsed_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClusterActionWorker(db *gorm.DB) archer.WorkerFn {
|
||||||
|
return func(ctx context.Context, j job.Job) (any, error) {
|
||||||
|
start := time.Now()
|
||||||
|
var args ClusterActionArgs
|
||||||
|
_ = j.ParseArguments(&args)
|
||||||
|
|
||||||
|
runID, _ := uuid.Parse(j.ID)
|
||||||
|
|
||||||
|
updateRun := func(status string, errMsg string) {
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"error": errMsg,
|
||||||
|
}
|
||||||
|
if status == "succeeded" || status == "failed" {
|
||||||
|
updates["finished_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
db.Model(&models.ClusterRun{}).Where("id = ?", runID).Updates(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRun("running", "")
|
||||||
|
|
||||||
|
logger := log.With().
|
||||||
|
Str("job", j.ID).
|
||||||
|
Str("cluster_id", args.ClusterID.String()).
|
||||||
|
Str("action", args.Action).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
var c models.Cluster
|
||||||
|
if err := db.
|
||||||
|
Preload("BastionServer.SshKey").
|
||||||
|
Preload("CaptainDomain").
|
||||||
|
Preload("ControlPlaneRecordSet").
|
||||||
|
Preload("AppsLoadBalancer").
|
||||||
|
Preload("GlueOpsLoadBalancer").
|
||||||
|
Preload("NodePools").
|
||||||
|
Preload("NodePools.Labels").
|
||||||
|
Preload("NodePools.Annotations").
|
||||||
|
Preload("NodePools.Taints").
|
||||||
|
Preload("NodePools.Servers.SshKey").
|
||||||
|
Where("id = ? AND organization_id = ?", args.ClusterID, args.OrgID).
|
||||||
|
First(&c).Error; err != nil {
|
||||||
|
updateRun("failed", fmt.Errorf("load cluster: %w", err).Error())
|
||||||
|
return nil, fmt.Errorf("load cluster: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Step 1: Prepare (mostly lifted from ClusterPrepareWorker)
|
||||||
|
if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("mark bootstrapping: %w", err)
|
||||||
|
}
|
||||||
|
c.Status = clusterStatusBootstrapping
|
||||||
|
|
||||||
|
if err := validateClusterForPrepare(&c); err != nil {
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("validate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allServers := flattenClusterServers(&c)
|
||||||
|
keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, &c, allServers)
|
||||||
|
if err != nil {
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("build ssh assets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dtoCluster := mapper.ClusterToDTO(c)
|
||||||
|
|
||||||
|
if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
|
||||||
|
kubeconfig, err := utils.DecryptForOrg(
|
||||||
|
c.OrganizationID,
|
||||||
|
c.EncryptedKubeconfig,
|
||||||
|
c.KubeIV,
|
||||||
|
c.KubeTag,
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||||
|
return nil, fmt.Errorf("decrypt kubeconfig: %w", err)
|
||||||
|
}
|
||||||
|
dtoCluster.Kubeconfig = &kubeconfig
|
||||||
|
}
|
||||||
|
|
||||||
|
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(db, c.OrganizationID, c.ID, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("org key: %w", err)
|
||||||
|
}
|
||||||
|
dtoCluster.OrgKey = &orgKey
|
||||||
|
dtoCluster.OrgSecret = &orgSecret
|
||||||
|
|
||||||
|
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
|
||||||
|
err := pushAssetsToBastion(runCtx, db, &c, sshConfig, keyPayloads, payloadJSON)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("push assets: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("mark pending: %w", err)
|
||||||
|
}
|
||||||
|
c.Status = clusterStatusPending
|
||||||
|
|
||||||
|
// ---- Step 2: Setup (ping-servers)
|
||||||
|
{
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||||
|
out, err := runMakeOnBastion(runCtx, db, &c, "ping-servers")
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Str("output", out).Msg("ping-servers failed")
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("ping-servers: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("mark provisioning: %w", err)
|
||||||
|
}
|
||||||
|
c.Status = clusterStatusProvisioning
|
||||||
|
|
||||||
|
// ---- Step 3: Bootstrap (parameterized target)
|
||||||
|
{
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, 60*time.Minute)
|
||||||
|
out, err := runMakeOnBastion(runCtx, db, &c, args.MakeTarget)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Str("output", out).Msg("bootstrap target failed")
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make %s: %v", args.MakeTarget, err))
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("make %s: %w", args.MakeTarget, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
|
||||||
|
updateRun("failed", err.Error())
|
||||||
|
return nil, fmt.Errorf("mark ready: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRun("succeeded", "")
|
||||||
|
|
||||||
|
return ClusterActionResult{
|
||||||
|
Status: "ok",
|
||||||
|
Action: args.Action,
|
||||||
|
ClusterID: c.ID.String(),
|
||||||
|
ElapsedMs: int(time.Since(start).Milliseconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,14 +68,14 @@ func ClusterBootstrapWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
|||||||
logger.Info().Msg("[cluster_bootstrap] running make bootstrap")
|
logger.Info().Msg("[cluster_bootstrap] running make bootstrap")
|
||||||
|
|
||||||
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
|
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
|
||||||
out, err := runMakeOnBastion(runCtx, db, c, "bootstrap")
|
out, err := runMakeOnBastion(runCtx, db, c, "setup")
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failCount++
|
failCount++
|
||||||
failedIDs = append(failedIDs, c.ID)
|
failedIDs = append(failedIDs, c.ID)
|
||||||
logger.Error().Err(err).Str("output", out).Msg("[cluster_bootstrap] make bootstrap failed")
|
logger.Error().Err(err).Str("output", out).Msg("[cluster_bootstrap] make setup failed")
|
||||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make bootstrap: %v", err))
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make setup: %v", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ func ClusterSetupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
|||||||
logger.Info().Msg("[cluster_setup] running make setup")
|
logger.Info().Msg("[cluster_setup] running make setup")
|
||||||
|
|
||||||
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
|
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
|
||||||
out, err := runMakeOnBastion(runCtx, db, c, "setup")
|
out, err := runMakeOnBastion(runCtx, db, c, "ping-servers")
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failCount++
|
failCount++
|
||||||
failedIDs = append(failedIDs, c.ID)
|
failedIDs = append(failedIDs, c.ID)
|
||||||
logger.Error().Err(err).Str("output", out).Msg("[cluster_setup] make setup failed")
|
logger.Error().Err(err).Str("output", out).Msg("[cluster_setup] make ping-servers failed")
|
||||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make setup: %v", err))
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/glueops/autoglue/internal/models"
|
"github.com/glueops/autoglue/internal/models"
|
||||||
"github.com/glueops/autoglue/internal/utils"
|
"github.com/glueops/autoglue/internal/utils"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
r53 "github.com/aws/aws-sdk-go-v2/service/route53"
|
r53 "github.com/aws/aws-sdk-go-v2/service/route53"
|
||||||
r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
|
r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
|
||||||
|
"github.com/aws/smithy-go"
|
||||||
|
smithyhttp "github.com/aws/smithy-go/transport/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
/************* args & small DTOs *************/
|
/************* args & small DTOs *************/
|
||||||
@@ -47,6 +50,9 @@ const externalDNSPoisonOwner = "autoglue-lock"
|
|||||||
// ExternalDNS poison content – fake owner so real external-dns skips it.
|
// ExternalDNS poison content – fake owner so real external-dns skips it.
|
||||||
const externalDNSPoisonValue = "heritage=external-dns,external-dns/owner=" + externalDNSPoisonOwner + ",external-dns/resource=manual/autoglue"
|
const externalDNSPoisonValue = "heritage=external-dns,external-dns/owner=" + externalDNSPoisonOwner + ",external-dns/resource=manual/autoglue"
|
||||||
|
|
||||||
|
// Default TTL for non-alias records (alias not supported in this reconciler)
|
||||||
|
const defaultRecordTTLSeconds int64 = 300
|
||||||
|
|
||||||
/************* entrypoint worker *************/
|
/************* entrypoint worker *************/
|
||||||
|
|
||||||
func DNSReconsileWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
func DNSReconsileWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
||||||
@@ -225,7 +231,14 @@ func processPendingRecordsForDomain(ctx context.Context, db *gorm.DB, d *models.
|
|||||||
applied := 0
|
applied := 0
|
||||||
for i := range records {
|
for i := range records {
|
||||||
if err := applyRecord(ctx, db, r53c, d, &records[i]); err != nil {
|
if err := applyRecord(ctx, db, r53c, d, &records[i]); err != nil {
|
||||||
log.Error().Err(err).Str("rr", records[i].Name).Msg("[dns] apply record failed")
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("zone_id", d.ZoneID).
|
||||||
|
Str("domain", d.DomainName).
|
||||||
|
Str("record_id", records[i].ID.String()).
|
||||||
|
Str("name", records[i].Name).
|
||||||
|
Str("type", strings.ToUpper(records[i].Type)).
|
||||||
|
Msg("[dns] apply record failed")
|
||||||
_ = setRecordFailed(db, &records[i], err)
|
_ = setRecordFailed(db, &records[i], err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -249,12 +262,24 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
|
|||||||
mname := markerName(fq)
|
mname := markerName(fq)
|
||||||
expected := buildMarkerValue(d.OrganizationID.String(), r.ID.String(), r.Fingerprint)
|
expected := buildMarkerValue(d.OrganizationID.String(), r.ID.String(), r.Fingerprint)
|
||||||
|
|
||||||
|
logCtx := log.With().
|
||||||
|
Str("zone_id", zoneID).
|
||||||
|
Str("domain", d.DomainName).
|
||||||
|
Str("fqdn", fq).
|
||||||
|
Str("rr_type", rt).
|
||||||
|
Str("record_id", r.ID.String()).
|
||||||
|
Str("org_id", d.OrganizationID.String()).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
// ---- ExternalDNS preflight ----
|
// ---- ExternalDNS preflight ----
|
||||||
extOwned, err := hasExternalDNSOwnership(ctx, r53c, zoneID, fq, rt)
|
extOwned, err := hasExternalDNSOwnership(ctx, r53c, zoneID, fq, rt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("external_dns_lookup: %w", err)
|
return fmt.Errorf("external_dns_lookup: %w", err)
|
||||||
}
|
}
|
||||||
if extOwned {
|
if extOwned {
|
||||||
|
logCtx.Warn().Msg("[dns] ownership conflict: external-dns claims this record")
|
||||||
r.Owner = "external"
|
r.Owner = "external"
|
||||||
_ = db.Save(r).Error
|
_ = db.Save(r).Error
|
||||||
return fmt.Errorf("ownership_conflict: external-dns claims %s; refusing to modify", strings.TrimSuffix(fq, "."))
|
return fmt.Errorf("ownership_conflict: external-dns claims %s; refusing to modify", strings.TrimSuffix(fq, "."))
|
||||||
@@ -265,6 +290,7 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marker lookup: %w", err)
|
return fmt.Errorf("marker lookup: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hasForeignOwner := false
|
hasForeignOwner := false
|
||||||
hasOurExact := false
|
hasOurExact := false
|
||||||
for _, v := range markerVals {
|
for _, v := range markerVals {
|
||||||
@@ -279,25 +305,26 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
|
|||||||
hasForeignOwner = true
|
hasForeignOwner = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logCtx.Debug().
|
||||||
|
Bool("externaldns_owned", extOwned).
|
||||||
|
Int("marker_txt_count", len(markerVals)).
|
||||||
|
Bool("marker_has_our_exact", hasOurExact).
|
||||||
|
Bool("marker_has_foreign", hasForeignOwner).
|
||||||
|
Msg("[dns] ownership preflight")
|
||||||
|
|
||||||
if hasForeignOwner {
|
if hasForeignOwner {
|
||||||
|
logCtx.Warn().Msg("[dns] ownership conflict: foreign _autoglue marker")
|
||||||
r.Owner = "external"
|
r.Owner = "external"
|
||||||
_ = db.Save(r).Error
|
_ = db.Save(r).Error
|
||||||
return fmt.Errorf("ownership_conflict: marker for %s is owned by another controller; refusing to modify", strings.TrimSuffix(fq, "."))
|
return fmt.Errorf("ownership_conflict: marker for %s is owned by another controller; refusing to modify", strings.TrimSuffix(fq, "."))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build RR change (UPSERT)
|
|
||||||
rrChange := r53types.Change{
|
|
||||||
Action: r53types.ChangeActionUpsert,
|
|
||||||
ResourceRecordSet: &r53types.ResourceRecordSet{
|
|
||||||
Name: aws.String(fq),
|
|
||||||
Type: r53types.RRType(rt),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode user values
|
// Decode user values
|
||||||
var userVals []string
|
var userVals []string
|
||||||
if len(r.Values) > 0 {
|
rawVals := strings.TrimSpace(string(r.Values))
|
||||||
if err := jsonUnmarshalStrict([]byte(r.Values), &userVals); err != nil {
|
if rawVals != "" && rawVals != "null" {
|
||||||
|
if err := jsonUnmarshalStrict([]byte(rawVals), &userVals); err != nil {
|
||||||
return fmt.Errorf("values decode: %w", err)
|
return fmt.Errorf("values decode: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,15 +333,38 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
|
|||||||
recs := make([]r53types.ResourceRecord, 0, len(userVals))
|
recs := make([]r53types.ResourceRecord, 0, len(userVals))
|
||||||
for _, v := range userVals {
|
for _, v := range userVals {
|
||||||
v = strings.TrimSpace(v)
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if rt == "TXT" && !(strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`)) {
|
if rt == "TXT" && !(strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`)) {
|
||||||
v = strconv.Quote(v)
|
v = strconv.Quote(v)
|
||||||
}
|
}
|
||||||
recs = append(recs, r53types.ResourceRecord{Value: aws.String(v)})
|
recs = append(recs, r53types.ResourceRecord{Value: aws.String(v)})
|
||||||
}
|
}
|
||||||
rrChange.ResourceRecordSet.ResourceRecords = recs
|
|
||||||
if r.TTL != nil {
|
// Alias is NOT supported - enforce at least one value for all record types we manage
|
||||||
ttl := int64(*r.TTL)
|
if len(recs) == 0 {
|
||||||
rrChange.ResourceRecordSet.TTL = aws.Int64(ttl)
|
logCtx.Warn().
|
||||||
|
Str("raw_values", truncateForLog(string(r.Values), 240)).
|
||||||
|
Int("decoded_value_count", len(userVals)).
|
||||||
|
Msg("[dns] invalid record: no values (alias not supported)")
|
||||||
|
return fmt.Errorf("invalid_record: %s %s requires at least one value (alias not supported)", strings.TrimSuffix(fq, "."), rt)
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := defaultRecordTTLSeconds
|
||||||
|
if r.TTL != nil && *r.TTL > 0 {
|
||||||
|
ttl = int64(*r.TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build RR change (UPSERT)
|
||||||
|
rrChange := r53types.Change{
|
||||||
|
Action: r53types.ChangeActionUpsert,
|
||||||
|
ResourceRecordSet: &r53types.ResourceRecordSet{
|
||||||
|
Name: aws.String(fq),
|
||||||
|
Type: r53types.RRType(rt),
|
||||||
|
TTL: aws.Int64(ttl),
|
||||||
|
ResourceRecords: recs,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build marker TXT change (UPSERT)
|
// Build marker TXT change (UPSERT)
|
||||||
@@ -323,7 +373,7 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
|
|||||||
ResourceRecordSet: &r53types.ResourceRecordSet{
|
ResourceRecordSet: &r53types.ResourceRecordSet{
|
||||||
Name: aws.String(mname),
|
Name: aws.String(mname),
|
||||||
Type: r53types.RRTypeTxt,
|
Type: r53types.RRTypeTxt,
|
||||||
TTL: aws.Int64(300),
|
TTL: aws.Int64(defaultRecordTTLSeconds),
|
||||||
ResourceRecords: []r53types.ResourceRecord{
|
ResourceRecords: []r53types.ResourceRecord{
|
||||||
{Value: aws.String(strconv.Quote(expected))},
|
{Value: aws.String(strconv.Quote(expected))},
|
||||||
},
|
},
|
||||||
@@ -337,14 +387,26 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
|
|||||||
changes := []r53types.Change{rrChange, markerChange}
|
changes := []r53types.Change{rrChange, markerChange}
|
||||||
changes = append(changes, poisonChanges...)
|
changes = append(changes, poisonChanges...)
|
||||||
|
|
||||||
|
// Log what we are about to send
|
||||||
|
logCtx.Debug().
|
||||||
|
Interface("route53_change_batch", toLogChangeBatch(zoneID, changes)).
|
||||||
|
Msg("[dns] route53 request preview")
|
||||||
|
|
||||||
_, err = r53c.ChangeResourceRecordSets(ctx, &r53.ChangeResourceRecordSetsInput{
|
_, err = r53c.ChangeResourceRecordSets(ctx, &r53.ChangeResourceRecordSetsInput{
|
||||||
HostedZoneId: aws.String(zoneID),
|
HostedZoneId: aws.String(zoneID),
|
||||||
ChangeBatch: &r53types.ChangeBatch{Changes: changes},
|
ChangeBatch: &r53types.ChangeBatch{Changes: changes},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logAWSError(logCtx, err)
|
||||||
|
logCtx.Info().Dur("elapsed", time.Since(start)).Msg("[dns] apply failed")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logCtx.Info().
|
||||||
|
Dur("elapsed", time.Since(start)).
|
||||||
|
Int("change_count", len(changes)).
|
||||||
|
Msg("[dns] apply ok")
|
||||||
|
|
||||||
// Success → mark ready & ownership
|
// Success → mark ready & ownership
|
||||||
r.Status = "ready"
|
r.Status = "ready"
|
||||||
r.LastError = ""
|
r.LastError = ""
|
||||||
@@ -352,6 +414,7 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
|
|||||||
if err := db.Save(r).Error; err != nil {
|
if err := db.Save(r).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = hasOurExact // could be used to skip marker write in future
|
_ = hasOurExact // could be used to skip marker write in future
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -568,7 +631,7 @@ func buildExternalDNSPoisonTXTChanges(fqdn, rrType string) []r53types.Change {
|
|||||||
ResourceRecordSet: &r53types.ResourceRecordSet{
|
ResourceRecordSet: &r53types.ResourceRecordSet{
|
||||||
Name: aws.String(n),
|
Name: aws.String(n),
|
||||||
Type: r53types.RRTypeTxt,
|
Type: r53types.RRTypeTxt,
|
||||||
TTL: aws.Int64(300),
|
TTL: aws.Int64(defaultRecordTTLSeconds),
|
||||||
ResourceRecords: []r53types.ResourceRecord{
|
ResourceRecords: []r53types.ResourceRecord{
|
||||||
{Value: aws.String(val)},
|
{Value: aws.String(val)},
|
||||||
},
|
},
|
||||||
@@ -595,3 +658,125 @@ func jsonUnmarshalStrict(b []byte, dst any) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal(b, dst)
|
return json.Unmarshal(b, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/************* logging DTOs & helpers *************/
|
||||||
|
|
||||||
|
type logRR struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logRRSet struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL *int64 `json:"ttl,omitempty"`
|
||||||
|
Records []logRR `json:"records,omitempty"`
|
||||||
|
RecordCount int `json:"record_count"`
|
||||||
|
HasAliasTarget bool `json:"has_alias_target"`
|
||||||
|
SetIdentifier *string `json:"set_identifier,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logChangeBatch struct {
|
||||||
|
HostedZoneID string `json:"hosted_zone_id"`
|
||||||
|
ChangeCount int `json:"change_count"`
|
||||||
|
Changes []logRRSet `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForLog(s string, max int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if max <= 0 || len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLogChangeBatch(zoneID string, changes []r53types.Change) logChangeBatch {
|
||||||
|
out := logChangeBatch{
|
||||||
|
HostedZoneID: zoneID,
|
||||||
|
ChangeCount: len(changes),
|
||||||
|
Changes: make([]logRRSet, 0, len(changes)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range changes {
|
||||||
|
if ch.ResourceRecordSet == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rrs := ch.ResourceRecordSet
|
||||||
|
lc := logRRSet{
|
||||||
|
Action: string(ch.Action),
|
||||||
|
Name: aws.ToString(rrs.Name),
|
||||||
|
Type: string(rrs.Type),
|
||||||
|
TTL: rrs.TTL,
|
||||||
|
HasAliasTarget: rrs.AliasTarget != nil,
|
||||||
|
SetIdentifier: rrs.SetIdentifier,
|
||||||
|
RecordCount: len(rrs.ResourceRecords),
|
||||||
|
Records: make([]logRR, 0, min(len(rrs.ResourceRecords), 5)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log up to first 5 values (truncate each) to avoid log bloat / secrets
|
||||||
|
for i, rr := range rrs.ResourceRecords {
|
||||||
|
if i >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lc.Records = append(lc.Records, logRR{Value: truncateForLog(aws.ToString(rr.Value), 160)})
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Changes = append(out.Changes, lc)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// logAWSError extracts useful smithy/HTTP metadata (status code + request id + api code) into logs.
|
||||||
|
// logAWSError extracts useful smithy/HTTP metadata (status code + request id + api code) into logs.
|
||||||
|
func logAWSError(l zerolog.Logger, err error) {
|
||||||
|
// Add operation context if present
|
||||||
|
var opErr *smithy.OperationError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
l = l.With().
|
||||||
|
Str("aws_service", opErr.ServiceID).
|
||||||
|
Str("aws_operation", opErr.OperationName).
|
||||||
|
Logger()
|
||||||
|
err = opErr.Unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP status + request id (smithy-go transport/http)
|
||||||
|
var re *smithyhttp.ResponseError
|
||||||
|
if errors.As(err, &re) {
|
||||||
|
status := re.HTTPStatusCode()
|
||||||
|
|
||||||
|
reqID := ""
|
||||||
|
if resp := re.HTTPResponse(); resp != nil && resp.Header != nil {
|
||||||
|
reqID = resp.Header.Get("x-amzn-RequestId")
|
||||||
|
if reqID == "" {
|
||||||
|
reqID = resp.Header.Get("x-amz-request-id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := l.Error().Int("http_status", status).Err(err)
|
||||||
|
if reqID != "" {
|
||||||
|
ev = ev.Str("aws_request_id", reqID)
|
||||||
|
}
|
||||||
|
ev.Msg("[dns] aws route53 call failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// API error code/message (best-effort)
|
||||||
|
var apiErr smithy.APIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
l.Error().
|
||||||
|
Str("aws_error_code", apiErr.ErrorCode()).
|
||||||
|
Str("aws_error_message", apiErr.ErrorMessage()).
|
||||||
|
Err(err).
|
||||||
|
Msg("[dns] aws route53 api error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Error().Err(err).Msg("[dns] aws route53 error")
|
||||||
|
}
|
||||||
|
|||||||
95
internal/bg/org_key_sweeper.go
Normal file
95
internal/bg/org_key_sweeper.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package bg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dyaksa/archer"
|
||||||
|
"github.com/dyaksa/archer/job"
|
||||||
|
"github.com/glueops/autoglue/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrgKeySweeperArgs struct {
|
||||||
|
IntervalS int `json:"interval_seconds,omitempty"`
|
||||||
|
RetentionDays int `json:"retention_days,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgKeySweeperResult struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
MarkedRevoked int `json:"marked_revoked"`
|
||||||
|
DeletedEphemeral int `json:"deleted_ephemeral"`
|
||||||
|
ElapsedMs int `json:"elapsed_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func OrgKeySweeperWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
||||||
|
return func(ctx context.Context, j job.Job) (any, error) {
|
||||||
|
args := OrgKeySweeperArgs{
|
||||||
|
IntervalS: 3600,
|
||||||
|
RetentionDays: 10,
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
_ = j.ParseArguments(&args)
|
||||||
|
if args.IntervalS <= 0 {
|
||||||
|
args.IntervalS = 3600
|
||||||
|
}
|
||||||
|
if args.RetentionDays <= 0 {
|
||||||
|
args.RetentionDays = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 1) Mark expired keys as revoked
|
||||||
|
res1 := db.Model(&models.APIKey{}).
|
||||||
|
Where("expires_at IS NOT NULL AND expires_at <= ? AND revoked = false", now).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"revoked": true,
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if res1.Error != nil {
|
||||||
|
log.Error().Err(res1.Error).Msg("[org_key_sweeper] mark expired revoked failed")
|
||||||
|
return nil, res1.Error
|
||||||
|
}
|
||||||
|
markedRevoked := int(res1.RowsAffected)
|
||||||
|
|
||||||
|
// 2) Hard-delete ephemeral keys that are revoked and older than retention
|
||||||
|
cutoff := now.Add(-time.Duration(args.RetentionDays) * 24 * time.Hour)
|
||||||
|
res2 := db.
|
||||||
|
Where("is_ephemeral = ? AND revoked = ? AND updated_at <= ?", true, true, cutoff).
|
||||||
|
Delete(&models.APIKey{})
|
||||||
|
|
||||||
|
if res2.Error != nil {
|
||||||
|
log.Error().Err(res2.Error).Msg("[org_key_sweeper] delete revoked ephemeral keys failed")
|
||||||
|
return nil, res2.Error
|
||||||
|
}
|
||||||
|
deletedEphemeral := int(res2.RowsAffected)
|
||||||
|
|
||||||
|
out := OrgKeySweeperResult{
|
||||||
|
Status: "ok",
|
||||||
|
MarkedRevoked: markedRevoked,
|
||||||
|
DeletedEphemeral: deletedEphemeral,
|
||||||
|
ElapsedMs: int(time.Since(start).Milliseconds()),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Int("marked_revoked", markedRevoked).
|
||||||
|
Int("deleted_ephemeral", deletedEphemeral).
|
||||||
|
Msg("[org_key_sweeper] cleanup tick ok")
|
||||||
|
|
||||||
|
// Re-enqueue the sweeper
|
||||||
|
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
|
||||||
|
_, _ = jobs.Enqueue(
|
||||||
|
ctx,
|
||||||
|
uuid.NewString(),
|
||||||
|
"org_key_sweeper",
|
||||||
|
args,
|
||||||
|
archer.WithScheduleTime(next),
|
||||||
|
archer.WithMaxRetries(1),
|
||||||
|
)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package bg
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"github.com/dyaksa/archer"
|
"github.com/dyaksa/archer"
|
||||||
"github.com/dyaksa/archer/job"
|
"github.com/dyaksa/archer/job"
|
||||||
|
"github.com/glueops/autoglue/internal/auth"
|
||||||
|
"github.com/glueops/autoglue/internal/mapper"
|
||||||
"github.com/glueops/autoglue/internal/models"
|
"github.com/glueops/autoglue/internal/models"
|
||||||
"github.com/glueops/autoglue/internal/utils"
|
"github.com/glueops/autoglue/internal/utils"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -42,11 +45,12 @@ type ClusterPrepareResult struct {
|
|||||||
|
|
||||||
// Alias the status constants from models to avoid string drift.
|
// Alias the status constants from models to avoid string drift.
|
||||||
const (
|
const (
|
||||||
clusterStatusPrePending = models.ClusterStatusPrePending
|
clusterStatusPrePending = models.ClusterStatusPrePending
|
||||||
clusterStatusPending = models.ClusterStatusPending
|
clusterStatusPending = models.ClusterStatusPending
|
||||||
clusterStatusProvisioning = models.ClusterStatusProvisioning
|
clusterStatusProvisioning = models.ClusterStatusProvisioning
|
||||||
clusterStatusReady = models.ClusterStatusReady
|
clusterStatusReady = models.ClusterStatusReady
|
||||||
clusterStatusFailed = models.ClusterStatusFailed
|
clusterStatusFailed = models.ClusterStatusFailed
|
||||||
|
clusterStatusBootstrapping = models.ClusterStatusBootstrapping
|
||||||
)
|
)
|
||||||
|
|
||||||
func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
||||||
@@ -66,6 +70,12 @@ func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
|||||||
Preload("BastionServer.SshKey").
|
Preload("BastionServer.SshKey").
|
||||||
Preload("CaptainDomain").
|
Preload("CaptainDomain").
|
||||||
Preload("ControlPlaneRecordSet").
|
Preload("ControlPlaneRecordSet").
|
||||||
|
Preload("AppsLoadBalancer").
|
||||||
|
Preload("GlueOpsLoadBalancer").
|
||||||
|
Preload("NodePools").
|
||||||
|
Preload("NodePools.Labels").
|
||||||
|
Preload("NodePools.Annotations").
|
||||||
|
Preload("NodePools.Taints").
|
||||||
Preload("NodePools.Servers.SshKey").
|
Preload("NodePools.Servers.SshKey").
|
||||||
Where("status = ?", clusterStatusPrePending).
|
Where("status = ?", clusterStatusPrePending).
|
||||||
Find(&clusters).Error; err != nil {
|
Find(&clusters).Error; err != nil {
|
||||||
@@ -88,6 +98,13 @@ func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
|
||||||
|
log.Error().Err(err).Msg("[cluster_prepare] failed to mark cluster bootstrapping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status = clusterStatusBootstrapping
|
||||||
|
|
||||||
clusterLog := log.With().
|
clusterLog := log.With().
|
||||||
Str("job", jobID).
|
Str("job", jobID).
|
||||||
Str("cluster_id", c.ID.String()).
|
Str("cluster_id", c.ID.String()).
|
||||||
@@ -124,7 +141,55 @@ func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
payloadJSON, err := json.MarshalIndent(c, "", " ")
|
dtoCluster := mapper.ClusterToDTO(*c)
|
||||||
|
|
||||||
|
if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
|
||||||
|
kubeconfig, err := utils.DecryptForOrg(
|
||||||
|
c.OrganizationID,
|
||||||
|
c.EncryptedKubeconfig,
|
||||||
|
c.KubeIV,
|
||||||
|
c.KubeTag,
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fail++
|
||||||
|
failedIDs = append(failedIDs, c.ID)
|
||||||
|
failures = append(failures, ClusterPrepareFailure{
|
||||||
|
ClusterID: c.ID,
|
||||||
|
Step: "decrypt_kubeconfig",
|
||||||
|
Reason: err.Error(),
|
||||||
|
})
|
||||||
|
clusterLog.Error().Err(err).Msg("[cluster_prepare] decrypt kubeconfig failed")
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dtoCluster.Kubeconfig = &kubeconfig
|
||||||
|
}
|
||||||
|
|
||||||
|
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(
|
||||||
|
db,
|
||||||
|
c.OrganizationID,
|
||||||
|
c.ID,
|
||||||
|
24*time.Hour,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fail++
|
||||||
|
failedIDs = append(failedIDs, c.ID)
|
||||||
|
failures = append(failures, ClusterPrepareFailure{
|
||||||
|
ClusterID: c.ID,
|
||||||
|
Step: "create_org_key",
|
||||||
|
Reason: err.Error(),
|
||||||
|
})
|
||||||
|
clusterLog.Error().Err(err).Msg("[cluster_prepare] create org key for payload failed")
|
||||||
|
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dtoCluster.OrgKey = &orgKey
|
||||||
|
dtoCluster.OrgSecret = &orgSecret
|
||||||
|
|
||||||
|
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail++
|
fail++
|
||||||
failedIDs = append(failedIDs, c.ID)
|
failedIDs = append(failedIDs, c.ID)
|
||||||
@@ -443,6 +508,11 @@ func runMakeOnBastion(
|
|||||||
c *models.Cluster,
|
c *models.Cluster,
|
||||||
target string,
|
target string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
logger := log.With().
|
||||||
|
Str("cluster_id", c.ID.String()).
|
||||||
|
Str("cluster_name", c.Name).
|
||||||
|
Logger()
|
||||||
|
|
||||||
bastion := c.BastionServer
|
bastion := c.BastionServer
|
||||||
if bastion == nil {
|
if bastion == nil {
|
||||||
return "", fmt.Errorf("bastion server is nil")
|
return "", fmt.Errorf("bastion server is nil")
|
||||||
@@ -500,9 +570,13 @@ func runMakeOnBastion(
|
|||||||
defer sess.Close()
|
defer sess.Close()
|
||||||
|
|
||||||
clusterDir := fmt.Sprintf("$HOME/autoglue/clusters/%s", c.ID.String())
|
clusterDir := fmt.Sprintf("$HOME/autoglue/clusters/%s", c.ID.String())
|
||||||
sshDir := fmt.Sprintf("$HOME/.ssh/autoglue")
|
sshDir := fmt.Sprintf("$HOME/.ssh")
|
||||||
|
|
||||||
cmd := fmt.Sprintf("cd %s && docker run -it -v %s:/root/.ssh -v ./payload.json:/opt/gluekube/platform.json %s:%s make %s", clusterDir, sshDir, c.DockerImage, c.DockerTag, target)
|
cmd := fmt.Sprintf("cd %s && docker run -v %s:/root/.ssh -v ./payload.json:/opt/gluekube/platform.json %s:%s make %s", clusterDir, sshDir, c.DockerImage, c.DockerTag, target)
|
||||||
|
|
||||||
|
logger.Info().
|
||||||
|
Str("cmd", cmd).
|
||||||
|
Msg("[runMakeOnBastion] executing remote command")
|
||||||
|
|
||||||
out, runErr := sess.CombinedOutput(cmd)
|
out, runErr := sess.CombinedOutput(cmd)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
@@ -510,3 +584,75 @@ func runMakeOnBastion(
|
|||||||
}
|
}
|
||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func randomB64URL(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrCreateClusterAutomationKey(
|
||||||
|
db *gorm.DB,
|
||||||
|
orgID uuid.UUID,
|
||||||
|
clusterID uuid.UUID,
|
||||||
|
ttl time.Duration,
|
||||||
|
) (orgKey string, orgSecret string, err error) {
|
||||||
|
now := time.Now()
|
||||||
|
name := fmt.Sprintf("cluster-%s-bastion", clusterID.String())
|
||||||
|
|
||||||
|
// 1) Delete any existing ephemeral cluster-bastion key for this org+cluster
|
||||||
|
if err := db.Where(
|
||||||
|
"org_id = ? AND scope = ? AND purpose = ? AND cluster_id = ? AND is_ephemeral = ?",
|
||||||
|
orgID, "org", "cluster_bastion", clusterID, true,
|
||||||
|
).Delete(&models.APIKey{}).Error; err != nil {
|
||||||
|
return "", "", fmt.Errorf("delete existing cluster key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Mint a fresh keypair
|
||||||
|
keySuffix, err := randomB64URL(16)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("entropy_error: %w", err)
|
||||||
|
}
|
||||||
|
sec, err := randomB64URL(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("entropy_error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
orgKey = "org_" + keySuffix
|
||||||
|
orgSecret = sec
|
||||||
|
|
||||||
|
keyHash := auth.SHA256Hex(orgKey)
|
||||||
|
secretHash, err := auth.HashSecretArgon2id(orgSecret)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("hash_error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exp := now.Add(ttl)
|
||||||
|
|
||||||
|
prefix := orgKey
|
||||||
|
if len(prefix) > 12 {
|
||||||
|
prefix = prefix[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := models.APIKey{
|
||||||
|
OrgID: &orgID,
|
||||||
|
Scope: "org",
|
||||||
|
Purpose: "cluster_bastion",
|
||||||
|
ClusterID: &clusterID,
|
||||||
|
IsEphemeral: true,
|
||||||
|
Name: name,
|
||||||
|
KeyHash: keyHash,
|
||||||
|
SecretHash: &secretHash,
|
||||||
|
ExpiresAt: &exp,
|
||||||
|
Revoked: false,
|
||||||
|
Prefix: &prefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&rec).Error; err != nil {
|
||||||
|
return "", "", fmt.Errorf("db_error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return orgKey, orgSecret, nil
|
||||||
|
}
|
||||||
|
|||||||
256
internal/handlers/actions.go
Normal file
256
internal/handlers/actions.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||||
|
"github.com/glueops/autoglue/internal/models"
|
||||||
|
"github.com/glueops/autoglue/internal/utils"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListActions godoc
|
||||||
|
//
|
||||||
|
// @ID ListActions
|
||||||
|
// @Summary List available actions
|
||||||
|
// @Description Returns all admin-configured actions.
|
||||||
|
// @Tags Actions
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} dto.ActionResponse
|
||||||
|
// @Failure 401 {string} string "Unauthorized"
|
||||||
|
// @Failure 500 {string} string "db error"
|
||||||
|
// @Router /admin/actions [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func ListActions(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var rows []models.Action
|
||||||
|
if err := db.Order("label ASC").Find(&rows).Error; err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]dto.ActionResponse, 0, len(rows))
|
||||||
|
for _, a := range rows {
|
||||||
|
out = append(out, actionToDTO(a))
|
||||||
|
}
|
||||||
|
utils.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAction godoc
|
||||||
|
//
|
||||||
|
// @ID GetAction
|
||||||
|
// @Summary Get a single action by ID
|
||||||
|
// @Description Returns a single action.
|
||||||
|
// @Tags Actions
|
||||||
|
// @Produce json
|
||||||
|
// @Param actionID path string true "Action ID"
|
||||||
|
// @Success 200 {object} dto.ActionResponse
|
||||||
|
// @Failure 400 {string} string "bad request"
|
||||||
|
// @Failure 401 {string} string "Unauthorized"
|
||||||
|
// @Failure 404 {string} string "not found"
|
||||||
|
// @Failure 500 {string} string "db error"
|
||||||
|
// @Router /admin/actions/{actionID} [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func GetAction(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.Action
|
||||||
|
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAction godoc
|
||||||
|
//
|
||||||
|
// @ID CreateAction
|
||||||
|
// @Summary Create an action
|
||||||
|
// @Description Creates a new admin-configured action.
|
||||||
|
// @Tags Actions
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body dto.CreateActionRequest true "payload"
|
||||||
|
// @Success 201 {object} dto.ActionResponse
|
||||||
|
// @Failure 400 {string} string "bad request"
|
||||||
|
// @Failure 401 {string} string "Unauthorized"
|
||||||
|
// @Failure 500 {string} string "db error"
|
||||||
|
// @Router /admin/actions [post]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func CreateAction(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in dto.CreateActionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
label := strings.TrimSpace(in.Label)
|
||||||
|
desc := strings.TrimSpace(in.Description)
|
||||||
|
target := strings.TrimSpace(in.MakeTarget)
|
||||||
|
|
||||||
|
if label == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row := models.Action{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MakeTarget: target,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteJSON(w, http.StatusCreated, actionToDTO(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAction godoc
|
||||||
|
//
|
||||||
|
// @ID UpdateAction
|
||||||
|
// @Summary Update an action
|
||||||
|
// @Description Updates an action. Only provided fields are modified.
|
||||||
|
// @Tags Actions
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param actionID path string true "Action ID"
|
||||||
|
// @Param body body dto.UpdateActionRequest true "payload"
|
||||||
|
// @Success 200 {object} dto.ActionResponse
|
||||||
|
// @Failure 400 {string} string "bad request"
|
||||||
|
// @Failure 401 {string} string "Unauthorized"
|
||||||
|
// @Failure 404 {string} string "not found"
|
||||||
|
// @Failure 500 {string} string "db error"
|
||||||
|
// @Router /admin/actions/{actionID} [patch]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func UpdateAction(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in dto.UpdateActionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.Action
|
||||||
|
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Label != nil {
|
||||||
|
v := strings.TrimSpace(*in.Label)
|
||||||
|
if v == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row.Label = v
|
||||||
|
}
|
||||||
|
if in.Description != nil {
|
||||||
|
v := strings.TrimSpace(*in.Description)
|
||||||
|
if v == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row.Description = v
|
||||||
|
}
|
||||||
|
if in.MakeTarget != nil {
|
||||||
|
v := strings.TrimSpace(*in.MakeTarget)
|
||||||
|
if v == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row.MakeTarget = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Save(&row).Error; err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAction godoc
|
||||||
|
//
|
||||||
|
// @ID DeleteAction
|
||||||
|
// @Summary Delete an action
|
||||||
|
// @Description Deletes an action.
|
||||||
|
// @Tags Actions
|
||||||
|
// @Produce json
|
||||||
|
// @Param actionID path string true "Action ID"
|
||||||
|
// @Success 204 {string} string "deleted"
|
||||||
|
// @Failure 400 {string} string "bad request"
|
||||||
|
// @Failure 401 {string} string "Unauthorized"
|
||||||
|
// @Failure 404 {string} string "not found"
|
||||||
|
// @Failure 500 {string} string "db error"
|
||||||
|
// @Router /admin/actions/{actionID} [delete]
|
||||||
|
// @Security BearerAuth
|
||||||
|
func DeleteAction(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := db.Where("id = ?", actionID).Delete(&models.Action{})
|
||||||
|
if tx.Error != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tx.RowsAffected == 0 {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionToDTO(a models.Action) dto.ActionResponse {
|
||||||
|
return dto.ActionResponse{
|
||||||
|
ID: a.ID,
|
||||||
|
Label: a.Label,
|
||||||
|
Description: a.Description,
|
||||||
|
MakeTarget: a.MakeTarget,
|
||||||
|
CreatedAt: a.CreatedAt,
|
||||||
|
UpdatedAt: a.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
263
internal/handlers/cluster_runs.go
Normal file
263
internal/handlers/cluster_runs.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dyaksa/archer"
|
||||||
|
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||||
|
"github.com/glueops/autoglue/internal/bg"
|
||||||
|
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||||
|
"github.com/glueops/autoglue/internal/models"
|
||||||
|
"github.com/glueops/autoglue/internal/utils"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListClusterRuns godoc
|
||||||
|
//
|
||||||
|
// @ID ListClusterRuns
|
||||||
|
// @Summary List cluster runs (org scoped)
|
||||||
|
// @Description Returns runs for a cluster within the organization in X-Org-ID.
|
||||||
|
// @Tags ClusterRuns
|
||||||
|
// @Produce json
|
||||||
|
// @Param X-Org-ID header string false "Organization UUID"
|
||||||
|
// @Param clusterID path string true "Cluster ID"
|
||||||
|
// @Success 200 {array} dto.ClusterRunResponse
|
||||||
|
// @Failure 401 {string} string "Unauthorized"
|
||||||
|
// @Failure 403 {string} string "organization required"
|
||||||
|
// @Failure 404 {string} string "cluster not found"
|
||||||
|
// @Failure 500 {string} string "db error"
|
||||||
|
// @Router /clusters/{clusterID}/runs [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Security OrgKeyAuth
|
||||||
|
// @Security OrgSecretAuth
|
||||||
|
func ListClusterRuns(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||||
|
if !ok {
|
||||||
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cluster exists + org scoped
|
||||||
|
if err := db.Select("id").
|
||||||
|
Where("id = ? AND organization_id = ?", clusterID, orgID).
|
||||||
|
First(&models.Cluster{}).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []models.ClusterRun
|
||||||
|
if err := db.
|
||||||
|
Where("organization_id = ? AND cluster_id = ?", orgID, clusterID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]dto.ClusterRunResponse, 0, len(rows))
|
||||||
|
for _, cr := range rows {
|
||||||
|
out = append(out, clusterRunToDTO(cr))
|
||||||
|
}
|
||||||
|
utils.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClusterRun godoc
|
||||||
|
//
|
||||||
|
// @ID GetClusterRun
|
||||||
|
// @Summary Get a cluster run (org scoped)
|
||||||
|
// @Description Returns a single run for a cluster within the organization in X-Org-ID.
|
||||||
|
// @Tags ClusterRuns
|
||||||
|
// @Produce json
|
||||||
|
// @Param X-Org-ID header string false "Organization UUID"
|
||||||
|
// @Param clusterID path string true "Cluster ID"
|
||||||
|
// @Param runID path string true "Run ID"
|
||||||
|
// @Success 200 {object} dto.ClusterRunResponse
|
||||||
|
// @Failure 400 {string} string "bad request"
|
||||||
|
// @Failure 401 {string} string "Unauthorized"
|
||||||
|
// @Failure 403 {string} string "organization required"
|
||||||
|
// @Failure 404 {string} string "not found"
|
||||||
|
// @Failure 500 {string} string "db error"
|
||||||
|
// @Router /clusters/{clusterID}/runs/{runID} [get]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Security OrgKeyAuth
|
||||||
|
// @Security OrgSecretAuth
|
||||||
|
func GetClusterRun(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||||
|
if !ok {
|
||||||
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runID, err := uuid.Parse(chi.URLParam(r, "runID"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_run_id", "invalid run id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.ClusterRun
|
||||||
|
if err := db.
|
||||||
|
Where("id = ? AND organization_id = ? AND cluster_id = ?", runID, orgID, clusterID).
|
||||||
|
First(&row).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "not_found", "run not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, clusterRunToDTO(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunClusterAction godoc
|
||||||
|
//
|
||||||
|
// @ID RunClusterAction
|
||||||
|
// @Summary Run an admin-configured action on a cluster (org scoped)
|
||||||
|
// @Description Creates a ClusterRun record for the cluster/action. Execution is handled asynchronously by workers.
|
||||||
|
// @Tags ClusterRuns
|
||||||
|
// @Produce json
|
||||||
|
// @Param X-Org-ID header string false "Organization UUID"
|
||||||
|
// @Param clusterID path string true "Cluster ID"
|
||||||
|
// @Param actionID path string true "Action ID"
|
||||||
|
// @Success 201 {object} dto.ClusterRunResponse
|
||||||
|
// @Failure 400 {string} string "bad request"
|
||||||
|
// @Failure 401 {string} string "Unauthorized"
|
||||||
|
// @Failure 403 {string} string "organization required"
|
||||||
|
// @Failure 404 {string} string "cluster or action not found"
|
||||||
|
// @Failure 500 {string} string "db error"
|
||||||
|
// @Router /clusters/{clusterID}/actions/{actionID}/runs [post]
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Security OrgKeyAuth
|
||||||
|
// @Security OrgSecretAuth
|
||||||
|
func RunClusterAction(db *gorm.DB, jobs *bg.Jobs) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||||
|
if !ok {
|
||||||
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// cluster must exist + org scoped
|
||||||
|
var cluster models.Cluster
|
||||||
|
if err := db.Select("id", "organization_id").
|
||||||
|
Where("id = ? AND organization_id = ?", clusterID, orgID).
|
||||||
|
First(&cluster).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// action is global/admin-configured (not org scoped)
|
||||||
|
var action models.Action
|
||||||
|
if err := db.Where("id = ?", actionID).First(&action).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "action_not_found", "action not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
run := models.ClusterRun{
|
||||||
|
OrganizationID: orgID,
|
||||||
|
ClusterID: clusterID,
|
||||||
|
Action: action.MakeTarget, // this is what you actually execute
|
||||||
|
Status: models.ClusterRunStatusQueued,
|
||||||
|
Error: "",
|
||||||
|
FinishedAt: time.Time{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&run).Error; err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := bg.ClusterActionArgs{
|
||||||
|
OrgID: orgID,
|
||||||
|
ClusterID: clusterID,
|
||||||
|
Action: action.MakeTarget,
|
||||||
|
MakeTarget: action.MakeTarget,
|
||||||
|
}
|
||||||
|
// Enqueue with run.ID as the job ID so the worker can look it up.
|
||||||
|
_, enqueueErr := jobs.Enqueue(
|
||||||
|
r.Context(),
|
||||||
|
run.ID.String(),
|
||||||
|
"cluster_action",
|
||||||
|
args,
|
||||||
|
archer.WithMaxRetries(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
if enqueueErr != nil {
|
||||||
|
_ = db.Model(&models.ClusterRun{}).
|
||||||
|
Where("id = ?", run.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": models.ClusterRunStatusFailed,
|
||||||
|
"error": "failed to enqueue job: " + enqueueErr.Error(),
|
||||||
|
"finished_at": time.Now().UTC(),
|
||||||
|
}).Error
|
||||||
|
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "job_error", "failed to enqueue cluster action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteJSON(w, http.StatusCreated, clusterRunToDTO(run))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clusterRunToDTO(cr models.ClusterRun) dto.ClusterRunResponse {
|
||||||
|
var finished *time.Time
|
||||||
|
if !cr.FinishedAt.IsZero() {
|
||||||
|
t := cr.FinishedAt
|
||||||
|
finished = &t
|
||||||
|
}
|
||||||
|
return dto.ClusterRunResponse{
|
||||||
|
ID: cr.ID,
|
||||||
|
OrganizationID: cr.OrganizationID,
|
||||||
|
ClusterID: cr.ClusterID,
|
||||||
|
Action: cr.Action,
|
||||||
|
Status: cr.Status,
|
||||||
|
Error: cr.Error,
|
||||||
|
CreatedAt: cr.CreatedAt,
|
||||||
|
UpdatedAt: cr.UpdatedAt,
|
||||||
|
FinishedAt: finished,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,17 @@ func ListClusters(db *gorm.DB) http.HandlerFunc {
|
|||||||
|
|
||||||
out := make([]dto.ClusterResponse, 0, len(rows))
|
out := make([]dto.ClusterResponse, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
out = append(out, clusterToDTO(row))
|
cr := clusterToDTO(row)
|
||||||
|
|
||||||
|
if row.EncryptedKubeconfig != "" && row.KubeIV != "" && row.KubeTag != "" {
|
||||||
|
kubeconfig, err := utils.DecryptForOrg(orgID, row.EncryptedKubeconfig, row.KubeIV, row.KubeTag, db)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "kubeconfig_decrypt_failed", "failed to decrypt kubeconfig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cr.Kubeconfig = &kubeconfig
|
||||||
|
}
|
||||||
|
out = append(out, cr)
|
||||||
}
|
}
|
||||||
utils.WriteJSON(w, http.StatusOK, out)
|
utils.WriteJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
@@ -131,7 +141,18 @@ func GetCluster(db *gorm.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
|
resp := clusterToDTO(cluster)
|
||||||
|
|
||||||
|
if cluster.EncryptedKubeconfig != "" && cluster.KubeIV != "" && cluster.KubeTag != "" {
|
||||||
|
kubeconfig, err := utils.DecryptForOrg(orgID, cluster.EncryptedKubeconfig, cluster.KubeIV, cluster.KubeTag, db)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "kubeconfig_decrypt_failed", "failed to decrypt kubeconfig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Kubeconfig = &kubeconfig
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -503,6 +503,50 @@ func ListRecordSets(db *gorm.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRecordSet godoc
|
||||||
|
//
|
||||||
|
// @ID GetRecordSet
|
||||||
|
// @Summary Get a record set (org scoped)
|
||||||
|
// @Tags DNS
|
||||||
|
// @Produce json
|
||||||
|
// @Param X-Org-ID header string false "Organization UUID"
|
||||||
|
// @Param id path string true "Record Set ID (UUID)"
|
||||||
|
// @Success 200 {object} dto.RecordSetResponse
|
||||||
|
// @Failure 403 {string} string "organization required"
|
||||||
|
// @Failure 404 {string} string "not found"
|
||||||
|
// @Router /dns/records/{id} [get]
|
||||||
|
func GetRecordSet(db *gorm.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||||
|
if !ok {
|
||||||
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.RecordSet
|
||||||
|
if err := db.
|
||||||
|
Joins("Domain").
|
||||||
|
Where(`record_sets.id = ? AND "Domain"."organization_id" = ?`, id, orgID).
|
||||||
|
First(&row).Error; err != nil {
|
||||||
|
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, recordOut(&row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CreateRecordSet godoc
|
// CreateRecordSet godoc
|
||||||
//
|
//
|
||||||
// @ID CreateRecordSet
|
// @ID CreateRecordSet
|
||||||
|
|||||||
28
internal/handlers/dto/actions.go
Normal file
28
internal/handlers/dto/actions.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionResponse struct {
|
||||||
|
ID uuid.UUID `json:"id" format:"uuid"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
MakeTarget string `json:"make_target"`
|
||||||
|
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateActionRequest struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
MakeTarget string `json:"make_target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateActionRequest struct {
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
MakeTarget *string `json:"make_target,omitempty"`
|
||||||
|
}
|
||||||
19
internal/handlers/dto/cluster_runs.go
Normal file
19
internal/handlers/dto/cluster_runs.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClusterRunResponse struct {
|
||||||
|
ID uuid.UUID `json:"id" format:"uuid"`
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
||||||
|
ClusterID uuid.UUID `json:"cluster_id" format:"uuid"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ type ClusterResponse struct {
|
|||||||
NodePools []NodePoolResponse `json:"node_pools,omitempty"`
|
NodePools []NodePoolResponse `json:"node_pools,omitempty"`
|
||||||
DockerImage string `json:"docker_image"`
|
DockerImage string `json:"docker_image"`
|
||||||
DockerTag string `json:"docker_tag"`
|
DockerTag string `json:"docker_tag"`
|
||||||
|
Kubeconfig *string `json:"kubeconfig,omitempty"`
|
||||||
|
OrgKey *string `json:"org_key,omitempty"`
|
||||||
|
OrgSecret *string `json:"org_secret,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -828,16 +828,16 @@ func ListNodePoolLabels(db *gorm.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := make([]dto.LabelResponse, 0, len(np.Taints))
|
out := make([]dto.LabelResponse, 0, len(np.Taints))
|
||||||
for _, taint := range np.Taints {
|
for _, label := range np.Labels {
|
||||||
out = append(out, dto.LabelResponse{
|
out = append(out, dto.LabelResponse{
|
||||||
AuditFields: common.AuditFields{
|
AuditFields: common.AuditFields{
|
||||||
ID: taint.ID,
|
ID: label.ID,
|
||||||
OrganizationID: taint.OrganizationID,
|
OrganizationID: label.OrganizationID,
|
||||||
CreatedAt: taint.CreatedAt,
|
CreatedAt: label.CreatedAt,
|
||||||
UpdatedAt: taint.UpdatedAt,
|
UpdatedAt: label.UpdatedAt,
|
||||||
},
|
},
|
||||||
Key: taint.Key,
|
Key: label.Key,
|
||||||
Value: taint.Value,
|
Value: label.Value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
utils.WriteJSON(w, http.StatusOK, out)
|
utils.WriteJSON(w, http.StatusOK, out)
|
||||||
|
|||||||
@@ -585,13 +585,22 @@ func CreateOrgKey(db *gorm.DB) http.HandlerFunc {
|
|||||||
exp = &e
|
exp = &e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prefix := orgKey
|
||||||
|
if len(prefix) > 12 {
|
||||||
|
prefix = prefix[:12]
|
||||||
|
}
|
||||||
|
|
||||||
rec := models.APIKey{
|
rec := models.APIKey{
|
||||||
OrgID: &oid,
|
OrgID: &oid,
|
||||||
Scope: "org",
|
Scope: "org",
|
||||||
Name: req.Name,
|
Purpose: "user",
|
||||||
KeyHash: keyHash,
|
IsEphemeral: false,
|
||||||
SecretHash: &secretHash,
|
Name: req.Name,
|
||||||
ExpiresAt: exp,
|
KeyHash: keyHash,
|
||||||
|
SecretHash: &secretHash,
|
||||||
|
ExpiresAt: exp,
|
||||||
|
Revoked: false,
|
||||||
|
Prefix: &prefix,
|
||||||
}
|
}
|
||||||
if err := db.Create(&rec).Error; err != nil {
|
if err := db.Create(&rec).Error; err != nil {
|
||||||
utils.WriteError(w, 500, "db_error", err.Error())
|
utils.WriteError(w, 500, "db_error", err.Error())
|
||||||
|
|||||||
182
internal/mapper/cluster.go
Normal file
182
internal/mapper/cluster.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package mapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glueops/autoglue/internal/common"
|
||||||
|
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||||
|
"github.com/glueops/autoglue/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ClusterToDTO(c models.Cluster) dto.ClusterResponse {
|
||||||
|
var bastion *dto.ServerResponse
|
||||||
|
if c.BastionServer != nil {
|
||||||
|
b := ServerToDTO(*c.BastionServer)
|
||||||
|
bastion = &b
|
||||||
|
}
|
||||||
|
|
||||||
|
var captainDomain *dto.DomainResponse
|
||||||
|
if c.CaptainDomainID != nil && c.CaptainDomain.ID != uuid.Nil {
|
||||||
|
dr := DomainToDTO(c.CaptainDomain)
|
||||||
|
captainDomain = &dr
|
||||||
|
}
|
||||||
|
|
||||||
|
var controlPlane *dto.RecordSetResponse
|
||||||
|
if c.ControlPlaneRecordSet != nil {
|
||||||
|
rr := RecordSetToDTO(*c.ControlPlaneRecordSet)
|
||||||
|
controlPlane = &rr
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfqdn *string
|
||||||
|
if captainDomain != nil && controlPlane != nil {
|
||||||
|
fq := fmt.Sprintf("%s.%s", controlPlane.Name, captainDomain.DomainName)
|
||||||
|
cfqdn = &fq
|
||||||
|
}
|
||||||
|
|
||||||
|
var appsLB *dto.LoadBalancerResponse
|
||||||
|
if c.AppsLoadBalancer != nil {
|
||||||
|
lr := LoadBalancerToDTO(*c.AppsLoadBalancer)
|
||||||
|
appsLB = &lr
|
||||||
|
}
|
||||||
|
|
||||||
|
var glueOpsLB *dto.LoadBalancerResponse
|
||||||
|
if c.GlueOpsLoadBalancer != nil {
|
||||||
|
lr := LoadBalancerToDTO(*c.GlueOpsLoadBalancer)
|
||||||
|
glueOpsLB = &lr
|
||||||
|
}
|
||||||
|
|
||||||
|
nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
|
||||||
|
for _, np := range c.NodePools {
|
||||||
|
nps = append(nps, NodePoolToDTO(np))
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.ClusterResponse{
|
||||||
|
ID: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
CaptainDomain: captainDomain,
|
||||||
|
ControlPlaneRecordSet: controlPlane,
|
||||||
|
ControlPlaneFQDN: cfqdn,
|
||||||
|
AppsLoadBalancer: appsLB,
|
||||||
|
GlueOpsLoadBalancer: glueOpsLB,
|
||||||
|
BastionServer: bastion,
|
||||||
|
Provider: c.Provider,
|
||||||
|
Region: c.Region,
|
||||||
|
Status: c.Status,
|
||||||
|
LastError: c.LastError,
|
||||||
|
RandomToken: c.RandomToken,
|
||||||
|
CertificateKey: c.CertificateKey,
|
||||||
|
NodePools: nps,
|
||||||
|
DockerImage: c.DockerImage,
|
||||||
|
DockerTag: c.DockerTag,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
UpdatedAt: c.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
|
||||||
|
labels := make([]dto.LabelResponse, 0, len(np.Labels))
|
||||||
|
for _, l := range np.Labels {
|
||||||
|
labels = append(labels, dto.LabelResponse{
|
||||||
|
Key: l.Key,
|
||||||
|
Value: l.Value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
|
||||||
|
for _, a := range np.Annotations {
|
||||||
|
annotations = append(annotations, dto.AnnotationResponse{
|
||||||
|
Key: a.Key,
|
||||||
|
Value: a.Value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
taints := make([]dto.TaintResponse, 0, len(np.Taints))
|
||||||
|
for _, t := range np.Taints {
|
||||||
|
taints = append(taints, dto.TaintResponse{
|
||||||
|
Key: t.Key,
|
||||||
|
Value: t.Value,
|
||||||
|
Effect: t.Effect,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
servers := make([]dto.ServerResponse, 0, len(np.Servers))
|
||||||
|
for _, s := range np.Servers {
|
||||||
|
servers = append(servers, ServerToDTO(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NodePoolResponse{
|
||||||
|
AuditFields: common.AuditFields{
|
||||||
|
ID: np.ID,
|
||||||
|
OrganizationID: np.OrganizationID,
|
||||||
|
CreatedAt: np.CreatedAt,
|
||||||
|
UpdatedAt: np.UpdatedAt,
|
||||||
|
},
|
||||||
|
Name: np.Name,
|
||||||
|
Role: dto.NodeRole(np.Role),
|
||||||
|
Labels: labels,
|
||||||
|
Annotations: annotations,
|
||||||
|
Taints: taints,
|
||||||
|
Servers: servers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServerToDTO(s models.Server) dto.ServerResponse {
|
||||||
|
return dto.ServerResponse{
|
||||||
|
ID: s.ID,
|
||||||
|
Hostname: s.Hostname,
|
||||||
|
PrivateIPAddress: s.PrivateIPAddress,
|
||||||
|
PublicIPAddress: s.PublicIPAddress,
|
||||||
|
Role: s.Role,
|
||||||
|
Status: s.Status,
|
||||||
|
SSHUser: s.SSHUser,
|
||||||
|
SshKeyID: s.SshKeyID,
|
||||||
|
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DomainToDTO(d models.Domain) dto.DomainResponse {
|
||||||
|
return dto.DomainResponse{
|
||||||
|
ID: d.ID.String(),
|
||||||
|
OrganizationID: d.OrganizationID.String(),
|
||||||
|
DomainName: d.DomainName,
|
||||||
|
ZoneID: d.ZoneID,
|
||||||
|
Status: d.Status,
|
||||||
|
LastError: d.LastError,
|
||||||
|
CredentialID: d.CredentialID.String(),
|
||||||
|
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordSetToDTO(rs models.RecordSet) dto.RecordSetResponse {
|
||||||
|
return dto.RecordSetResponse{
|
||||||
|
ID: rs.ID.String(),
|
||||||
|
DomainID: rs.DomainID.String(),
|
||||||
|
Name: rs.Name,
|
||||||
|
Type: rs.Type,
|
||||||
|
TTL: rs.TTL,
|
||||||
|
Values: []byte(rs.Values),
|
||||||
|
Fingerprint: rs.Fingerprint,
|
||||||
|
Status: rs.Status,
|
||||||
|
Owner: rs.Owner,
|
||||||
|
LastError: rs.LastError,
|
||||||
|
CreatedAt: rs.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
UpdatedAt: rs.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadBalancerToDTO(lb models.LoadBalancer) dto.LoadBalancerResponse {
|
||||||
|
return dto.LoadBalancerResponse{
|
||||||
|
ID: lb.ID,
|
||||||
|
OrganizationID: lb.OrganizationID,
|
||||||
|
Name: lb.Name,
|
||||||
|
Kind: lb.Kind,
|
||||||
|
PublicIPAddress: lb.PublicIPAddress,
|
||||||
|
PrivateIPAddress: lb.PrivateIPAddress,
|
||||||
|
CreatedAt: lb.CreatedAt,
|
||||||
|
UpdatedAt: lb.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
16
internal/models/action.go
Normal file
16
internal/models/action.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Action struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||||
|
Label string `gorm:"type:varchar(255);not null;uniqueIndex" json:"label"`
|
||||||
|
Description string `gorm:"type:text;not null" json:"description"`
|
||||||
|
MakeTarget string `gorm:"type:varchar(255);not null;uniqueIndex" json:"make_target"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
|
||||||
|
}
|
||||||
@@ -7,17 +7,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||||
Name string `gorm:"not null;default:''" json:"name"`
|
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
|
||||||
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
|
Scope string `gorm:"not null;default:''" json:"scope"`
|
||||||
Scope string `gorm:"not null;default:''" json:"scope"`
|
Purpose string `json:"purpose"`
|
||||||
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
|
ClusterID *uuid.UUID `json:"cluster_id,omitempty"`
|
||||||
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
|
IsEphemeral bool `json:"is_ephemeral"`
|
||||||
SecretHash *string `json:"-"`
|
Name string `gorm:"not null;default:''" json:"name"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
|
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||||
Revoked bool `gorm:"not null;default:false" json:"revoked"`
|
SecretHash *string `json:"-"`
|
||||||
Prefix *string `json:"prefix,omitempty"`
|
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
|
||||||
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
|
||||||
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
|
Revoked bool `gorm:"not null;default:false" json:"revoked"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
|
Prefix *string `json:"prefix,omitempty"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ClusterStatusPrePending = "pre_pending" // needs validation
|
ClusterStatusPrePending = "pre_pending" // needs validation
|
||||||
ClusterStatusIncomplete = "incomplete" // invalid/missing shape
|
ClusterStatusIncomplete = "incomplete" // invalid/missing shape
|
||||||
ClusterStatusPending = "pending" // valid shape, waiting for provisioning
|
ClusterStatusPending = "pending" // valid shape, waiting for provisioning
|
||||||
ClusterStatusProvisioning = "provisioning"
|
ClusterStatusProvisioning = "provisioning"
|
||||||
ClusterStatusReady = "ready"
|
ClusterStatusReady = "ready"
|
||||||
ClusterStatusFailed = "failed" // provisioning/runtime failure
|
ClusterStatusFailed = "failed" // provisioning/runtime failure
|
||||||
|
ClusterStatusBootstrapping = "bootstrapping"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cluster struct {
|
type Cluster struct {
|
||||||
|
|||||||
27
internal/models/cluster_runs.go
Normal file
27
internal/models/cluster_runs.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClusterRunStatusQueued = "queued"
|
||||||
|
ClusterRunStatusRunning = "running"
|
||||||
|
ClusterRunStatusSuccess = "success"
|
||||||
|
ClusterRunStatusFailed = "failed"
|
||||||
|
ClusterRunStatusCanceled = "canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClusterRun struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||||
|
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
|
||||||
|
ClusterID uuid.UUID `json:"cluster_id" gorm:"type:uuid;index"`
|
||||||
|
Action string `json:"action" gorm:"type:text;not null"`
|
||||||
|
Status string `json:"status" gorm:"type:text;not null"`
|
||||||
|
Error string `json:"error" gorm:"type:text;not null"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
|
||||||
|
FinishedAt time.Time `json:"finished_at,omitempty" gorm:"type:timestamptz" format:"date-time"`
|
||||||
|
}
|
||||||
4167
internal/web/dist/assets/index-BwyDjDcq.js
vendored
4167
internal/web/dist/assets/index-BwyDjDcq.js
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/index-BwyDjDcq.js.br
vendored
BIN
internal/web/dist/assets/index-BwyDjDcq.js.br
vendored
Binary file not shown.
BIN
internal/web/dist/assets/index-BwyDjDcq.js.gz
vendored
BIN
internal/web/dist/assets/index-BwyDjDcq.js.gz
vendored
Binary file not shown.
2
internal/web/dist/assets/index-Cdjh6IZW.css
vendored
Normal file
2
internal/web/dist/assets/index-Cdjh6IZW.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4186
internal/web/dist/assets/index-GteqH5KT.js
vendored
Normal file
4186
internal/web/dist/assets/index-GteqH5KT.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
internal/web/dist/assets/index-VHZG0dIU.css
vendored
2
internal/web/dist/assets/index-VHZG0dIU.css
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/index-VHZG0dIU.css.br
vendored
BIN
internal/web/dist/assets/index-VHZG0dIU.css.br
vendored
Binary file not shown.
BIN
internal/web/dist/assets/index-VHZG0dIU.css.gz
vendored
BIN
internal/web/dist/assets/index-VHZG0dIU.css.gz
vendored
Binary file not shown.
4
internal/web/dist/assets/react-Dt2M6tWj.js
vendored
4
internal/web/dist/assets/react-Dt2M6tWj.js
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/react-Dt2M6tWj.js.br
vendored
BIN
internal/web/dist/assets/react-Dt2M6tWj.js.br
vendored
Binary file not shown.
BIN
internal/web/dist/assets/react-Dt2M6tWj.js.gz
vendored
BIN
internal/web/dist/assets/react-Dt2M6tWj.js.gz
vendored
Binary file not shown.
File diff suppressed because one or more lines are too long
4
internal/web/dist/assets/react-v1TLhXpT.js
vendored
Normal file
4
internal/web/dist/assets/react-v1TLhXpT.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/web/dist/assets/react-v1TLhXpT.js.map
vendored
Normal file
1
internal/web/dist/assets/react-v1TLhXpT.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
internal/web/dist/index.html
vendored
6
internal/web/dist/index.html
vendored
@@ -5,9 +5,9 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AutoGlue</title>
|
<title>AutoGlue</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BwyDjDcq.js"></script>
|
<script type="module" crossorigin src="/assets/index-GteqH5KT.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/react-Dt2M6tWj.js">
|
<link rel="modulepreload" crossorigin href="/assets/react-v1TLhXpT.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-VHZG0dIU.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cdjh6IZW.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
internal/web/dist/index.html.br
vendored
BIN
internal/web/dist/index.html.br
vendored
Binary file not shown.
BIN
internal/web/dist/index.html.gz
vendored
BIN
internal/web/dist/index.html.gz
vendored
Binary file not shown.
3
internal/web/dist/vite.svg
vendored
3
internal/web/dist/vite.svg
vendored
@@ -1,2 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88"
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,4 +1,4 @@
|
|||||||
FROM postgres:17.7@sha256:44640f16641cf36716cabd011e2f7eb4742b6b6b19f4488ddcbb7c250e5c9753
|
FROM postgres:17.7@sha256:dca7512acaa113409df7e40d977d801e53c0c8088e45d4311a45b4065ccfdcd3
|
||||||
|
|
||||||
RUN cd /var/lib/postgresql/ && \
|
RUN cd /var/lib/postgresql/ && \
|
||||||
openssl req -new -text -passout pass:abcd -subj /CN=localhost -out server.req -keyout privkey.pem && \
|
openssl req -new -text -passout pass:abcd -subj /CN=localhost -out server.req -keyout privkey.pem && \
|
||||||
|
|||||||
@@ -16,6 +16,6 @@
|
|||||||
"prepare": "npm run build"
|
"prepare": "npm run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^4.0 || ^5.0"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -46,41 +46,41 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.563.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.26",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"rapidoc": "^9.3.8",
|
"rapidoc": "^9.3.8",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.3",
|
||||||
"react-day-picker": "^9.11.3",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.3",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.70.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.11.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.2",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "4.7.0",
|
"@ianvs/prettier-plugin-sort-imports": "4.7.0",
|
||||||
"@types/node": "24.10.1",
|
"@types/node": "25.2.2",
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.13",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "5.1.1",
|
"@vitejs/plugin-react": "5.1.3",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.2",
|
||||||
"eslint-plugin-react-hooks": "7.0.1",
|
"eslint-plugin-react-hooks": "7.0.1",
|
||||||
"eslint-plugin-react-refresh": "0.4.24",
|
"eslint-plugin-react-refresh": "0.5.0",
|
||||||
"globals": "16.5.0",
|
"globals": "16.5.0",
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.8.1",
|
||||||
"prettier-plugin-tailwindcss": "0.7.2",
|
"prettier-plugin-tailwindcss": "0.7.2",
|
||||||
"shadcn": "3.5.1",
|
"shadcn": "3.8.3",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.48.1",
|
"typescript-eslint": "8.54.0",
|
||||||
"vite": "7.2.6"
|
"vite": "7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AppShell } from "@/layouts/app-shell.tsx"
|
|||||||
import { Route, Routes } from "react-router-dom"
|
import { Route, Routes } from "react-router-dom"
|
||||||
|
|
||||||
import { ProtectedRoute } from "@/components/protected-route.tsx"
|
import { ProtectedRoute } from "@/components/protected-route.tsx"
|
||||||
|
import { ActionsPage } from "@/pages/actions-page.tsx"
|
||||||
import { AnnotationPage } from "@/pages/annotation-page.tsx"
|
import { AnnotationPage } from "@/pages/annotation-page.tsx"
|
||||||
import { ClustersPage } from "@/pages/cluster-page"
|
import { ClustersPage } from "@/pages/cluster-page"
|
||||||
import { CredentialPage } from "@/pages/credential-page.tsx"
|
import { CredentialPage } from "@/pages/credential-page.tsx"
|
||||||
@@ -46,6 +47,7 @@ export default function App() {
|
|||||||
<Route path="/clusters" element={<ClustersPage />} />
|
<Route path="/clusters" element={<ClustersPage />} />
|
||||||
|
|
||||||
<Route path="/admin/jobs" element={<JobsPage />} />
|
<Route path="/admin/jobs" element={<JobsPage />} />
|
||||||
|
<Route path="/admin/actions" element={<ActionsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Login />} />
|
<Route path="*" element={<Login />} />
|
||||||
|
|||||||
30
ui/src/api/actions.ts
Normal file
30
ui/src/api/actions.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { withRefresh } from "@/api/with-refresh.ts"
|
||||||
|
import type { DtoCreateActionRequest, DtoUpdateActionRequest } from "@/sdk"
|
||||||
|
import { makeActionsApi } from "@/sdkClient.ts"
|
||||||
|
|
||||||
|
const actions = makeActionsApi()
|
||||||
|
export const actionsApi = {
|
||||||
|
listActions: () =>
|
||||||
|
withRefresh(async () => {
|
||||||
|
return await actions.listActions()
|
||||||
|
}),
|
||||||
|
createAction: (body: DtoCreateActionRequest) =>
|
||||||
|
withRefresh(async () => {
|
||||||
|
return await actions.createAction({
|
||||||
|
dtoCreateActionRequest: body,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
updateAction: (id: string, body: DtoUpdateActionRequest) =>
|
||||||
|
withRefresh(async () => {
|
||||||
|
return await actions.updateAction({
|
||||||
|
actionID: id,
|
||||||
|
dtoUpdateActionRequest: body,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
deleteAction: (id: string) =>
|
||||||
|
withRefresh(async () => {
|
||||||
|
await actions.deleteAction({
|
||||||
|
actionID: id,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -8,9 +8,10 @@ import type {
|
|||||||
DtoSetKubeconfigRequest,
|
DtoSetKubeconfigRequest,
|
||||||
DtoUpdateClusterRequest,
|
DtoUpdateClusterRequest,
|
||||||
} from "@/sdk"
|
} from "@/sdk"
|
||||||
import { makeClusterApi } from "@/sdkClient"
|
import { makeClusterApi, makeClusterRunsApi } from "@/sdkClient"
|
||||||
|
|
||||||
const clusters = makeClusterApi()
|
const clusters = makeClusterApi()
|
||||||
|
const clusterRuns = makeClusterRunsApi()
|
||||||
|
|
||||||
export const clustersApi = {
|
export const clustersApi = {
|
||||||
// --- basic CRUD ---
|
// --- basic CRUD ---
|
||||||
@@ -147,4 +148,20 @@ export const clustersApi = {
|
|||||||
withRefresh(async () => {
|
withRefresh(async () => {
|
||||||
return await clusters.detachNodePool({ clusterID, nodePoolID })
|
return await clusters.detachNodePool({ clusterID, nodePoolID })
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// --- cluster runs / actions ---
|
||||||
|
listClusterRuns: (clusterID: string) =>
|
||||||
|
withRefresh(async () => {
|
||||||
|
return await clusterRuns.listClusterRuns({ clusterID })
|
||||||
|
}),
|
||||||
|
|
||||||
|
getClusterRun: (clusterID: string, runID: string) =>
|
||||||
|
withRefresh(async () => {
|
||||||
|
return await clusterRuns.getClusterRun({ clusterID, runID })
|
||||||
|
}),
|
||||||
|
|
||||||
|
runClusterAction: (clusterID: string, actionID: string) =>
|
||||||
|
withRefresh(async () => {
|
||||||
|
return await clusterRuns.runClusterAction({ clusterID, actionID })
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FileKey2Icon,
|
FileKey2Icon,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LockKeyholeIcon,
|
LockKeyholeIcon,
|
||||||
|
PickaxeIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
SprayCanIcon,
|
SprayCanIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
@@ -49,5 +50,6 @@ export const userNav: NavItem[] = [{ to: "/me", label: "Profile", icon: User2 }]
|
|||||||
export const adminNav: NavItem[] = [
|
export const adminNav: NavItem[] = [
|
||||||
{ to: "/admin/users", label: "Users Admin", icon: Users },
|
{ to: "/admin/users", label: "Users Admin", icon: Users },
|
||||||
{ to: "/admin/jobs", label: "Jobs Admin", icon: GrUserWorker },
|
{ to: "/admin/jobs", label: "Jobs Admin", icon: GrUserWorker },
|
||||||
|
{ to: "/admin/actions", label: "Actions Admin", icon: PickaxeIcon},
|
||||||
{ to: "/docs", label: "API Docs ", icon: SiSwagger, target: "_blank" },
|
{ to: "/docs", label: "API Docs ", icon: SiSwagger, target: "_blank" },
|
||||||
]
|
]
|
||||||
|
|||||||
433
ui/src/pages/actions-page.tsx
Normal file
433
ui/src/pages/actions-page.tsx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { actionsApi } from "@/api/actions.ts"
|
||||||
|
import type { DtoActionResponse } from "@/sdk"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { AlertCircle, CircleSlash2, Loader2, Pencil, Plus, Search, Trash2 } from "lucide-react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge.tsx"
|
||||||
|
import { Button } from "@/components/ui/button.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 {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table.tsx"
|
||||||
|
import { Textarea } from "@/components/ui/textarea.tsx"
|
||||||
|
|
||||||
|
const createActionSchema = z.object({
|
||||||
|
label: z.string().trim().min(1, "Label is required").max(255, "Max 255 chars"),
|
||||||
|
description: z.string().trim().min(1, "Description is required"),
|
||||||
|
make_target: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Make target is required")
|
||||||
|
.max(255, "Max 255 chars")
|
||||||
|
// keep client-side fairly strict to avoid surprises; server should also validate
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/, "Invalid make target (allowed: a-z A-Z 0-9 . _ -)"),
|
||||||
|
})
|
||||||
|
type CreateActionInput = z.input<typeof createActionSchema>
|
||||||
|
|
||||||
|
const updateActionSchema = createActionSchema.partial()
|
||||||
|
type UpdateActionInput = z.input<typeof updateActionSchema>
|
||||||
|
|
||||||
|
function TargetBadge({ target }: { target?: string | null }) {
|
||||||
|
if (!target) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
—
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
{target}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionsPage = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState("")
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [updateOpen, setUpdateOpen] = useState(false)
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState<DtoActionResponse | null>(null)
|
||||||
|
|
||||||
|
const actionsQ = useQuery({
|
||||||
|
queryKey: ["admin-actions"],
|
||||||
|
queryFn: () => actionsApi.listActions(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const data: DtoActionResponse[] = actionsQ.data ?? []
|
||||||
|
const q = filter.trim().toLowerCase()
|
||||||
|
if (!q) return data
|
||||||
|
|
||||||
|
return data.filter((a) => {
|
||||||
|
return (
|
||||||
|
(a.label ?? "").toLowerCase().includes(q) ||
|
||||||
|
(a.description ?? "").toLowerCase().includes(q) ||
|
||||||
|
(a.make_target ?? "").toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [filter, actionsQ.data])
|
||||||
|
|
||||||
|
const createForm = useForm<CreateActionInput>({
|
||||||
|
resolver: zodResolver(createActionSchema),
|
||||||
|
defaultValues: {
|
||||||
|
label: "",
|
||||||
|
description: "",
|
||||||
|
make_target: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (values: CreateActionInput) => actionsApi.createAction(values),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
|
||||||
|
createForm.reset()
|
||||||
|
setCreateOpen(false)
|
||||||
|
toast.success("Action created.")
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.message ?? "Failed to create action.")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateForm = useForm<UpdateActionInput>({
|
||||||
|
resolver: zodResolver(updateActionSchema),
|
||||||
|
defaultValues: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: UpdateActionInput }) =>
|
||||||
|
actionsApi.updateAction(id, values),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
|
||||||
|
updateForm.reset()
|
||||||
|
setUpdateOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
toast.success("Action updated.")
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.message ?? "Failed to update action.")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const openEdit = (a: DtoActionResponse) => {
|
||||||
|
if (!a.id) return
|
||||||
|
setEditing(a)
|
||||||
|
updateForm.reset({
|
||||||
|
label: a.label ?? "",
|
||||||
|
description: a.description ?? "",
|
||||||
|
make_target: a.make_target ?? "",
|
||||||
|
})
|
||||||
|
setUpdateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: string) => actionsApi.deleteAction(id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
|
||||||
|
setDeleteId(null)
|
||||||
|
toast.success("Action deleted.")
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.message ?? "Failed to delete action.")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (actionsQ.isLoading) return <div className="p-6">Loading actions…</div>
|
||||||
|
if (actionsQ.error) return <div className="p-6 text-red-500">Error loading actions.</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Admin Actions</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
|
||||||
|
<Input
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="Search actions"
|
||||||
|
className="w-72 pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Action
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Action</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...createForm}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={createForm.handleSubmit((v) => createMut.mutate(v))}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="label"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Label</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Setup" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="make_target"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Make Target</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="setup" className="font-mono" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={createForm.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder="Runs prepare, ping-servers, then make setup on the bastion."
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createMut.isPending}>
|
||||||
|
{createMut.isPending ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Label</TableHead>
|
||||||
|
<TableHead>Make Target</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className="w-[260px] text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="font-medium">{a.label}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TargetBadge target={a.make_target} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground max-w-[680px] truncate">
|
||||||
|
{a.description}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(a)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => a.id && setDeleteId(a.id)}
|
||||||
|
disabled={deleteMut.isPending && deleteId === a.id}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{deleteMut.isPending && deleteId === a.id ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground py-10 text-center">
|
||||||
|
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
|
||||||
|
No actions match your search.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={updateOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setUpdateOpen(open)
|
||||||
|
if (!open) setEditing(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Action</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<Form {...updateForm}>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={updateForm.handleSubmit((values) => {
|
||||||
|
if (!editing.id) return
|
||||||
|
updateMut.mutate({ id: editing.id, values })
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={updateForm.control}
|
||||||
|
name="label"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Label</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={updateForm.control}
|
||||||
|
name="make_target"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Make Target</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="font-mono" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={updateForm.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea rows={4} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setUpdateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateMut.isPending}>
|
||||||
|
{updateMut.isPending ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Save changes"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-sm">No action selected.</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete confirm dialog */}
|
||||||
|
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete action</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="mt-0.5 h-5 w-5 text-red-500" />
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
This action cannot be undone. Are you sure you want to delete it?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteId && deleteMut.mutate(deleteId)}
|
||||||
|
disabled={deleteMut.isPending}
|
||||||
|
>
|
||||||
|
{deleteMut.isPending ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,76 +1,34 @@
|
|||||||
// src/pages/ClustersPage.tsx
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { actionsApi } from "@/api/actions";
|
||||||
|
import { clustersApi } from "@/api/clusters";
|
||||||
|
import { dnsApi } from "@/api/dns";
|
||||||
|
import { loadBalancersApi } from "@/api/loadbalancers";
|
||||||
|
import { nodePoolsApi } from "@/api/node_pools";
|
||||||
|
import { serversApi } from "@/api/servers";
|
||||||
|
import type { DtoActionResponse, DtoClusterResponse, DtoClusterRunResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench } from "lucide-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import { truncateMiddle } from "@/lib/utils";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import { clustersApi } from "@/api/clusters"
|
|
||||||
import { dnsApi } from "@/api/dns"
|
|
||||||
import { loadBalancersApi } from "@/api/loadbalancers"
|
|
||||||
import { nodePoolsApi } from "@/api/node_pools"
|
|
||||||
import { serversApi } from "@/api/servers"
|
|
||||||
import type {
|
|
||||||
DtoClusterResponse,
|
|
||||||
DtoDomainResponse,
|
|
||||||
DtoLoadBalancerResponse,
|
|
||||||
DtoNodePoolResponse,
|
|
||||||
DtoRecordSetResponse,
|
|
||||||
DtoServerResponse,
|
|
||||||
} from "@/sdk"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
CircleSlash2,
|
|
||||||
FileCode2,
|
|
||||||
Globe2,
|
|
||||||
Loader2,
|
|
||||||
MapPin,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Server,
|
|
||||||
Wrench,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
import { truncateMiddle } from "@/lib/utils"
|
|
||||||
import { Badge } from "@/components/ui/badge.tsx"
|
|
||||||
import { Button } from "@/components/ui/button.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 { Label } from "@/components/ui/label.tsx"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select.tsx"
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table.tsx"
|
|
||||||
import { Textarea } from "@/components/ui/textarea.tsx"
|
|
||||||
|
|
||||||
// --- Schemas ---
|
// --- Schemas ---
|
||||||
|
|
||||||
@@ -86,6 +44,22 @@ type CreateClusterInput = z.input<typeof createClusterSchema>
|
|||||||
const updateClusterSchema = createClusterSchema.partial()
|
const updateClusterSchema = createClusterSchema.partial()
|
||||||
type UpdateClusterValues = z.infer<typeof updateClusterSchema>
|
type UpdateClusterValues = z.infer<typeof updateClusterSchema>
|
||||||
|
|
||||||
|
// --- Data normalization helpers (fixes rows.some is not a function) ---
|
||||||
|
|
||||||
|
function asArray<T>(res: any): T[] {
|
||||||
|
if (Array.isArray(res)) return res as T[]
|
||||||
|
if (Array.isArray(res?.data)) return res.data as T[]
|
||||||
|
if (Array.isArray(res?.body)) return res.body as T[]
|
||||||
|
if (Array.isArray(res?.result)) return res.result as T[]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject<T>(res: any): T {
|
||||||
|
// for get endpoints that might return {data: {...}}
|
||||||
|
if (res?.data && typeof res.data === "object") return res.data as T
|
||||||
|
return res as T
|
||||||
|
}
|
||||||
|
|
||||||
// --- UI helpers ---
|
// --- UI helpers ---
|
||||||
|
|
||||||
function StatusBadge({ status }: { status?: string | null }) {
|
function StatusBadge({ status }: { status?: string | null }) {
|
||||||
@@ -142,6 +116,61 @@ function StatusBadge({ status }: { status?: string | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RunStatusBadge({ status }: { status?: string | null }) {
|
||||||
|
const s = (status ?? "").toLowerCase()
|
||||||
|
|
||||||
|
if (!s)
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
unknown
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (s === "succeeded" || s === "success") {
|
||||||
|
return (
|
||||||
|
<Badge variant="default" className="flex items-center gap-1 text-xs">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
succeeded
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s === "failed" || s === "error") {
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="flex items-center gap-1 text-xs">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
failed
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s === "queued" || s === "running") {
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="flex items-center gap-1 text-xs">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
{s}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{s}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(v: any): string {
|
||||||
|
if (!v) return "-"
|
||||||
|
try {
|
||||||
|
const d = v instanceof Date ? v : new Date(v)
|
||||||
|
if (Number.isNaN(d.getTime())) return "-"
|
||||||
|
return d.toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ClusterSummary({ c }: { c: DtoClusterResponse }) {
|
function ClusterSummary({ c }: { c: DtoClusterResponse }) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex flex-col gap-1 text-xs">
|
<div className="text-muted-foreground flex flex-col gap-1 text-xs">
|
||||||
@@ -182,7 +211,7 @@ export const ClustersPage = () => {
|
|||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Config dialog state
|
// Configure dialog state
|
||||||
const [configCluster, setConfigCluster] = useState<DtoClusterResponse | null>(null)
|
const [configCluster, setConfigCluster] = useState<DtoClusterResponse | null>(null)
|
||||||
|
|
||||||
const [captainDomainId, setCaptainDomainId] = useState("")
|
const [captainDomainId, setCaptainDomainId] = useState("")
|
||||||
@@ -202,36 +231,69 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
const clustersQ = useQuery({
|
const clustersQ = useQuery({
|
||||||
queryKey: ["clusters"],
|
queryKey: ["clusters"],
|
||||||
queryFn: () => clustersApi.listClusters(),
|
queryFn: async () => asArray<DtoClusterResponse>(await clustersApi.listClusters()),
|
||||||
})
|
})
|
||||||
|
|
||||||
const lbsQ = useQuery({
|
const lbsQ = useQuery({
|
||||||
queryKey: ["load-balancers"],
|
queryKey: ["load-balancers"],
|
||||||
queryFn: () => loadBalancersApi.listLoadBalancers(),
|
queryFn: async () =>
|
||||||
|
asArray<DtoLoadBalancerResponse>(await loadBalancersApi.listLoadBalancers()),
|
||||||
})
|
})
|
||||||
|
|
||||||
const domainsQ = useQuery({
|
const domainsQ = useQuery({
|
||||||
queryKey: ["domains"],
|
queryKey: ["domains"],
|
||||||
queryFn: () => dnsApi.listDomains(),
|
queryFn: async () => asArray<DtoDomainResponse>(await dnsApi.listDomains()),
|
||||||
})
|
})
|
||||||
|
|
||||||
// record sets fetched per captain domain
|
|
||||||
const recordSetsQ = useQuery({
|
const recordSetsQ = useQuery({
|
||||||
queryKey: ["record-sets", captainDomainId],
|
queryKey: ["record-sets", captainDomainId],
|
||||||
enabled: !!captainDomainId,
|
enabled: !!captainDomainId,
|
||||||
queryFn: () => dnsApi.listRecordSetsByDomain(captainDomainId),
|
queryFn: async () =>
|
||||||
|
asArray<DtoRecordSetResponse>(await dnsApi.listRecordSetsByDomain(captainDomainId)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const serversQ = useQuery({
|
const serversQ = useQuery({
|
||||||
queryKey: ["servers"],
|
queryKey: ["servers"],
|
||||||
queryFn: () => serversApi.listServers(),
|
queryFn: async () => asArray<DtoServerResponse>(await serversApi.listServers()),
|
||||||
})
|
})
|
||||||
|
|
||||||
const npQ = useQuery({
|
const npQ = useQuery({
|
||||||
queryKey: ["node-pools"],
|
queryKey: ["node-pools"],
|
||||||
queryFn: () => nodePoolsApi.listNodePools(),
|
queryFn: async () => asArray<DtoNodePoolResponse>(await nodePoolsApi.listNodePools()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const actionsQ = useQuery({
|
||||||
|
queryKey: ["actions"],
|
||||||
|
queryFn: async () => asArray<DtoActionResponse>(await actionsApi.listActions()),
|
||||||
|
})
|
||||||
|
|
||||||
|
const runsQ = useQuery({
|
||||||
|
queryKey: ["cluster-runs", configCluster?.id],
|
||||||
|
enabled: !!configCluster?.id,
|
||||||
|
queryFn: async () =>
|
||||||
|
asArray<DtoClusterRunResponse>(await clustersApi.listClusterRuns(configCluster!.id!)),
|
||||||
|
refetchInterval: (data) => {
|
||||||
|
// IMPORTANT: data might not be array if queryFn isn't normalizing. But it is here anyway.
|
||||||
|
const rows = Array.isArray(data) ? data : []
|
||||||
|
const active = rows.some((r: any) => {
|
||||||
|
const s = String(r?.status ?? "").toLowerCase()
|
||||||
|
return s === "queued" || s === "running"
|
||||||
|
})
|
||||||
|
return active ? 2000 : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionLabelByTarget = useMemo(() => {
|
||||||
|
const m = new Map<string, string>()
|
||||||
|
;(actionsQ.data ?? []).forEach((a) => {
|
||||||
|
if (a.make_target) m.set(a.make_target, a.label ?? a.make_target)
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}, [actionsQ.data])
|
||||||
|
|
||||||
|
const runDisplayName = (r: DtoClusterRunResponse) =>
|
||||||
|
actionLabelByTarget.get(r.action ?? "") ?? r.action ?? "unknown"
|
||||||
|
|
||||||
// --- Create ---
|
// --- Create ---
|
||||||
|
|
||||||
const createForm = useForm<CreateClusterInput>({
|
const createForm = useForm<CreateClusterInput>({
|
||||||
@@ -253,15 +315,10 @@ export const ClustersPage = () => {
|
|||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
toast.success("Cluster created successfully.")
|
toast.success("Cluster created successfully.")
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) =>
|
||||||
toast.error(err?.message ?? "There was an error while creating the cluster")
|
toast.error(err?.message ?? "There was an error while creating the cluster"),
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const onCreateSubmit = (values: CreateClusterInput) => {
|
|
||||||
createMut.mutate(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Update basic details ---
|
// --- Update basic details ---
|
||||||
|
|
||||||
const updateForm = useForm<UpdateClusterValues>({
|
const updateForm = useForm<UpdateClusterValues>({
|
||||||
@@ -278,9 +335,8 @@ export const ClustersPage = () => {
|
|||||||
setUpdateOpen(false)
|
setUpdateOpen(false)
|
||||||
toast.success("Cluster updated successfully.")
|
toast.success("Cluster updated successfully.")
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) =>
|
||||||
toast.error(err?.message ?? "There was an error while updating the cluster")
|
toast.error(err?.message ?? "There was an error while updating the cluster"),
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const openEdit = (cluster: DtoClusterResponse) => {
|
const openEdit = (cluster: DtoClusterResponse) => {
|
||||||
@@ -305,11 +361,32 @@ export const ClustersPage = () => {
|
|||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
toast.success("Cluster deleted successfully.")
|
toast.success("Cluster deleted successfully.")
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) =>
|
||||||
toast.error(err?.message ?? "There was an error while deleting the cluster")
|
toast.error(err?.message ?? "There was an error while deleting the cluster"),
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Run Action ---
|
||||||
|
|
||||||
|
const runActionMut = useMutation({
|
||||||
|
mutationFn: ({ clusterID, actionID }: { clusterID: string; actionID: string }) =>
|
||||||
|
clustersApi.runClusterAction(clusterID, actionID),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster?.id] })
|
||||||
|
toast.success("Action enqueued.")
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err?.message ?? "Failed to enqueue action."),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRunAction(actionID: string) {
|
||||||
|
if (!configCluster?.id) return
|
||||||
|
setBusyKey(`run:${actionID}`)
|
||||||
|
try {
|
||||||
|
await runActionMut.mutateAsync({ clusterID: configCluster.id, actionID })
|
||||||
|
} finally {
|
||||||
|
setBusyKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Filter ---
|
// --- Filter ---
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -342,30 +419,23 @@ export const ClustersPage = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefill IDs from current attachments
|
if (configCluster.captain_domain?.id) setCaptainDomainId(configCluster.captain_domain.id)
|
||||||
if (configCluster.captain_domain?.id) {
|
if (configCluster.control_plane_record_set?.id)
|
||||||
setCaptainDomainId(configCluster.captain_domain.id)
|
|
||||||
}
|
|
||||||
if (configCluster.control_plane_record_set?.id) {
|
|
||||||
setRecordSetId(configCluster.control_plane_record_set.id)
|
setRecordSetId(configCluster.control_plane_record_set.id)
|
||||||
}
|
if (configCluster.apps_load_balancer?.id) setAppsLbId(configCluster.apps_load_balancer.id)
|
||||||
if (configCluster.apps_load_balancer?.id) {
|
if (configCluster.glueops_load_balancer?.id)
|
||||||
setAppsLbId(configCluster.apps_load_balancer.id)
|
|
||||||
}
|
|
||||||
if (configCluster.glueops_load_balancer?.id) {
|
|
||||||
setGlueopsLbId(configCluster.glueops_load_balancer.id)
|
setGlueopsLbId(configCluster.glueops_load_balancer.id)
|
||||||
}
|
if (configCluster.bastion_server?.id) setBastionId(configCluster.bastion_server.id)
|
||||||
if (configCluster.bastion_server?.id) {
|
|
||||||
setBastionId(configCluster.bastion_server.id)
|
|
||||||
}
|
|
||||||
}, [configCluster])
|
}, [configCluster])
|
||||||
|
|
||||||
async function refreshConfigCluster() {
|
async function refreshConfigCluster() {
|
||||||
if (!configCluster?.id) return
|
if (!configCluster?.id) return
|
||||||
try {
|
try {
|
||||||
const updated = await clustersApi.getCluster(configCluster.id)
|
const updatedRaw = await clustersApi.getCluster(configCluster.id)
|
||||||
|
const updated = asObject<DtoClusterResponse>(updatedRaw)
|
||||||
setConfigCluster(updated)
|
setConfigCluster(updated)
|
||||||
await qc.invalidateQueries({ queryKey: ["clusters"] })
|
await qc.invalidateQueries({ queryKey: ["clusters"] })
|
||||||
|
await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster.id] })
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -373,15 +443,10 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
async function handleAttachCaptain() {
|
async function handleAttachCaptain() {
|
||||||
if (!configCluster?.id) return
|
if (!configCluster?.id) return
|
||||||
if (!captainDomainId) {
|
if (!captainDomainId) return toast.error("Domain is required")
|
||||||
toast.error("Domain is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusyKey("captain")
|
setBusyKey("captain")
|
||||||
try {
|
try {
|
||||||
await clustersApi.attachCaptainDomain(configCluster.id, {
|
await clustersApi.attachCaptainDomain(configCluster.id, { domain_id: captainDomainId })
|
||||||
domain_id: captainDomainId,
|
|
||||||
})
|
|
||||||
toast.success("Captain domain attached.")
|
toast.success("Captain domain attached.")
|
||||||
await refreshConfigCluster()
|
await refreshConfigCluster()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -407,10 +472,7 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
async function handleAttachRecordSet() {
|
async function handleAttachRecordSet() {
|
||||||
if (!configCluster?.id) return
|
if (!configCluster?.id) return
|
||||||
if (!recordSetId) {
|
if (!recordSetId) return toast.error("Record set is required")
|
||||||
toast.error("Record set is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusyKey("recordset")
|
setBusyKey("recordset")
|
||||||
try {
|
try {
|
||||||
await clustersApi.attachControlPlaneRecordSet(configCluster.id, {
|
await clustersApi.attachControlPlaneRecordSet(configCluster.id, {
|
||||||
@@ -441,15 +503,10 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
async function handleAttachAppsLb() {
|
async function handleAttachAppsLb() {
|
||||||
if (!configCluster?.id) return
|
if (!configCluster?.id) return
|
||||||
if (!appsLbId) {
|
if (!appsLbId) return toast.error("Load balancer is required")
|
||||||
toast.error("Load balancer is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusyKey("apps-lb")
|
setBusyKey("apps-lb")
|
||||||
try {
|
try {
|
||||||
await clustersApi.attachAppsLoadBalancer(configCluster.id, {
|
await clustersApi.attachAppsLoadBalancer(configCluster.id, { load_balancer_id: appsLbId })
|
||||||
load_balancer_id: appsLbId,
|
|
||||||
})
|
|
||||||
toast.success("Apps load balancer attached.")
|
toast.success("Apps load balancer attached.")
|
||||||
await refreshConfigCluster()
|
await refreshConfigCluster()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -475,10 +532,7 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
async function handleAttachGlueopsLb() {
|
async function handleAttachGlueopsLb() {
|
||||||
if (!configCluster?.id) return
|
if (!configCluster?.id) return
|
||||||
if (!glueopsLbId) {
|
if (!glueopsLbId) return toast.error("Load balancer is required")
|
||||||
toast.error("Load balancer is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusyKey("glueops-lb")
|
setBusyKey("glueops-lb")
|
||||||
try {
|
try {
|
||||||
await clustersApi.attachGlueOpsLoadBalancer(configCluster.id, {
|
await clustersApi.attachGlueOpsLoadBalancer(configCluster.id, {
|
||||||
@@ -509,15 +563,10 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
async function handleAttachBastion() {
|
async function handleAttachBastion() {
|
||||||
if (!configCluster?.id) return
|
if (!configCluster?.id) return
|
||||||
if (!bastionId) {
|
if (!bastionId) return toast.error("Server is required")
|
||||||
toast.error("Server is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusyKey("bastion")
|
setBusyKey("bastion")
|
||||||
try {
|
try {
|
||||||
await clustersApi.attachBastion(configCluster.id, {
|
await clustersApi.attachBastion(configCluster.id, { server_id: bastionId })
|
||||||
server_id: bastionId,
|
|
||||||
})
|
|
||||||
toast.success("Bastion server attached.")
|
toast.success("Bastion server attached.")
|
||||||
await refreshConfigCluster()
|
await refreshConfigCluster()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -543,10 +592,7 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
async function handleAttachNodePool() {
|
async function handleAttachNodePool() {
|
||||||
if (!configCluster?.id) return
|
if (!configCluster?.id) return
|
||||||
if (!nodePoolId) {
|
if (!nodePoolId) return toast.error("Node pool is required")
|
||||||
toast.error("Node pool is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusyKey("nodepool")
|
setBusyKey("nodepool")
|
||||||
try {
|
try {
|
||||||
await clustersApi.attachNodePool(configCluster.id, nodePoolId)
|
await clustersApi.attachNodePool(configCluster.id, nodePoolId)
|
||||||
@@ -576,15 +622,10 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
async function handleSetKubeconfig() {
|
async function handleSetKubeconfig() {
|
||||||
if (!configCluster?.id) return
|
if (!configCluster?.id) return
|
||||||
if (!kubeconfigText.trim()) {
|
if (!kubeconfigText.trim()) return toast.error("Kubeconfig is required")
|
||||||
toast.error("Kubeconfig is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusyKey("kubeconfig")
|
setBusyKey("kubeconfig")
|
||||||
try {
|
try {
|
||||||
await clustersApi.setKubeconfig(configCluster.id, {
|
await clustersApi.setKubeconfig(configCluster.id, { kubeconfig: kubeconfigText })
|
||||||
kubeconfig: kubeconfigText,
|
|
||||||
})
|
|
||||||
toast.success("Kubeconfig updated.")
|
toast.success("Kubeconfig updated.")
|
||||||
setKubeconfigText("")
|
setKubeconfigText("")
|
||||||
await refreshConfigCluster()
|
await refreshConfigCluster()
|
||||||
@@ -645,7 +686,10 @@ export const ClustersPage = () => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...createForm}>
|
<Form {...createForm}>
|
||||||
<form className="space-y-4" onSubmit={createForm.handleSubmit(onCreateSubmit)}>
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={createForm.handleSubmit((v) => createMut.mutate(v))}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={createForm.control}
|
control={createForm.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -759,7 +803,7 @@ export const ClustersPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{c.docker_image + ":" + c.docker_tag}</TableCell>
|
<TableCell>{(c.docker_image ?? "") + ":" + (c.docker_tag ?? "")}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ClusterSummary c={c} />
|
<ClusterSummary c={c} />
|
||||||
{c.id && (
|
{c.id && (
|
||||||
@@ -791,7 +835,7 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-muted-foreground py-10 text-center">
|
<TableCell colSpan={7} className="text-muted-foreground py-10 text-center">
|
||||||
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
|
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
|
||||||
No clusters match your search.
|
No clusters match your search.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -808,6 +852,7 @@ export const ClustersPage = () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Cluster</DialogTitle>
|
<DialogTitle>Edit Cluster</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...updateForm}>
|
<Form {...updateForm}>
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
@@ -863,7 +908,7 @@ export const ClustersPage = () => {
|
|||||||
name="docker_image"
|
name="docker_image"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Region</FormLabel>
|
<FormLabel>Docker Image</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="ghcr.io/glueops/gluekube" {...field} />
|
<Input placeholder="ghcr.io/glueops/gluekube" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -877,7 +922,7 @@ export const ClustersPage = () => {
|
|||||||
name="docker_tag"
|
name="docker_tag"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Region</FormLabel>
|
<FormLabel>Docker Tag</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="v1.33" {...field} />
|
<Input placeholder="v1.33" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -899,37 +944,154 @@ export const ClustersPage = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Configure dialog (attachments + kubeconfig + node pools) */}
|
{/* Configure dialog (attachments + kubeconfig + node pools + actions/runs) */}
|
||||||
<Dialog open={!!configCluster} onOpenChange={(open) => !open && setConfigCluster(null)}>
|
<Dialog open={!!configCluster} onOpenChange={(open) => !open && setConfigCluster(null)}>
|
||||||
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl lg:max-w-250 ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Configure Cluster{configCluster?.name ? `: ${configCluster.name}` : ""}
|
Configure Cluster{configCluster?.name ? `: ${configCluster.name}` : ""}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{configCluster && (
|
{configCluster && (
|
||||||
<div className="space-y-6 py-2">
|
<div className="space-y-6 py-2">
|
||||||
{/* Kubeconfig */}
|
{/* Cluster Actions */}
|
||||||
<section className="space-y-2 rounded-xl border p-4">
|
<section className="space-y-2 rounded-xl border p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileCode2 className="h-4 w-4" />
|
<Wrench className="h-4 w-4" />
|
||||||
<h3 className="text-sm font-semibold">Kubeconfig</h3>
|
<h3 className="text-sm font-semibold">Cluster Actions</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Paste the kubeconfig for this cluster. It will be stored encrypted and never
|
Run admin-configured actions on this cluster. Actions are executed
|
||||||
returned by the API.
|
asynchronously.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => runsQ.refetch()}
|
||||||
|
disabled={runsQ.isFetching || !configCluster?.id}
|
||||||
|
>
|
||||||
|
{runsQ.isFetching ? "Refreshing…" : "Refresh runs"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{actionsQ.isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-xs">Loading actions…</p>
|
||||||
|
) : (actionsQ.data ?? []).length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
No actions configured yet. Create actions in Admin → Actions.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-border rounded-md border">
|
||||||
|
{(actionsQ.data ?? []).map((a: DtoActionResponse) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{a.label}</span>
|
||||||
|
{a.make_target && (
|
||||||
|
<code className="text-muted-foreground text-xs">
|
||||||
|
{a.make_target}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{a.description && (
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||||
|
{a.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => a.id && handleRunAction(a.id)}
|
||||||
|
disabled={!a.id || isBusy(`run:${a.id}`)}
|
||||||
|
>
|
||||||
|
{a.id && isBusy(`run:${a.id}`) ? "Enqueueing…" : "Run"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<Label className="text-xs">Recent Runs</Label>
|
||||||
|
|
||||||
|
{runsQ.isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-xs">Loading runs…</p>
|
||||||
|
) : (runsQ.data ?? []).length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-xs">No runs yet for this cluster.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Finished</TableHead>
|
||||||
|
<TableHead>Error</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(runsQ.data ?? []).slice(0, 20).map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="min-w-[220px]">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{runDisplayName(r)}</span>
|
||||||
|
{r.id && (
|
||||||
|
<code className="text-muted-foreground text-xs">
|
||||||
|
{truncateMiddle(r.id, 8)}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RunStatusBadge status={r.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{fmtTime((r as any).created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{fmtTime((r as any).finished_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{r.error ? truncateMiddle(r.error, 80) : "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Kubeconfig */}
|
||||||
|
<section className="space-y-2 rounded-xl border p-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCode2 className="h-4 w-4" />
|
||||||
|
<h3 className="text-sm font-semibold">Kubeconfig</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Paste the kubeconfig for this cluster. It will be stored encrypted and never
|
||||||
|
returned by the API.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
value={kubeconfigText}
|
value={kubeconfigText}
|
||||||
onChange={(e) => setKubeconfigText(e.target.value)}
|
onChange={(e) => setKubeconfigText(e.target.value)}
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder="apiVersion: v1 clusters: - cluster: ..."
|
placeholder={"apiVersion: v1\nclusters:\n - cluster: ..."}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1014,7 +1176,7 @@ export const ClustersPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Control Plane Record Set (shown once we have a captainDomainId) */}
|
{/* Control Plane Record Set */}
|
||||||
{captainDomainId && (
|
{captainDomainId && (
|
||||||
<section className="space-y-2 rounded-xl border p-4">
|
<section className="space-y-2 rounded-xl border p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -1251,14 +1413,12 @@ export const ClustersPage = () => {
|
|||||||
|
|
||||||
{/* Node Pools */}
|
{/* Node Pools */}
|
||||||
<section className="space-y-2 rounded-xl border p-4">
|
<section className="space-y-2 rounded-xl border p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div>
|
||||||
<div>
|
<h3 className="text-sm font-semibold">Node Pools</h3>
|
||||||
<h3 className="text-sm font-semibold">Node Pools</h3>
|
<p className="text-muted-foreground text-xs">
|
||||||
<p className="text-muted-foreground text-xs">
|
Attach node pools to this cluster. Each node pool may have its own labels,
|
||||||
Attach node pools to this cluster. Each node pool may have its own labels,
|
taints, and backing servers.
|
||||||
taints, and backing servers.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||||
@@ -1357,8 +1517,6 @@ export const ClustersPage = () => {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<pre>{JSON.stringify(clustersQ.data, null, 2)}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ const AWS_ALLOWED_SERVICES = ["route53", "s3", "ec2", "iam", "rds", "dynamodb"]
|
|||||||
type AwsSvc = (typeof AWS_ALLOWED_SERVICES)[number]
|
type AwsSvc = (typeof AWS_ALLOWED_SERVICES)[number]
|
||||||
|
|
||||||
// -------------------- Schemas --------------------
|
// -------------------- Schemas --------------------
|
||||||
|
// Zod v4 gotchas you hit:
|
||||||
|
// - .partial() cannot be used if the object contains refinements/effects (often true once you have transforms/refines).
|
||||||
|
// - .extend() cannot overwrite keys after refinements (requires .safeExtend()).
|
||||||
|
// Easiest fix: define CREATE and UPDATE schemas separately (no .partial(), no post-refinement .extend()).
|
||||||
|
|
||||||
const createCredentialSchema = z
|
const createCredentialSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -91,6 +95,16 @@ const createCredentialSchema = z
|
|||||||
secret: z.any(),
|
secret: z.any(),
|
||||||
})
|
})
|
||||||
.superRefine((val, ctx) => {
|
.superRefine((val, ctx) => {
|
||||||
|
// scope required unless provider scope
|
||||||
|
if (val.scope_kind !== "provider" && !val.scope) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["scope"],
|
||||||
|
message: `scope is required`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AWS scope checks
|
||||||
if (val.credential_provider === "aws") {
|
if (val.credential_provider === "aws") {
|
||||||
if (val.scope_kind === "service") {
|
if (val.scope_kind === "service") {
|
||||||
const svc = (val.scope as any)?.service
|
const svc = (val.scope as any)?.service
|
||||||
@@ -112,23 +126,25 @@ const createCredentialSchema = z
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (val.kind === "aws_access_key") {
|
}
|
||||||
const sk = val.secret ?? {}
|
|
||||||
const id = sk.access_key_id
|
// secret requiredness by kind (create always validates)
|
||||||
if (typeof id !== "string" || !/^[A-Z0-9]{20}$/.test(id)) {
|
if (val.kind === "aws_access_key") {
|
||||||
ctx.addIssue({
|
const sk = val.secret ?? {}
|
||||||
code: z.ZodIssueCode.custom,
|
const id = sk.access_key_id
|
||||||
path: ["secret"],
|
if (typeof id !== "string" || !/^[A-Z0-9]{20}$/.test(id)) {
|
||||||
message: `access_key_id must be 20 chars (A-Z0-9)`,
|
ctx.addIssue({
|
||||||
})
|
code: z.ZodIssueCode.custom,
|
||||||
}
|
path: ["secret"],
|
||||||
if (typeof sk.secret_access_key !== "string" || sk.secret_access_key.length < 10) {
|
message: `access_key_id must be 20 chars (A-Z0-9)`,
|
||||||
ctx.addIssue({
|
})
|
||||||
code: z.ZodIssueCode.custom,
|
}
|
||||||
path: ["secret"],
|
if (typeof sk.secret_access_key !== "string" || sk.secret_access_key.length < 10) {
|
||||||
message: `secret_access_key is required`,
|
ctx.addIssue({
|
||||||
})
|
code: z.ZodIssueCode.custom,
|
||||||
}
|
path: ["secret"],
|
||||||
|
message: `secret_access_key is required`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +158,7 @@ const createCredentialSchema = z
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (val.kind === "basic_auth") {
|
if (val.kind === "basic_auth") {
|
||||||
const s = val.secret ?? {}
|
const s = val.secret ?? {}
|
||||||
if (!s.username || !s.password) {
|
if (!s.username || !s.password) {
|
||||||
@@ -152,6 +169,7 @@ const createCredentialSchema = z
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (val.kind === "oauth2") {
|
if (val.kind === "oauth2") {
|
||||||
const s = val.secret ?? {}
|
const s = val.secret ?? {}
|
||||||
if (!s.client_id || !s.client_secret || !s.refresh_token) {
|
if (!s.client_id || !s.client_secret || !s.refresh_token) {
|
||||||
@@ -162,30 +180,144 @@ const createCredentialSchema = z
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (val.scope_kind !== "provider" && !val.scope) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["scope"],
|
|
||||||
message: `scope is required`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type CreateCredentialValues = z.input<typeof createCredentialSchema>
|
type CreateCredentialValues = z.input<typeof createCredentialSchema>
|
||||||
const updateCredentialSchema = createCredentialSchema.partial().extend({
|
|
||||||
name: z.string().min(1, "Name is required").max(100).optional(),
|
// UPDATE schema: all fields optional, and validations are "patch-friendly".
|
||||||
})
|
const updateCredentialSchema = z
|
||||||
|
.object({
|
||||||
|
credential_provider: z
|
||||||
|
.enum(["aws", "cloudflare", "hetzner", "digitalocean", "generic"])
|
||||||
|
.optional(),
|
||||||
|
kind: z.enum(["aws_access_key", "api_token", "basic_auth", "oauth2"]).optional(),
|
||||||
|
schema_version: z.number().optional(),
|
||||||
|
name: z.string().min(1, "Name is required").max(100).optional(),
|
||||||
|
scope_kind: z.enum(["provider", "service", "resource"]).optional(),
|
||||||
|
scope_version: z.number().optional(),
|
||||||
|
scope: z.any().optional(),
|
||||||
|
// allow "" so your form can keep empty strings; buildUpdateBody will drop them
|
||||||
|
account_id: z.string().optional().or(z.literal("")),
|
||||||
|
region: z.string().optional().or(z.literal("")),
|
||||||
|
secret: z.any().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
// If scope_kind is being changed to non-provider, require scope in the patch
|
||||||
|
if (typeof val.scope_kind !== "undefined") {
|
||||||
|
if (val.scope_kind !== "provider" && !val.scope) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["scope"],
|
||||||
|
message: `scope is required`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AWS scope checks only if we have enough info
|
||||||
|
if (val.credential_provider === "aws") {
|
||||||
|
if (val.scope_kind === "service" && typeof val.scope !== "undefined") {
|
||||||
|
const svc = (val.scope as any)?.service
|
||||||
|
if (!AWS_ALLOWED_SERVICES.includes(svc)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["scope"],
|
||||||
|
message: `For AWS service scope, "service" must be one of: ${AWS_ALLOWED_SERVICES.join(", ")}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (val.scope_kind === "resource" && typeof val.scope !== "undefined") {
|
||||||
|
const arn = (val.scope as any)?.arn
|
||||||
|
if (typeof arn !== "string" || !arn.startsWith("arn:")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["scope"],
|
||||||
|
message: `For AWS resource scope, "arn" must start with "arn:"`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret validation on update:
|
||||||
|
// - only validate if rotating secret OR changing kind
|
||||||
|
// - if rotating secret but kind is NOT provided, skip kind-specific checks (backend can validate)
|
||||||
|
const rotatingSecret = typeof val.secret !== "undefined"
|
||||||
|
const changingKind = typeof val.kind !== "undefined"
|
||||||
|
if (!rotatingSecret && !changingKind) return
|
||||||
|
if (!val.kind) return
|
||||||
|
|
||||||
|
if (val.kind === "aws_access_key") {
|
||||||
|
const sk = val.secret ?? {}
|
||||||
|
const id = sk.access_key_id
|
||||||
|
if (typeof id !== "string" || !/^[A-Z0-9]{20}$/.test(id)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["secret"],
|
||||||
|
message: `access_key_id must be 20 chars (A-Z0-9)`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (typeof sk.secret_access_key !== "string" || sk.secret_access_key.length < 10) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["secret"],
|
||||||
|
message: `secret_access_key is required`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val.kind === "api_token") {
|
||||||
|
const token = (val.secret ?? {}).token
|
||||||
|
if (!token) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["secret"],
|
||||||
|
message: `token is required`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val.kind === "basic_auth") {
|
||||||
|
const s = val.secret ?? {}
|
||||||
|
if (!s.username || !s.password) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["secret"],
|
||||||
|
message: `username and password are required`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val.kind === "oauth2") {
|
||||||
|
const s = val.secret ?? {}
|
||||||
|
if (!s.client_id || !s.client_secret || !s.refresh_token) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["secret"],
|
||||||
|
message: `client_id, client_secret, and refresh_token are required`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
type UpdateCredentialValues = z.input<typeof updateCredentialSchema>
|
||||||
|
|
||||||
// -------------------- Helpers --------------------
|
// -------------------- Helpers --------------------
|
||||||
|
|
||||||
function pretty(obj: unknown) {
|
function pretty(obj: unknown) {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(obj as string), null, 2)
|
if (obj == null) return ""
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(obj), null, 2)
|
||||||
|
} catch {
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(obj, null, 2)
|
||||||
} catch {
|
} catch {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractErr(e: any): string {
|
function extractErr(e: any): string {
|
||||||
const raw = (e as any)?.body ?? (e as any)?.response ?? (e as any)?.message
|
const raw = (e as any)?.body ?? (e as any)?.response ?? (e as any)?.message
|
||||||
if (typeof raw === "string") return raw
|
if (typeof raw === "string") return raw
|
||||||
@@ -252,9 +384,9 @@ function buildCreateBody(v: CreateCredentialValues) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build exact PATCH body (only provided fields)
|
// Build exact PATCH body (only provided fields)
|
||||||
function buildUpdateBody(v: z.infer<typeof updateCredentialSchema>) {
|
function buildUpdateBody(v: UpdateCredentialValues) {
|
||||||
const body: any = {}
|
const body: any = {}
|
||||||
const keys: (keyof typeof v)[] = [
|
const keys: (keyof UpdateCredentialValues)[] = [
|
||||||
"name",
|
"name",
|
||||||
"account_id",
|
"account_id",
|
||||||
"region",
|
"region",
|
||||||
@@ -316,7 +448,7 @@ export const CredentialPage = () => {
|
|||||||
|
|
||||||
// Update
|
// Update
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (payload: { id: string; body: z.infer<typeof updateCredentialSchema> }) =>
|
mutationFn: (payload: { id: string; body: UpdateCredentialValues }) =>
|
||||||
credentialsApi.updateCredential(payload.id, buildUpdateBody(payload.body)),
|
credentialsApi.updateCredential(payload.id, buildUpdateBody(payload.body)),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await qc.invalidateQueries({ queryKey: ["credentials"] })
|
await qc.invalidateQueries({ queryKey: ["credentials"] })
|
||||||
@@ -362,7 +494,7 @@ export const CredentialPage = () => {
|
|||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
})
|
})
|
||||||
|
|
||||||
const editForm = useForm<z.input<typeof updateCredentialSchema>>({
|
const editForm = useForm<UpdateCredentialValues>({
|
||||||
resolver: zodResolver(updateCredentialSchema),
|
resolver: zodResolver(updateCredentialSchema),
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
@@ -371,7 +503,8 @@ export const CredentialPage = () => {
|
|||||||
function openEdit(row: any) {
|
function openEdit(row: any) {
|
||||||
setEditingId(row.id)
|
setEditingId(row.id)
|
||||||
editForm.reset({
|
editForm.reset({
|
||||||
provider: row.provider,
|
// FIX: correct key (was "provider" in your original)
|
||||||
|
credential_provider: row.credential_provider,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
schema_version: row.schema_version ?? 1,
|
schema_version: row.schema_version ?? 1,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -380,7 +513,7 @@ export const CredentialPage = () => {
|
|||||||
account_id: row.account_id ?? "",
|
account_id: row.account_id ?? "",
|
||||||
region: row.region ?? "",
|
region: row.region ?? "",
|
||||||
scope: row.scope ?? (row.scope_kind === "provider" ? {} : undefined),
|
scope: row.scope ?? (row.scope_kind === "provider" ? {} : undefined),
|
||||||
secret: undefined,
|
secret: undefined, // keep existing unless user rotates
|
||||||
} as any)
|
} as any)
|
||||||
setUseRawEditSecretJSON(false)
|
setUseRawEditSecretJSON(false)
|
||||||
setEditOpen(true)
|
setEditOpen(true)
|
||||||
@@ -394,7 +527,7 @@ export const CredentialPage = () => {
|
|||||||
return items.filter((c: any) =>
|
return items.filter((c: any) =>
|
||||||
[
|
[
|
||||||
c.name,
|
c.name,
|
||||||
c.provider,
|
c.credential_provider,
|
||||||
c.kind,
|
c.kind,
|
||||||
c.scope_kind,
|
c.scope_kind,
|
||||||
c.account_id,
|
c.account_id,
|
||||||
@@ -436,6 +569,7 @@ export const CredentialPage = () => {
|
|||||||
|
|
||||||
function ensureCreateDefaultsForSecret() {
|
function ensureCreateDefaultsForSecret() {
|
||||||
if (useRawSecretJSON) return
|
if (useRawSecretJSON) return
|
||||||
|
|
||||||
if (credential_provider === "aws" && kind === "aws_access_key") {
|
if (credential_provider === "aws" && kind === "aws_access_key") {
|
||||||
const s = createForm.getValues("secret") ?? {}
|
const s = createForm.getValues("secret") ?? {}
|
||||||
setCreateSecret({
|
setCreateSecret({
|
||||||
@@ -459,7 +593,7 @@ export const CredentialPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onChangeCreateScopeKind(next: "provider" | "service" | "resource") {
|
function onChangeCreateScopeKind(next: "provider" | "service" | "resource") {
|
||||||
createForm.setValue("scope_kind", next)
|
createForm.setValue("scope_kind", next, { shouldDirty: true, shouldValidate: true })
|
||||||
if (next === "provider") setCreateScope({})
|
if (next === "provider") setCreateScope({})
|
||||||
if (next === "service") setCreateScope({ service: "route53" as AwsSvc })
|
if (next === "service") setCreateScope({ service: "route53" as AwsSvc })
|
||||||
if (next === "resource") setCreateScope({ arn: "" })
|
if (next === "resource") setCreateScope({ arn: "" })
|
||||||
@@ -905,6 +1039,7 @@ export const CredentialPage = () => {
|
|||||||
client_secret: e.target.value,
|
client_secret: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
placeholder="••••••••••"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -933,7 +1068,6 @@ export const CredentialPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
{/* Preview Create button */}
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -1260,9 +1394,7 @@ export const CredentialPage = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Rotate Secret (JSON)</FormLabel>
|
<FormLabel>Rotate Secret (JSON)</FormLabel>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={
|
value={typeof field.value === "string" ? field.value : pretty(field.value)}
|
||||||
typeof field.value === "string" ? field.value : pretty(field.value ?? {})
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
try {
|
try {
|
||||||
field.onChange(JSON.parse(e.target.value))
|
field.onChange(JSON.parse(e.target.value))
|
||||||
@@ -1281,7 +1413,6 @@ export const CredentialPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
{/* Preview Update button */}
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ const credLabel = (c: DtoCredentialOut) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- zod schemas ----------
|
// ---------- zod schemas ----------
|
||||||
|
// IMPORTANT (Zod v4):
|
||||||
|
// - `.partial()` cannot be used on object schemas containing refinements/effects.
|
||||||
|
// Your schemas contain effects via `.transform(...)` and refinements via `.superRefine(...)` / `.refine(...)`,
|
||||||
|
// so we define UPDATE schemas explicitly instead of using `.partial()`.
|
||||||
|
|
||||||
const createDomainSchema = z.object({
|
const createDomainSchema = z.object({
|
||||||
domain_name: z
|
domain_name: z
|
||||||
@@ -144,8 +148,22 @@ const createDomainSchema = z.object({
|
|||||||
})
|
})
|
||||||
type CreateDomainValues = z.input<typeof createDomainSchema>
|
type CreateDomainValues = z.input<typeof createDomainSchema>
|
||||||
|
|
||||||
const updateDomainSchema = createDomainSchema.partial()
|
// Update: all optional; replicate the normalization safely
|
||||||
type UpdateDomainValues = z.infer<typeof updateDomainSchema>
|
const updateDomainSchema = z.object({
|
||||||
|
domain_name: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Domain is required")
|
||||||
|
.max(253)
|
||||||
|
.transform((s) => s.trim().replace(/\.$/, "").toLowerCase())
|
||||||
|
.optional(),
|
||||||
|
credential_id: z.string().uuid("Pick a credential").optional(),
|
||||||
|
zone_id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.or(z.literal(""))
|
||||||
|
.transform((v) => (v ? v.trim() : undefined)),
|
||||||
|
})
|
||||||
|
type UpdateDomainValues = z.input<typeof updateDomainSchema>
|
||||||
|
|
||||||
const ttlSchema = z
|
const ttlSchema = z
|
||||||
.union([
|
.union([
|
||||||
@@ -182,7 +200,42 @@ const createRecordSchema = z
|
|||||||
})
|
})
|
||||||
type CreateRecordValues = z.input<typeof createRecordSchema>
|
type CreateRecordValues = z.input<typeof createRecordSchema>
|
||||||
|
|
||||||
const updateRecordSchema = createRecordSchema.partial()
|
// Update: all optional. Only enforce "values required"/"CNAME exactly one" if valuesCsv is present.
|
||||||
|
// Only validate ttl if present (ttlSchema already optional).
|
||||||
|
const updateRecordSchema = z
|
||||||
|
.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Name required")
|
||||||
|
.max(253)
|
||||||
|
.transform((s) => s.trim().replace(/\.$/, "").toLowerCase())
|
||||||
|
.optional(),
|
||||||
|
type: z.enum(rrtypes as [string, ...string[]]).optional(),
|
||||||
|
ttl: ttlSchema,
|
||||||
|
valuesCsv: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((vals, ctx) => {
|
||||||
|
const hasValues = typeof vals.valuesCsv !== "undefined"
|
||||||
|
if (!hasValues) return
|
||||||
|
|
||||||
|
const arr = parseCommaList(vals.valuesCsv ?? "")
|
||||||
|
if (arr.length === 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["valuesCsv"],
|
||||||
|
message: "At least one value is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can only enforce CNAME rule if `type` is provided in patch (or you can enforce always if you want).
|
||||||
|
if (vals.type === "CNAME" && arr.length !== 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["valuesCsv"],
|
||||||
|
message: "CNAME requires exactly one value",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
type UpdateRecordValues = z.input<typeof updateRecordSchema>
|
type UpdateRecordValues = z.input<typeof updateRecordSchema>
|
||||||
|
|
||||||
// ---------- main ----------
|
// ---------- main ----------
|
||||||
@@ -224,12 +277,9 @@ export const DnsPage = () => {
|
|||||||
const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data])
|
const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setSelectedDns = () => {
|
if (!selected && domainsQ.data && domainsQ.data.length) {
|
||||||
if (!selected && domainsQ.data && domainsQ.data.length) {
|
setSelected(domainsQ.data[0]!)
|
||||||
setSelected(domainsQ.data[0]!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setSelectedDns()
|
|
||||||
}, [domainsQ.data, selected])
|
}, [domainsQ.data, selected])
|
||||||
|
|
||||||
const filteredDomains = useMemo(() => {
|
const filteredDomains = useMemo(() => {
|
||||||
@@ -237,7 +287,7 @@ export const DnsPage = () => {
|
|||||||
if (!filter.trim()) return list
|
if (!filter.trim()) return list
|
||||||
const f = filter.toLowerCase()
|
const f = filter.toLowerCase()
|
||||||
return list.filter((d) =>
|
return list.filter((d) =>
|
||||||
[d.domain_name, d.zone_id, d.status, d.domain_name]
|
[d.domain_name, d.zone_id, d.status]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((x) => String(x).toLowerCase())
|
.map((x) => String(x).toLowerCase())
|
||||||
.some((s) => s.includes(f))
|
.some((s) => s.includes(f))
|
||||||
@@ -271,6 +321,7 @@ export const DnsPage = () => {
|
|||||||
|
|
||||||
const editDomainForm = useForm<UpdateDomainValues>({
|
const editDomainForm = useForm<UpdateDomainValues>({
|
||||||
resolver: zodResolver(updateDomainSchema),
|
resolver: zodResolver(updateDomainSchema),
|
||||||
|
defaultValues: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const openEditDomain = (d: DtoDomainResponse) => {
|
const openEditDomain = (d: DtoDomainResponse) => {
|
||||||
@@ -283,10 +334,20 @@ export const DnsPage = () => {
|
|||||||
setEditDomOpen(true)
|
setEditDomOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build PATCH body (don’t send empty strings)
|
||||||
|
const buildUpdateDomainBody = (vals: UpdateDomainValues): DtoUpdateDomainRequest => {
|
||||||
|
const body: any = {}
|
||||||
|
if (typeof vals.domain_name !== "undefined") body.domain_name = vals.domain_name
|
||||||
|
if (typeof vals.credential_id !== "undefined" && vals.credential_id !== "")
|
||||||
|
body.credential_id = vals.credential_id
|
||||||
|
if (typeof vals.zone_id !== "undefined" && vals.zone_id !== "") body.zone_id = vals.zone_id
|
||||||
|
return body as DtoUpdateDomainRequest
|
||||||
|
}
|
||||||
|
|
||||||
const updateDomainMut = useMutation({
|
const updateDomainMut = useMutation({
|
||||||
mutationFn: (vals: UpdateDomainValues) => {
|
mutationFn: (vals: UpdateDomainValues) => {
|
||||||
if (!selected) throw new Error("No domain selected")
|
if (!selected) throw new Error("No domain selected")
|
||||||
return dnsApi.updateDomain(selected.id!, vals as unknown as DtoUpdateDomainRequest)
|
return dnsApi.updateDomain(selected.id!, buildUpdateDomainBody(vals))
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success("Domain updated")
|
toast.success("Domain updated")
|
||||||
@@ -338,7 +399,6 @@ export const DnsPage = () => {
|
|||||||
const body: DtoCreateRecordSetRequest = {
|
const body: DtoCreateRecordSetRequest = {
|
||||||
name: vals.name,
|
name: vals.name,
|
||||||
type: vals.type,
|
type: vals.type,
|
||||||
// omit ttl when empty/undefined
|
|
||||||
...(vals.ttl ? { ttl: vals.ttl as unknown as number } : {}),
|
...(vals.ttl ? { ttl: vals.ttl as unknown as number } : {}),
|
||||||
values: parseCommaList(vals.valuesCsv ?? ""),
|
values: parseCommaList(vals.valuesCsv ?? ""),
|
||||||
}
|
}
|
||||||
@@ -356,6 +416,7 @@ export const DnsPage = () => {
|
|||||||
|
|
||||||
const editRecForm = useForm<UpdateRecordValues>({
|
const editRecForm = useForm<UpdateRecordValues>({
|
||||||
resolver: zodResolver(updateRecordSchema),
|
resolver: zodResolver(updateRecordSchema),
|
||||||
|
defaultValues: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const openEditRecord = (r: DtoRecordSetResponse) => {
|
const openEditRecord = (r: DtoRecordSetResponse) => {
|
||||||
@@ -374,15 +435,12 @@ export const DnsPage = () => {
|
|||||||
mutationFn: async (vals: UpdateRecordValues) => {
|
mutationFn: async (vals: UpdateRecordValues) => {
|
||||||
if (!editingRecord) throw new Error("No record selected")
|
if (!editingRecord) throw new Error("No record selected")
|
||||||
const body: DtoUpdateRecordSetRequest = {}
|
const body: DtoUpdateRecordSetRequest = {}
|
||||||
if (vals.name !== undefined) body.name = vals.name
|
|
||||||
if (vals.type !== undefined) body.type = vals.type
|
if (typeof vals.name !== "undefined") body.name = vals.name
|
||||||
if (vals.ttl !== undefined && vals.ttl !== null) {
|
if (typeof vals.type !== "undefined") body.type = vals.type
|
||||||
// if blank string came through it would have been filtered; when undefined, omit
|
if (typeof vals.ttl !== "undefined") body.ttl = vals.ttl as unknown as number | undefined
|
||||||
body.ttl = vals.ttl as unknown as number | undefined
|
if (typeof vals.valuesCsv !== "undefined") body.values = parseCommaList(vals.valuesCsv)
|
||||||
}
|
|
||||||
if (vals.valuesCsv !== undefined) {
|
|
||||||
body.values = parseCommaList(vals.valuesCsv)
|
|
||||||
}
|
|
||||||
return dnsApi.updateRecordSetsByDomain(editingRecord.id!, body)
|
return dnsApi.updateRecordSetsByDomain(editingRecord.id!, body)
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -556,7 +614,14 @@ export const DnsPage = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button size="icon" variant="ghost" onClick={() => openEditDomain(d)}>
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
openEditDomain(d)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
@@ -650,10 +715,7 @@ export const DnsPage = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Type</FormLabel>
|
<FormLabel>Type</FormLabel>
|
||||||
<Select
|
<Select onValueChange={field.onChange} value={field.value as string}>
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value as string}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -969,7 +1031,7 @@ export const DnsPage = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Type</FormLabel>
|
<FormLabel>Type</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value as string}>
|
<Select onValueChange={field.onChange} value={field.value as string}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { type FC, useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState, type FC } from "react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
|
||||||
import "rapidoc"
|
import "rapidoc"
|
||||||
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select.tsx"
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select.tsx"
|
||||||
|
|
||||||
type RdThemeMode = "auto" | "light" | "dark"
|
type RdThemeMode = "auto" | "light" | "dark"
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ export const MePage = () => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead>Id</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Domain</TableHead>
|
<TableHead>Domain</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -312,6 +313,7 @@ export const MePage = () => {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{meQ.data?.organizations?.map((o) => (
|
{meQ.data?.organizations?.map((o) => (
|
||||||
<TableRow key={o.id}>
|
<TableRow key={o.id}>
|
||||||
|
<TableCell>{o.id}</TableCell>
|
||||||
<TableCell>{o.name}</TableCell>
|
<TableCell>{o.name}</TableCell>
|
||||||
<TableCell>{(o as any).domain ?? "—"}</TableCell>
|
<TableCell>{(o as any).domain ?? "—"}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useForm } from "react-hook-form"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge.tsx"
|
||||||
import { Button } from "@/components/ui/button.tsx"
|
import { Button } from "@/components/ui/button.tsx"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||||
import {
|
import {
|
||||||
@@ -35,10 +36,12 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table.tsx"
|
} from "@/components/ui/table.tsx"
|
||||||
|
|
||||||
|
// 1) No coerce; we’ll do the conversion in onChange
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
expires_in_hours: z.number().min(1).max(43800),
|
expires_in_hours: z.number().int().min(1).max(43800),
|
||||||
})
|
})
|
||||||
|
|
||||||
type CreateValues = z.infer<typeof createSchema>
|
type CreateValues = z.infer<typeof createSchema>
|
||||||
|
|
||||||
export const OrgApiKeys = () => {
|
export const OrgApiKeys = () => {
|
||||||
@@ -52,6 +55,7 @@ export const OrgApiKeys = () => {
|
|||||||
queryFn: () => withRefresh(() => api.listOrgKeys({ id: orgId! })),
|
queryFn: () => withRefresh(() => api.listOrgKeys({ id: orgId! })),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 2) Form holds numbers directly
|
||||||
const form = useForm<CreateValues>({
|
const form = useForm<CreateValues>({
|
||||||
resolver: zodResolver(createSchema),
|
resolver: zodResolver(createSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -71,7 +75,7 @@ export const OrgApiKeys = () => {
|
|||||||
void qc.invalidateQueries({ queryKey: ["org:keys", orgId] })
|
void qc.invalidateQueries({ queryKey: ["org:keys", orgId] })
|
||||||
setShowSecret({ key: resp.org_key, secret: resp.org_secret })
|
setShowSecret({ key: resp.org_key, secret: resp.org_secret })
|
||||||
toast.success("Key created")
|
toast.success("Key created")
|
||||||
form.reset({ name: "", expires_in_hours: undefined })
|
form.reset({ name: "", expires_in_hours: 720 })
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error(e?.message ?? "Failed to create key"),
|
onError: (e: any) => toast.error(e?.message ?? "Failed to create key"),
|
||||||
})
|
})
|
||||||
@@ -124,7 +128,17 @@ export const OrgApiKeys = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Expires In (hours)</FormLabel>
|
<FormLabel>Expires In (hours)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="e.g. 720" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 720"
|
||||||
|
{...field}
|
||||||
|
// 3) Convert string → number (or undefined if empty)
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
field.onChange(v === "" ? undefined : Number(v))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -148,6 +162,7 @@ export const OrgApiKeys = () => {
|
|||||||
<TableHead>Scope</TableHead>
|
<TableHead>Scope</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>Created</TableHead>
|
||||||
<TableHead>Expires</TableHead>
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="w-28" />
|
<TableHead className="w-28" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -160,6 +175,33 @@ export const OrgApiKeys = () => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{k.expires_at ? new Date(k.expires_at).toLocaleString() : "-"}
|
{k.expires_at ? new Date(k.expires_at).toLocaleString() : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{(() => {
|
||||||
|
const isExpired = k.expires_at ? new Date(k.expires_at) <= new Date() : false
|
||||||
|
|
||||||
|
if (k.revoked) {
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive" className="font-mono">
|
||||||
|
Revoked
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
Expired
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="font-mono">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button variant="destructive" size="sm" onClick={() => deleteMut.mutate(k.id!)}>
|
<Button variant="destructive" size="sm" onClick={() => deleteMut.mutate(k.id!)}>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@@ -53,13 +53,17 @@ type Role = (typeof ROLE_OPTIONS)[number]
|
|||||||
const STATUS = ["pending", "provisioning", "ready", "failed"] as const
|
const STATUS = ["pending", "provisioning", "ready", "failed"] as const
|
||||||
type Status = (typeof STATUS)[number]
|
type Status = (typeof STATUS)[number]
|
||||||
|
|
||||||
|
// ---------- Zod schemas ----------
|
||||||
|
// Zod v4: `.partial()` cannot be used on schemas with refinements/effects.
|
||||||
|
// createServerSchema has a refinement, so define updateServerSchema explicitly.
|
||||||
|
|
||||||
const createServerSchema = z
|
const createServerSchema = z
|
||||||
.object({
|
.object({
|
||||||
hostname: z.string().trim().max(60, "Max 60 chars"),
|
hostname: z.string().trim().max(60, "Max 60 chars"),
|
||||||
public_ip_address: z.string().trim().optional().or(z.literal("")),
|
public_ip_address: z.string().trim().optional().or(z.literal("")),
|
||||||
private_ip_address: z.string().trim().min(1, "Private IP address required"),
|
private_ip_address: z.string().trim().min(1, "Private IP address required"),
|
||||||
role: z.enum(ROLE_OPTIONS),
|
role: z.enum(ROLE_OPTIONS),
|
||||||
ssh_key_id: z.uuid("Pick a valid SSH key"),
|
ssh_key_id: z.string().uuid("Pick a valid SSH key"),
|
||||||
ssh_user: z.string().trim().min(1, "SSH user is required"),
|
ssh_user: z.string().trim().min(1, "SSH user is required"),
|
||||||
status: z.enum(STATUS).default("pending"),
|
status: z.enum(STATUS).default("pending"),
|
||||||
})
|
})
|
||||||
@@ -69,8 +73,33 @@ const createServerSchema = z
|
|||||||
)
|
)
|
||||||
type CreateServerInput = z.input<typeof createServerSchema>
|
type CreateServerInput = z.input<typeof createServerSchema>
|
||||||
|
|
||||||
const updateServerSchema = createServerSchema.partial()
|
// Patch-friendly update schema:
|
||||||
type UpdateServerValues = z.infer<typeof updateServerSchema>
|
// - all fields optional
|
||||||
|
// - only enforce "public ip required" if role is being set to bastion in the patch
|
||||||
|
const updateServerSchema = z
|
||||||
|
.object({
|
||||||
|
hostname: z.string().trim().max(60, "Max 60 chars").optional(),
|
||||||
|
public_ip_address: z.string().trim().optional().or(z.literal("")),
|
||||||
|
private_ip_address: z.string().trim().min(1, "Private IP address required").optional(),
|
||||||
|
role: z.enum(ROLE_OPTIONS).optional(),
|
||||||
|
ssh_key_id: z.string().uuid("Pick a valid SSH key").optional(),
|
||||||
|
ssh_user: z.string().trim().min(1, "SSH user is required").optional(),
|
||||||
|
status: z.enum(STATUS).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
// If updating role to bastion, require public_ip_address in the patch
|
||||||
|
if (v.role === "bastion") {
|
||||||
|
const pub = typeof v.public_ip_address === "string" ? v.public_ip_address.trim() : ""
|
||||||
|
if (!pub) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["public_ip_address"],
|
||||||
|
message: "Public IP required for bastion",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
type UpdateServerValues = z.input<typeof updateServerSchema>
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: Status }) {
|
function StatusBadge({ status }: { status: Status }) {
|
||||||
const v =
|
const v =
|
||||||
@@ -88,6 +117,27 @@ function StatusBadge({ status }: { status: Status }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build PATCH body: omit undefined and empty strings (so optional inputs can be cleared in UI without sending "")
|
||||||
|
function buildUpdateBody(values: UpdateServerValues) {
|
||||||
|
const body: any = {}
|
||||||
|
const keys: (keyof UpdateServerValues)[] = [
|
||||||
|
"hostname",
|
||||||
|
"public_ip_address",
|
||||||
|
"private_ip_address",
|
||||||
|
"role",
|
||||||
|
"ssh_key_id",
|
||||||
|
"ssh_user",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = values[k]
|
||||||
|
if (typeof v === "undefined") continue
|
||||||
|
if (v === "") continue
|
||||||
|
body[k] = v
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
export const ServerPage = () => {
|
export const ServerPage = () => {
|
||||||
const [filter, setFilter] = useState<string>("")
|
const [filter, setFilter] = useState<string>("")
|
||||||
const [createOpen, setCreateOpen] = useState<boolean>(false)
|
const [createOpen, setCreateOpen] = useState<boolean>(false)
|
||||||
@@ -180,13 +230,12 @@ export const ServerPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const roleIsBastionU = watchedRoleUpdate === "bastion"
|
const roleIsBastionU = watchedRoleUpdate === "bastion"
|
||||||
|
|
||||||
const pubUpdate = watchedPublicIpAddressUpdate?.trim() ?? ""
|
const pubUpdate = watchedPublicIpAddressUpdate?.trim() ?? ""
|
||||||
const needPubUpdate = roleIsBastionU && pubUpdate === ""
|
const needPubUpdate = roleIsBastionU && pubUpdate === ""
|
||||||
|
|
||||||
const updateMut = useMutation({
|
const updateMut = useMutation({
|
||||||
mutationFn: ({ id, values }: { id: string; values: UpdateServerValues }) =>
|
mutationFn: ({ id, values }: { id: string; values: UpdateServerValues }) =>
|
||||||
serversApi.updateServer(id, values as any),
|
serversApi.updateServer(id, buildUpdateBody(values) as any),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await qc.invalidateQueries({ queryKey: ["servers"] })
|
await qc.invalidateQueries({ queryKey: ["servers"] })
|
||||||
setUpdateOpen(false)
|
setUpdateOpen(false)
|
||||||
@@ -279,7 +328,7 @@ export const ServerPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={roleFilter || "all"} // map "" -> "all" for the UI
|
value={roleFilter || "all"}
|
||||||
onValueChange={(v) => setRoleFilter(v === "all" ? "" : (v as Role))}
|
onValueChange={(v) => setRoleFilter(v === "all" ? "" : (v as Role))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-36">
|
<SelectTrigger className="w-36">
|
||||||
@@ -296,14 +345,14 @@ export const ServerPage = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter || "all"} // map "" -> "all" for the UI
|
value={statusFilter || "all"}
|
||||||
onValueChange={(v) => setStatusFilter(v === "all" ? "" : (v as Status))}
|
onValueChange={(v) => setStatusFilter(v === "all" ? "" : (v as Status))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="w-40">
|
||||||
<SelectValue placeholder="Status (all)" />
|
<SelectValue placeholder="Status (all)" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All statuses</SelectItem> {/* sentinel */}
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
{STATUS.map((s) => (
|
{STATUS.map((s) => (
|
||||||
<SelectItem key={s} value={s}>
|
<SelectItem key={s} value={s}>
|
||||||
{s}
|
{s}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { orgStore } from "@/auth/org.ts"
|
import { orgStore } from "@/auth/org.ts"
|
||||||
import { authStore } from "@/auth/store.ts"
|
import { authStore } from "@/auth/store.ts"
|
||||||
import {
|
import {
|
||||||
|
ActionsApi,
|
||||||
AnnotationsApi,
|
AnnotationsApi,
|
||||||
ArcherAdminApi,
|
ArcherAdminApi,
|
||||||
AuthApi,
|
AuthApi,
|
||||||
|
ClusterRunsApi,
|
||||||
ClustersApi,
|
ClustersApi,
|
||||||
Configuration,
|
Configuration,
|
||||||
CredentialsApi,
|
CredentialsApi,
|
||||||
@@ -133,3 +135,11 @@ export function makeLoadBalancerApi() {
|
|||||||
export function makeClusterApi() {
|
export function makeClusterApi() {
|
||||||
return makeApiClient(ClustersApi)
|
return makeApiClient(ClustersApi)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function makeActionsApi() {
|
||||||
|
return makeApiClient(ActionsApi)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeClusterRunsApi() {
|
||||||
|
return makeApiClient(ClusterRunsApi)
|
||||||
|
}
|
||||||
2363
ui/yarn.lock
2363
ui/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user