Compare commits

...

81 Commits

Author SHA1 Message Date
Alanis
bdd6b61859 Remove rate limiting by IP from routes
Removed rate limiting middleware from the API routes.
2026-01-06 09:50:10 +00:00
public-glueops-renovatebot[bot]
42a86b22dd chore: lock file maintenance (#567)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-05 10:45:19 +00:00
public-glueops-renovatebot[bot]
b8cb1e1a2a chore: lock file maintenance (#566)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-05 10:11:13 +00:00
public-glueops-renovatebot[bot]
5a4ae19900 chore: lock file maintenance (#564)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-04 10:51:50 +00:00
public-glueops-renovatebot[bot]
d9db293894 chore: lock file maintenance (#563)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-04 08:27:53 +00:00
public-glueops-renovatebot[bot]
19d5ee7251 chore: lock file maintenance (#562)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-04 04:12:39 +00:00
public-glueops-renovatebot[bot]
6b91a5760b chore: lock file maintenance (#560)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-03 13:32:05 +00:00
public-glueops-renovatebot[bot]
bbd4c86013 chore: lock file maintenance (#559)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-02 14:40:46 +00:00
public-glueops-renovatebot[bot]
99ebcb11a3 chore: lock file maintenance (#558)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-02 13:44:37 +00:00
public-glueops-renovatebot[bot]
be1b35da3c feat: update typescript-eslint to 8.51.0 #minor (#540)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2026-01-01 17:09:21 +00:00
public-glueops-renovatebot[bot]
a6a296f283 chore: lock file maintenance (#556)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-31 23:27:07 +00:00
public-glueops-renovatebot[bot]
341ecf8b0a chore: lock file maintenance (#555)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-31 19:37:14 +00:00
public-glueops-renovatebot[bot]
92998015ec chore: lock file maintenance (#554)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-31 16:10:12 +00:00
public-glueops-renovatebot[bot]
9345c2761c chore: lock file maintenance (#553)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-31 15:08:55 +00:00
public-glueops-renovatebot[bot]
6944e5d027 chore: lock file maintenance (#552)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-31 06:14:14 +00:00
public-glueops-renovatebot[bot]
48b3bf5d3c chore: lock file maintenance (#551)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-31 06:13:11 +00:00
public-glueops-renovatebot[bot]
4c595db85e chore: lock file maintenance (#550)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-31 05:42:43 +00:00
allanice001
79f3259bd6 fix: ensure only a single instance is fired
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-30 17:30:38 +00:00
allanice001
e0d163181a Merge branch 'main' of github.com:GlueOps/autoglue 2025-12-30 16:35:18 +00:00
allanice001
e8d568eba7 fix: ensure only a single instance is fired
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-30 16:35:00 +00:00
public-glueops-renovatebot[bot]
c21a766dab chore: lock file maintenance (#548)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-30 15:09:36 +00:00
public-glueops-renovatebot[bot]
495c1551b4 chore: lock file maintenance (#547)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-30 08:45:47 +00:00
public-glueops-renovatebot[bot]
4a5c0df481 chore: lock file maintenance (#545)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 22:09:22 +00:00
public-glueops-renovatebot[bot]
c92bba5518 chore: lock file maintenance (#544)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 22:08:16 +00:00
public-glueops-renovatebot[bot]
823088e294 chore: lock file maintenance (#543)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 19:52:02 +00:00
public-glueops-renovatebot[bot]
938689fda3 chore: lock file maintenance (#542)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 18:46:56 +00:00
public-glueops-renovatebot[bot]
77332f208f chore: lock file maintenance (#541)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 17:26:24 +00:00
public-glueops-renovatebot[bot]
711488492c breaking: the dependency actions/checkout has been updated to a new major version (v6), which may include breaking changes. #major (#536)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 17:03:09 +00:00
public-glueops-renovatebot[bot]
27b89722a4 chore: lock file maintenance (#539)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 16:27:51 +00:00
public-glueops-renovatebot[bot]
c8a537e30f chore(fallback): update postgres (#535)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 12:34:44 +00:00
public-glueops-renovatebot[bot]
98de70b96b chore: lock file maintenance (#538)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 12:34:10 +00:00
public-glueops-renovatebot[bot]
63c4574f9c chore(fallback): update axllent/mailpit (#534)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 12:33:13 +00:00
public-glueops-renovatebot[bot]
5e85cad5b7 chore(pin): update typescript to #patch (#533)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-29 12:32:35 +00:00
allanice001
53725bb834 chore: add org id to org table 2025-12-29 11:59:27 +00:00
public-glueops-renovatebot[bot]
6611dc4950 chore(patch): update typescript-eslint to 8.50.1 #patch (#511)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-28 01:26:48 +00:00
public-glueops-renovatebot[bot]
49665ffc9c feat: update github.com/aws/aws-sdk-go-v2/service/s3 to v1.95.0 #minor (#516)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-28 01:26:33 +00:00
public-glueops-renovatebot[bot]
ac14ef8fff chore: lock file maintenance (#528)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-28 01:25:13 +00:00
allanice001
f8e543b595 fix: improve dns error logging 2025-12-28 00:08:42 +00:00
allanice001
8cc81e52b7 fix: add get record for dns 2025-12-27 23:21:59 +00:00
public-glueops-renovatebot[bot]
d6e28c7fa2 chore(patch): update @types/node to 25.0.3 #patch (#507)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 15:30:04 +00:00
public-glueops-renovatebot[bot]
9832229194 chore(patch): update eslint-plugin-react-refresh to 0.4.26 #patch (#508)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 15:29:07 +00:00
public-glueops-renovatebot[bot]
da82998754 chore(fallback): update alpine (#487)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 15:28:34 +00:00
public-glueops-renovatebot[bot]
bca32fe784 chore(fallback): update golang (#486)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 15:28:18 +00:00
public-glueops-renovatebot[bot]
848e8d5179 chore(patch): update shadcn to 3.6.2 #patch (#510)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 15:28:03 +00:00
public-glueops-renovatebot[bot]
d3ee38881c feat: update docker/setup-buildx-action to v3.12.0 #minor (#515)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 15:27:20 +00:00
public-glueops-renovatebot[bot]
d39db44aa7 feat: update lucide-react to 0.562.0 #minor (#518)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 15:26:54 +00:00
public-glueops-renovatebot[bot]
01b29a4706 feat: update vite to 7.3.0 #minor (#519)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 15:26:38 +00:00
allanice001
bc3bd92d54 fix: improve job tracking
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-26 15:14:48 +00:00
allanice001
2057f92b82 fix: improve job tracking 2025-12-26 15:04:31 +00:00
allanice001
169283b6c7 fix: improve job tracking
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-26 15:04:15 +00:00
allanice001
865270312c fix: update jobs
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-26 04:47:08 +00:00
public-glueops-renovatebot[bot]
7cc447c0f5 chore(lockfile): update react-hook-form-lockfile #patch (#496)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 01:12:10 +00:00
public-glueops-renovatebot[bot]
8a0345f7f5 chore: lock file maintenance (#520)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 01:11:47 +00:00
public-glueops-renovatebot[bot]
bb7114efe9 feat: update github.com/go-playground/validator/v10 to v10.30.1 #minor (#517)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 00:45:14 +00:00
public-glueops-renovatebot[bot]
9dd0148764 chore(lockfile): update react-router-dom-lockfile #patch (#514)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 00:45:00 +00:00
public-glueops-renovatebot[bot]
bcc69e1c86 chore(lockfile): update react-day-picker-lockfile #patch (#513)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 00:44:27 +00:00
public-glueops-renovatebot[bot]
a7bf6b43b4 chore(patch): update zod to 4.2.1 #patch (#512)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 00:44:17 +00:00
public-glueops-renovatebot[bot]
ced0a0663f chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.32.6 #patch (#509)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-26 00:43:51 +00:00
allanice001
dac28d3ea5 feat: move jobs to action based
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-26 00:30:46 +00:00
allanice001
dd0cefc08a fix: bugfix jobs
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-16 01:15:00 +00:00
allanice001
842f7c9be6 fix: bugfix jobs
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-16 00:52:16 +00:00
public-glueops-renovatebot[bot]
c15311a5a1 chore(patch): update @eslint/js to 9.39.2 #patch (#456)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-15 22:26:54 +00:00
public-glueops-renovatebot[bot]
25ced343c4 feat: update shadcn to 3.6.0 #minor (#454)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-15 20:23:28 +00:00
public-glueops-renovatebot[bot]
b72a8d384d feat: update github.com/aws/aws-sdk-go-v2/service/s3 to v1.94.0 #minor (#470)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-15 20:22:48 +00:00
public-glueops-renovatebot[bot]
c786a79b60 chore: lock file maintenance (#469)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-15 19:39:22 +00:00
public-glueops-renovatebot[bot]
01b1434842 chore: lock file maintenance (#468)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-15 19:38:13 +00:00
public-glueops-renovatebot[bot]
e8c9cde474 chore: lock file maintenance (#467)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-15 19:09:53 +00:00
public-glueops-renovatebot[bot]
ae92d05cd4 feat: update typescript-eslint to 8.50.0 #minor (#465)
* chore: lock file maintenance (#452)

Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>

* feat: update github.com/go-playground/validator/v10 to v10.29.0 #minor (#453)

Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>

* chore: lock file maintenance (#455)

Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>

* chore: lock file maintenance (#457)

Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>

* chore: lock file maintenance (#462)

Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>

* chore: lock file maintenance (#464)

Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>

* chore: lock file maintenance (#466)

Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>

* feat: update typescript-eslint to 8.50.0 #minor

---------

Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-15 18:08:04 +00:00
allanice001
67d50d2b15 fix: bugfix in responding with correct label ids 2025-12-15 18:04:22 +00:00
allanice001
e5a664b812 Merge remote-tracking branch 'origin/main' 2025-12-12 11:36:33 +00:00
allanice001
f722ba8dca chore: update dependencies
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-12 11:36:25 +00:00
public-glueops-renovatebot[bot]
20e6d8d186 chore: lock file maintenance (#449)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-12 07:40:12 +00:00
allanice001
85f37cd113 fix: ui updates for org api keys
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-12 02:05:31 +00:00
allanice001
fd1a81ecd8 fix: api keys form bugfix and org key sweeper job
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-12 01:37:42 +00:00
allanice001
793daf3ac3 Merge remote-tracking branch 'origin/main' 2025-12-12 00:20:34 +00:00
allanice001
7bef4ef6f1 feat: add org_key and org_secret to payload on bastion
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-12 00:20:27 +00:00
public-glueops-renovatebot[bot]
9fa9cd169b chore: lock file maintenance (#448)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-11 23:26:33 +00:00
public-glueops-renovatebot[bot]
8812b43346 chore: lock file maintenance (#447)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-11 20:39:59 +00:00
public-glueops-renovatebot[bot]
21a6d7d5a1 chore: lock file maintenance (#445)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-11 19:37:53 +00:00
public-glueops-renovatebot[bot]
da332c89dd chore: lock file maintenance (#443)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-11 17:12:09 +00:00
public-glueops-renovatebot[bot]
fd25825f34 chore: lock file maintenance (#441)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-11 15:54:18 +00:00
47 changed files with 3766 additions and 1118 deletions

View File

@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
@@ -47,7 +47,7 @@ jobs:
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- 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
# https://github.com/docker/login-action

View File

@@ -1,7 +1,7 @@
#################################
# Builder: Go + Node in one
#################################
FROM golang:1.25.5-alpine@sha256:26111811bc967321e7b6f852e914d14bede324cd1accb7f81811929a6a57fea9 AS builder
FROM golang:1.25.5-alpine@sha256:ac09a5f469f307e5da71e766b0bd59c9c49ea460a528cc3e6686513d64a6f1fb AS builder
RUN apk add --no-cache \
bash git ca-certificates tzdata \
@@ -24,7 +24,7 @@ RUN make build
#################################
# Runtime
#################################
FROM alpine:3.23@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375
FROM alpine:3.23@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62
RUN apk add --no-cache ca-certificates tzdata postgresql17-client \
&& addgroup -S app && adduser -S app -G app

View File

@@ -116,45 +116,62 @@ var serveCmd = &cobra.Command{
log.Printf("failed to enqueue bootstrap_bastion: %v", err)
}
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"prepare_cluster",
bg.ClusterPrepareArgs{IntervalS: 120},
archer.WithMaxRetries(3),
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
)
if err != nil {
log.Printf("failed to enqueue prepare_cluster: %v", err)
}
/*
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"prepare_cluster",
bg.ClusterPrepareArgs{IntervalS: 120},
archer.WithMaxRetries(3),
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
)
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(
context.Background(),
uuid.NewString(),
"cluster_setup",
bg.ClusterSetupArgs{
IntervalS: 120,
"org_key_sweeper",
bg.OrgKeySweeperArgs{
IntervalS: 3600,
RetentionDays: 10,
},
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)),
archer.WithMaxRetries(1),
archer.WithScheduleTime(time.Now()),
)
if err != nil {
log.Printf("failed to enqueue cluster bootstrap: %v", err)
log.Printf("failed to enqueue org_key_sweeper: %v", err)
}
}

View File

@@ -15,7 +15,7 @@ services:
- postgres_data:/var/lib/postgresql/data
mailpit:
image: axllent/mailpit@sha256:e22dce5b36f93c77082e204a3942fb6b283b7896e057458400a4c88344c3df68
image: axllent/mailpit@sha256:c076638db1e15662150be4fb62b8a6e96ef6ba5bde90c838a0239225854830f7
restart: always
ports:
- "1025:1025"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,23 @@
components:
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:
properties:
created_at:
@@ -104,6 +122,8 @@ components:
$ref: '#/components/schemas/dto.LoadBalancerResponse'
id:
type: string
kubeconfig:
type: string
last_error:
type: string
name:
@@ -113,6 +133,10 @@ components:
$ref: '#/components/schemas/dto.NodePoolResponse'
type: array
uniqueItems: false
org_key:
type: string
org_secret:
type: string
random_token:
type: string
region:
@@ -122,6 +146,42 @@ components:
updated_at:
type: string
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:
properties:
key:
@@ -710,6 +770,15 @@ components:
example: Bearer
type: string
type: object
dto.UpdateActionRequest:
properties:
description:
type: string
label:
type: string
make_target:
type: string
type: object
dto.UpdateAnnotationRequest:
properties:
key:
@@ -1037,6 +1106,8 @@ components:
type: object
models.APIKey:
properties:
cluster_id:
type: string
created_at:
format: date-time
type: string
@@ -1046,6 +1117,8 @@ components:
id:
format: uuid
type: string
is_ephemeral:
type: boolean
last_used_at:
format: date-time
type: string
@@ -1056,6 +1129,8 @@ components:
type: string
prefix:
type: string
purpose:
type: string
revoked:
type: boolean
scope:
@@ -1190,6 +1265,222 @@ paths:
summary: Get JWKS
tags:
- 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:
get:
description: Paginated background jobs with optional filters. Search `q` may
@@ -2112,6 +2403,73 @@ paths:
summary: Update basic cluster details (org scoped)
tags:
- 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:
delete:
description: Clears apps_load_balancer_id on the cluster.
@@ -3005,6 +3363,128 @@ paths:
summary: Detach a node pool from a cluster
tags:
- 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:
get:
description: Returns credential metadata for the current org. Secrets are never
@@ -3684,6 +4164,42 @@ paths:
external deletion policy)
tags:
- 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:
operationId: UpdateRecordSet
parameters:

15
go.mod
View File

@@ -5,10 +5,11 @@ go 1.25.4
require (
github.com/alexedwards/argon2id v1.0.0
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/aws/smithy-go v1.24.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dyaksa/archer v1.1.5
github.com/fergusstrange/embedded-postgres v1.33.0
@@ -16,7 +17,7 @@ require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
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/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
@@ -31,7 +32,6 @@ require (
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
github.com/swaggo/swag/v2 v2.0.0-rc4
)
require (
@@ -50,10 +50,9 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
@@ -62,7 +61,7 @@ require (
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/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/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect

24
go.sum
View File

@@ -14,10 +14,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgP
github.com/aws/aws-sdk-go-v2 v1.41.0/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/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
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.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
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/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
@@ -38,12 +38,12 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lu
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/route53 v1.62.0 h1:80pDB3Tpmb2RCSZORrK9/3iQxsd+w6vSzVqpT1FGiwE=
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/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
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/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/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
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/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/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
@@ -79,8 +79,8 @@ 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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
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.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
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/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
@@ -112,8 +112,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/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/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
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.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=

View File

@@ -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.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))
})
})
}

View File

@@ -33,7 +33,7 @@ func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
mountNodePoolRoutes(v1, db, authOrg)
mountDNSRoutes(v1, db, authOrg)
mountLoadBalancerRoutes(v1, db, authOrg)
mountClusterRoutes(v1, db, authOrg)
mountClusterRoutes(v1, db, jobs, authOrg)
})
})
}

View File

@@ -3,12 +3,13 @@ package api
import (
"net/http"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"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) {
c.Use(authOrg)
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.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))
})
}

View File

@@ -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.Post("/domains/{domain_id}/records", handlers.CreateRecordSet(db))
d.Get("/records/{id}", handlers.GetRecordSet(db))
d.Patch("/records/{id}", handlers.UpdateRecordSet(db))
d.Delete("/records/{id}", handlers.DeleteRecordSet(db))
})

View File

@@ -37,7 +37,6 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
r.Use(middleware.Recoverer)
r.Use(SecurityHeaders)
r.Use(requestBodyLimit(10 << 20))
r.Use(httprate.LimitByIP(100, 1*time.Minute))
r.Use(middleware.StripSlashes)
allowed := getAllowedOrigins()

View File

@@ -44,6 +44,9 @@ func NewRuntime() *Runtime {
&models.RecordSet{},
&models.LoadBalancer{},
&models.Cluster{},
&models.Action{},
&models.Cluster{},
&models.ClusterRun{},
)
if err != nil {

View File

@@ -107,28 +107,42 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
archer.WithInstances(1),
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(
"prepare_cluster",
ClusterPrepareWorker(gdb, jobs),
"org_key_sweeper",
OrgKeySweeperWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
archer.WithTimeout(5*time.Minute),
)
c.Register(
"cluster_setup",
ClusterSetupWorker(gdb, jobs),
"cluster_action",
ClusterActionWorker(gdb),
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
}

View 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
}
}

View File

@@ -40,7 +40,7 @@ func ClusterBootstrapWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
var clusters []models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Where("status = ?", clusterStatusPending).
Where("status = ?", clusterStatusProvisioning).
Find(&clusters).Error; err != nil {
log.Error().Err(err).Msg("[cluster_bootstrap] query clusters failed")
return nil, err

View File

@@ -74,8 +74,8 @@ func ClusterSetupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
if err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
logger.Error().Err(err).Str("output", out).Msg("[cluster_setup] make setup failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make setup: %v", err))
logger.Error().Err(err).Str("output", out).Msg("[cluster_setup] make ping-servers failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
continue
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
@@ -23,6 +24,8 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials"
r53 "github.com/aws/aws-sdk-go-v2/service/route53"
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 *************/
@@ -47,6 +50,9 @@ const externalDNSPoisonOwner = "autoglue-lock"
// 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"
// Default TTL for non-alias records (alias not supported in this reconciler)
const defaultRecordTTLSeconds int64 = 300
/************* entrypoint worker *************/
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
for i := range records {
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)
continue
}
@@ -249,12 +262,24 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
mname := markerName(fq)
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 ----
extOwned, err := hasExternalDNSOwnership(ctx, r53c, zoneID, fq, rt)
if err != nil {
return fmt.Errorf("external_dns_lookup: %w", err)
}
if extOwned {
logCtx.Warn().Msg("[dns] ownership conflict: external-dns claims this record")
r.Owner = "external"
_ = db.Save(r).Error
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 {
return fmt.Errorf("marker lookup: %w", err)
}
hasForeignOwner := false
hasOurExact := false
for _, v := range markerVals {
@@ -279,25 +305,26 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
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 {
logCtx.Warn().Msg("[dns] ownership conflict: foreign _autoglue marker")
r.Owner = "external"
_ = db.Save(r).Error
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
var userVals []string
if len(r.Values) > 0 {
if err := jsonUnmarshalStrict([]byte(r.Values), &userVals); err != nil {
rawVals := strings.TrimSpace(string(r.Values))
if rawVals != "" && rawVals != "null" {
if err := jsonUnmarshalStrict([]byte(rawVals), &userVals); err != nil {
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))
for _, v := range userVals {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if rt == "TXT" && !(strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`)) {
v = strconv.Quote(v)
}
recs = append(recs, r53types.ResourceRecord{Value: aws.String(v)})
}
rrChange.ResourceRecordSet.ResourceRecords = recs
if r.TTL != nil {
ttl := int64(*r.TTL)
rrChange.ResourceRecordSet.TTL = aws.Int64(ttl)
// Alias is NOT supported - enforce at least one value for all record types we manage
if len(recs) == 0 {
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)
@@ -323,7 +373,7 @@ func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.D
ResourceRecordSet: &r53types.ResourceRecordSet{
Name: aws.String(mname),
Type: r53types.RRTypeTxt,
TTL: aws.Int64(300),
TTL: aws.Int64(defaultRecordTTLSeconds),
ResourceRecords: []r53types.ResourceRecord{
{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 = 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{
HostedZoneId: aws.String(zoneID),
ChangeBatch: &r53types.ChangeBatch{Changes: changes},
})
if err != nil {
logAWSError(logCtx, err)
logCtx.Info().Dur("elapsed", time.Since(start)).Msg("[dns] apply failed")
return err
}
logCtx.Info().
Dur("elapsed", time.Since(start)).
Int("change_count", len(changes)).
Msg("[dns] apply ok")
// Success → mark ready & ownership
r.Status = "ready"
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 {
return err
}
_ = hasOurExact // could be used to skip marker write in future
return nil
}
@@ -568,7 +631,7 @@ func buildExternalDNSPoisonTXTChanges(fqdn, rrType string) []r53types.Change {
ResourceRecordSet: &r53types.ResourceRecordSet{
Name: aws.String(n),
Type: r53types.RRTypeTxt,
TTL: aws.Int64(300),
TTL: aws.Int64(defaultRecordTTLSeconds),
ResourceRecords: []r53types.ResourceRecord{
{Value: aws.String(val)},
},
@@ -595,3 +658,125 @@ func jsonUnmarshalStrict(b []byte, dst any) error {
}
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")
}

View 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
}
}

View File

@@ -3,6 +3,7 @@ package bg
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
@@ -12,6 +13,7 @@ import (
"github.com/dyaksa/archer"
"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/utils"
@@ -43,11 +45,12 @@ type ClusterPrepareResult struct {
// Alias the status constants from models to avoid string drift.
const (
clusterStatusPrePending = models.ClusterStatusPrePending
clusterStatusPending = models.ClusterStatusPending
clusterStatusProvisioning = models.ClusterStatusProvisioning
clusterStatusReady = models.ClusterStatusReady
clusterStatusFailed = models.ClusterStatusFailed
clusterStatusPrePending = models.ClusterStatusPrePending
clusterStatusPending = models.ClusterStatusPending
clusterStatusProvisioning = models.ClusterStatusProvisioning
clusterStatusReady = models.ClusterStatusReady
clusterStatusFailed = models.ClusterStatusFailed
clusterStatusBootstrapping = models.ClusterStatusBootstrapping
)
func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
@@ -95,6 +98,13 @@ func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
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().
Str("job", jobID).
Str("cluster_id", c.ID.String()).
@@ -156,6 +166,29 @@ func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
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 {
fail++
@@ -551,3 +584,75 @@ func runMakeOnBastion(
}
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
}

View 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,
}
}

View 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,
}
}

View File

@@ -503,6 +503,51 @@ 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
//
// @ID CreateRecordSet

View 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"`
}

View 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"`
}

View File

@@ -25,6 +25,8 @@ type ClusterResponse struct {
DockerImage string `json:"docker_image"`
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"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -828,16 +828,16 @@ func ListNodePoolLabels(db *gorm.DB) http.HandlerFunc {
}
out := make([]dto.LabelResponse, 0, len(np.Taints))
for _, taint := range np.Taints {
for _, label := range np.Labels {
out = append(out, dto.LabelResponse{
AuditFields: common.AuditFields{
ID: taint.ID,
OrganizationID: taint.OrganizationID,
CreatedAt: taint.CreatedAt,
UpdatedAt: taint.UpdatedAt,
ID: label.ID,
OrganizationID: label.OrganizationID,
CreatedAt: label.CreatedAt,
UpdatedAt: label.UpdatedAt,
},
Key: taint.Key,
Value: taint.Value,
Key: label.Key,
Value: label.Value,
})
}
utils.WriteJSON(w, http.StatusOK, out)

View File

@@ -585,13 +585,22 @@ func CreateOrgKey(db *gorm.DB) http.HandlerFunc {
exp = &e
}
prefix := orgKey
if len(prefix) > 12 {
prefix = prefix[:12]
}
rec := models.APIKey{
OrgID: &oid,
Scope: "org",
Name: req.Name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: exp,
OrgID: &oid,
Scope: "org",
Purpose: "user",
IsEphemeral: false,
Name: req.Name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: exp,
Revoked: false,
Prefix: &prefix,
}
if err := db.Create(&rec).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())

16
internal/models/action.go Normal file
View 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"`
}

View File

@@ -7,17 +7,20 @@ import (
)
type APIKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Name string `gorm:"not null;default:''" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
Scope string `gorm:"not null;default:''" json:"scope"`
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
SecretHash *string `json:"-"`
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
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"`
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
Scope string `gorm:"not null;default:''" json:"scope"`
Purpose string `json:"purpose"`
ClusterID *uuid.UUID `json:"cluster_id,omitempty"`
IsEphemeral bool `json:"is_ephemeral"`
Name string `gorm:"not null;default:''" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
SecretHash *string `json:"-"`
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
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"`
}

View File

@@ -7,12 +7,13 @@ import (
)
const (
ClusterStatusPrePending = "pre_pending" // needs validation
ClusterStatusIncomplete = "incomplete" // invalid/missing shape
ClusterStatusPending = "pending" // valid shape, waiting for provisioning
ClusterStatusProvisioning = "provisioning"
ClusterStatusReady = "ready"
ClusterStatusFailed = "failed" // provisioning/runtime failure
ClusterStatusPrePending = "pre_pending" // needs validation
ClusterStatusIncomplete = "incomplete" // invalid/missing shape
ClusterStatusPending = "pending" // valid shape, waiting for provisioning
ClusterStatusProvisioning = "provisioning"
ClusterStatusReady = "ready"
ClusterStatusFailed = "failed" // provisioning/runtime failure
ClusterStatusBootstrapping = "bootstrapping"
)
type Cluster struct {

View 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"`
}

View File

@@ -1,4 +1,4 @@
FROM postgres:17.7@sha256:44640f16641cf36716cabd011e2f7eb4742b6b6b19f4488ddcbb7c250e5c9753
FROM postgres:17.7@sha256:dca7512acaa113409df7e40d977d801e53c0c8088e45d4311a45b4065ccfdcd3
RUN cd /var/lib/postgresql/ && \
openssl req -new -text -passout pass:abcd -subj /CN=localhost -out server.req -keyout privkey.pem && \

View File

@@ -16,6 +16,6 @@
"prepare": "npm run build"
},
"devDependencies": {
"typescript": "^4.0 || ^5.0"
"typescript": "5.9.3"
}
}

View File

@@ -38,7 +38,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -46,41 +46,41 @@
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.557.0",
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
"next-themes": "^0.4.6",
"rapidoc": "^9.3.8",
"react": "^19.2.1",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react": "^19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.3",
"react-hook-form": "^7.69.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.10.1",
"react-router-dom": "^7.11.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss": "^4.1.18",
"vaul": "^1.1.2",
"zod": "^4.1.13"
"zod": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "9.39.1",
"@eslint/js": "9.39.2",
"@ianvs/prettier-plugin-sort-imports": "4.7.0",
"@types/node": "24.10.2",
"@types/node": "25.0.3",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.2",
"eslint": "9.39.1",
"eslint": "9.39.2",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.4.24",
"eslint-plugin-react-refresh": "0.4.26",
"globals": "16.5.0",
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "0.7.2",
"shadcn": "3.5.2",
"shadcn": "3.6.2",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"typescript-eslint": "8.49.0",
"vite": "7.2.7"
"typescript-eslint": "8.51.0",
"vite": "7.3.0"
}
}

View File

@@ -2,6 +2,7 @@ import { AppShell } from "@/layouts/app-shell.tsx"
import { Route, Routes } from "react-router-dom"
import { ProtectedRoute } from "@/components/protected-route.tsx"
import { ActionsPage } from "@/pages/actions-page.tsx"
import { AnnotationPage } from "@/pages/annotation-page.tsx"
import { ClustersPage } from "@/pages/cluster-page"
import { CredentialPage } from "@/pages/credential-page.tsx"
@@ -46,6 +47,7 @@ export default function App() {
<Route path="/clusters" element={<ClustersPage />} />
<Route path="/admin/jobs" element={<JobsPage />} />
<Route path="/admin/actions" element={<ActionsPage />} />
</Route>
</Route>
<Route path="*" element={<Login />} />

30
ui/src/api/actions.ts Normal file
View 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,
})
}),
}

View File

@@ -8,9 +8,10 @@ import type {
DtoSetKubeconfigRequest,
DtoUpdateClusterRequest,
} from "@/sdk"
import { makeClusterApi } from "@/sdkClient"
import { makeClusterApi, makeClusterRunsApi } from "@/sdkClient"
const clusters = makeClusterApi()
const clusterRuns = makeClusterRunsApi()
export const clustersApi = {
// --- basic CRUD ---
@@ -147,4 +148,20 @@ export const clustersApi = {
withRefresh(async () => {
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 })
}),
}

View File

@@ -6,6 +6,7 @@ import {
FileKey2Icon,
KeyRound,
LockKeyholeIcon,
PickaxeIcon,
ServerIcon,
SprayCanIcon,
TagsIcon,
@@ -49,5 +50,6 @@ export const userNav: NavItem[] = [{ to: "/me", label: "Profile", icon: User2 }]
export const adminNav: NavItem[] = [
{ to: "/admin/users", label: "Users Admin", icon: Users },
{ 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" },
]

View 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>
)
}

View File

@@ -1,14 +1,11 @@
;
// 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 { DtoClusterResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
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";
@@ -19,45 +16,15 @@ 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";
;
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";
@@ -77,6 +44,22 @@ type CreateClusterInput = z.input<typeof createClusterSchema>
const updateClusterSchema = createClusterSchema.partial()
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 ---
function StatusBadge({ status }: { status?: string | null }) {
@@ -133,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 }) {
return (
<div className="text-muted-foreground flex flex-col gap-1 text-xs">
@@ -173,7 +211,7 @@ export const ClustersPage = () => {
const [deleteId, setDeleteId] = 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 [captainDomainId, setCaptainDomainId] = useState("")
@@ -193,36 +231,69 @@ export const ClustersPage = () => {
const clustersQ = useQuery({
queryKey: ["clusters"],
queryFn: () => clustersApi.listClusters(),
queryFn: async () => asArray<DtoClusterResponse>(await clustersApi.listClusters()),
})
const lbsQ = useQuery({
queryKey: ["load-balancers"],
queryFn: () => loadBalancersApi.listLoadBalancers(),
queryFn: async () =>
asArray<DtoLoadBalancerResponse>(await loadBalancersApi.listLoadBalancers()),
})
const domainsQ = useQuery({
queryKey: ["domains"],
queryFn: () => dnsApi.listDomains(),
queryFn: async () => asArray<DtoDomainResponse>(await dnsApi.listDomains()),
})
// record sets fetched per captain domain
const recordSetsQ = useQuery({
queryKey: ["record-sets", captainDomainId],
enabled: !!captainDomainId,
queryFn: () => dnsApi.listRecordSetsByDomain(captainDomainId),
queryFn: async () =>
asArray<DtoRecordSetResponse>(await dnsApi.listRecordSetsByDomain(captainDomainId)),
})
const serversQ = useQuery({
queryKey: ["servers"],
queryFn: () => serversApi.listServers(),
queryFn: async () => asArray<DtoServerResponse>(await serversApi.listServers()),
})
const npQ = useQuery({
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 ---
const createForm = useForm<CreateClusterInput>({
@@ -244,15 +315,10 @@ export const ClustersPage = () => {
setCreateOpen(false)
toast.success("Cluster created successfully.")
},
onError: (err: any) => {
toast.error(err?.message ?? "There was an error while creating the cluster")
},
onError: (err: any) =>
toast.error(err?.message ?? "There was an error while creating the cluster"),
})
const onCreateSubmit = (values: CreateClusterInput) => {
createMut.mutate(values)
}
// --- Update basic details ---
const updateForm = useForm<UpdateClusterValues>({
@@ -269,9 +335,8 @@ export const ClustersPage = () => {
setUpdateOpen(false)
toast.success("Cluster updated successfully.")
},
onError: (err: any) => {
toast.error(err?.message ?? "There was an error while updating the cluster")
},
onError: (err: any) =>
toast.error(err?.message ?? "There was an error while updating the cluster"),
})
const openEdit = (cluster: DtoClusterResponse) => {
@@ -296,11 +361,32 @@ export const ClustersPage = () => {
setDeleteId(null)
toast.success("Cluster deleted successfully.")
},
onError: (err: any) => {
toast.error(err?.message ?? "There was an error while deleting the cluster")
},
onError: (err: any) =>
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 ---
const filtered = useMemo(() => {
@@ -333,30 +419,23 @@ export const ClustersPage = () => {
return
}
// Prefill IDs from current attachments
if (configCluster.captain_domain?.id) {
setCaptainDomainId(configCluster.captain_domain.id)
}
if (configCluster.control_plane_record_set?.id) {
if (configCluster.captain_domain?.id) setCaptainDomainId(configCluster.captain_domain.id)
if (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.glueops_load_balancer?.id) {
if (configCluster.apps_load_balancer?.id) setAppsLbId(configCluster.apps_load_balancer.id)
if (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])
async function refreshConfigCluster() {
if (!configCluster?.id) return
try {
const updated = await clustersApi.getCluster(configCluster.id)
const updatedRaw = await clustersApi.getCluster(configCluster.id)
const updated = asObject<DtoClusterResponse>(updatedRaw)
setConfigCluster(updated)
await qc.invalidateQueries({ queryKey: ["clusters"] })
await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster.id] })
} catch {
// ignore
}
@@ -364,15 +443,10 @@ export const ClustersPage = () => {
async function handleAttachCaptain() {
if (!configCluster?.id) return
if (!captainDomainId) {
toast.error("Domain is required")
return
}
if (!captainDomainId) return toast.error("Domain is required")
setBusyKey("captain")
try {
await clustersApi.attachCaptainDomain(configCluster.id, {
domain_id: captainDomainId,
})
await clustersApi.attachCaptainDomain(configCluster.id, { domain_id: captainDomainId })
toast.success("Captain domain attached.")
await refreshConfigCluster()
} catch (err: any) {
@@ -398,10 +472,7 @@ export const ClustersPage = () => {
async function handleAttachRecordSet() {
if (!configCluster?.id) return
if (!recordSetId) {
toast.error("Record set is required")
return
}
if (!recordSetId) return toast.error("Record set is required")
setBusyKey("recordset")
try {
await clustersApi.attachControlPlaneRecordSet(configCluster.id, {
@@ -432,15 +503,10 @@ export const ClustersPage = () => {
async function handleAttachAppsLb() {
if (!configCluster?.id) return
if (!appsLbId) {
toast.error("Load balancer is required")
return
}
if (!appsLbId) return toast.error("Load balancer is required")
setBusyKey("apps-lb")
try {
await clustersApi.attachAppsLoadBalancer(configCluster.id, {
load_balancer_id: appsLbId,
})
await clustersApi.attachAppsLoadBalancer(configCluster.id, { load_balancer_id: appsLbId })
toast.success("Apps load balancer attached.")
await refreshConfigCluster()
} catch (err: any) {
@@ -466,10 +532,7 @@ export const ClustersPage = () => {
async function handleAttachGlueopsLb() {
if (!configCluster?.id) return
if (!glueopsLbId) {
toast.error("Load balancer is required")
return
}
if (!glueopsLbId) return toast.error("Load balancer is required")
setBusyKey("glueops-lb")
try {
await clustersApi.attachGlueOpsLoadBalancer(configCluster.id, {
@@ -500,15 +563,10 @@ export const ClustersPage = () => {
async function handleAttachBastion() {
if (!configCluster?.id) return
if (!bastionId) {
toast.error("Server is required")
return
}
if (!bastionId) return toast.error("Server is required")
setBusyKey("bastion")
try {
await clustersApi.attachBastion(configCluster.id, {
server_id: bastionId,
})
await clustersApi.attachBastion(configCluster.id, { server_id: bastionId })
toast.success("Bastion server attached.")
await refreshConfigCluster()
} catch (err: any) {
@@ -534,10 +592,7 @@ export const ClustersPage = () => {
async function handleAttachNodePool() {
if (!configCluster?.id) return
if (!nodePoolId) {
toast.error("Node pool is required")
return
}
if (!nodePoolId) return toast.error("Node pool is required")
setBusyKey("nodepool")
try {
await clustersApi.attachNodePool(configCluster.id, nodePoolId)
@@ -567,15 +622,10 @@ export const ClustersPage = () => {
async function handleSetKubeconfig() {
if (!configCluster?.id) return
if (!kubeconfigText.trim()) {
toast.error("Kubeconfig is required")
return
}
if (!kubeconfigText.trim()) return toast.error("Kubeconfig is required")
setBusyKey("kubeconfig")
try {
await clustersApi.setKubeconfig(configCluster.id, {
kubeconfig: kubeconfigText,
})
await clustersApi.setKubeconfig(configCluster.id, { kubeconfig: kubeconfigText })
toast.success("Kubeconfig updated.")
setKubeconfigText("")
await refreshConfigCluster()
@@ -636,7 +686,10 @@ export const ClustersPage = () => {
</DialogHeader>
<Form {...createForm}>
<form className="space-y-4" onSubmit={createForm.handleSubmit(onCreateSubmit)}>
<form
className="space-y-4"
onSubmit={createForm.handleSubmit((v) => createMut.mutate(v))}
>
<FormField
control={createForm.control}
name="name"
@@ -750,7 +803,7 @@ export const ClustersPage = () => {
</div>
)}
</TableCell>
<TableCell>{c.docker_image + ":" + c.docker_tag}</TableCell>
<TableCell>{(c.docker_image ?? "") + ":" + (c.docker_tag ?? "")}</TableCell>
<TableCell>
<ClusterSummary c={c} />
{c.id && (
@@ -782,7 +835,7 @@ export const ClustersPage = () => {
{filtered.length === 0 && (
<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" />
No clusters match your search.
</TableCell>
@@ -799,6 +852,7 @@ export const ClustersPage = () => {
<DialogHeader>
<DialogTitle>Edit Cluster</DialogTitle>
</DialogHeader>
<Form {...updateForm}>
<form
className="space-y-4"
@@ -890,37 +944,154 @@ export const ClustersPage = () => {
</DialogContent>
</Dialog>
{/* Configure dialog (attachments + kubeconfig + node pools) */}
{/* Configure dialog (attachments + kubeconfig + node pools + actions/runs) */}
<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>
<DialogTitle>
Configure Cluster{configCluster?.name ? `: ${configCluster.name}` : ""}
</DialogTitle>
</DialogHeader>
{configCluster && (
<div className="space-y-6 py-2">
{/* Kubeconfig */}
{/* Cluster Actions */}
<section className="space-y-2 rounded-xl border p-4">
<div className="flex items-center justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<FileCode2 className="h-4 w-4" />
<h3 className="text-sm font-semibold">Kubeconfig</h3>
<Wrench className="h-4 w-4" />
<h3 className="text-sm font-semibold">Cluster Actions</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.
Run admin-configured actions on this cluster. Actions are executed
asynchronously.
</p>
</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>
<Textarea
value={kubeconfigText}
onChange={(e) => setKubeconfigText(e.target.value)}
rows={6}
placeholder="apiVersion: v1&#10;clusters:&#10; - cluster: ..."
placeholder={"apiVersion: v1\nclusters:\n - cluster: ..."}
className="font-mono text-xs"
/>
@@ -1005,7 +1176,7 @@ export const ClustersPage = () => {
</div>
</section>
{/* Control Plane Record Set (shown once we have a captainDomainId) */}
{/* Control Plane Record Set */}
{captainDomainId && (
<section className="space-y-2 rounded-xl border p-4">
<div className="flex items-center justify-between gap-2">
@@ -1242,14 +1413,12 @@ export const ClustersPage = () => {
{/* Node Pools */}
<section className="space-y-2 rounded-xl border p-4">
<div className="flex items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold">Node Pools</h3>
<p className="text-muted-foreground text-xs">
Attach node pools to this cluster. Each node pool may have its own labels,
taints, and backing servers.
</p>
</div>
<div>
<h3 className="text-sm font-semibold">Node Pools</h3>
<p className="text-muted-foreground text-xs">
Attach node pools to this cluster. Each node pool may have its own labels,
taints, and backing servers.
</p>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-end">
@@ -1348,8 +1517,6 @@ export const ClustersPage = () => {
</DialogFooter>
</DialogContent>
</Dialog>
<pre>{JSON.stringify(clustersQ.data, null, 2)}</pre>
</div>
)
}

View File

@@ -305,6 +305,7 @@ export const MePage = () => {
<Table>
<TableHeader>
<TableRow>
<TableHead>Id</TableHead>
<TableHead>Name</TableHead>
<TableHead>Domain</TableHead>
</TableRow>
@@ -312,6 +313,7 @@ export const MePage = () => {
<TableBody>
{meQ.data?.organizations?.map((o) => (
<TableRow key={o.id}>
<TableCell>{o.id}</TableCell>
<TableCell>{o.name}</TableCell>
<TableCell>{(o as any).domain ?? "—"}</TableCell>
</TableRow>

View File

@@ -8,6 +8,7 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
@@ -35,10 +36,12 @@ import {
TableRow,
} from "@/components/ui/table.tsx"
// 1) No coerce; well do the conversion in onChange
const createSchema = z.object({
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>
export const OrgApiKeys = () => {
@@ -52,6 +55,7 @@ export const OrgApiKeys = () => {
queryFn: () => withRefresh(() => api.listOrgKeys({ id: orgId! })),
})
// 2) Form holds numbers directly
const form = useForm<CreateValues>({
resolver: zodResolver(createSchema),
defaultValues: {
@@ -71,7 +75,7 @@ export const OrgApiKeys = () => {
void qc.invalidateQueries({ queryKey: ["org:keys", orgId] })
setShowSecret({ key: resp.org_key, secret: resp.org_secret })
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"),
})
@@ -124,7 +128,17 @@ export const OrgApiKeys = () => {
<FormItem>
<FormLabel>Expires In (hours)</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -148,6 +162,7 @@ export const OrgApiKeys = () => {
<TableHead>Scope</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
@@ -160,6 +175,33 @@ export const OrgApiKeys = () => {
<TableCell>
{k.expires_at ? new Date(k.expires_at).toLocaleString() : "-"}
</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">
<Button variant="destructive" size="sm" onClick={() => deleteMut.mutate(k.id!)}>
Delete

View File

@@ -1,9 +1,11 @@
import { orgStore } from "@/auth/org.ts"
import { authStore } from "@/auth/store.ts"
import {
ActionsApi,
AnnotationsApi,
ArcherAdminApi,
AuthApi,
ClusterRunsApi,
ClustersApi,
Configuration,
CredentialsApi,
@@ -133,3 +135,11 @@ export function makeLoadBalancerApi() {
export function makeClusterApi() {
return makeApiClient(ClustersApi)
}
export function makeActionsApi() {
return makeApiClient(ActionsApi)
}
export function makeClusterRunsApi() {
return makeApiClient(ClusterRunsApi)
}

File diff suppressed because it is too large Load Diff