Compare commits

..

262 Commits

Author SHA1 Message Date
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
allanice001
de3740e974 Merge remote-tracking branch 'origin/main' 2025-12-11 13:08:47 +00:00
allanice001
21dd26503f feat: add kubeconfig to payload if available
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-11 13:08:39 +00:00
public-glueops-renovatebot[bot]
e1da229c30 chore: lock file maintenance (#439)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-10 14:43:04 +00:00
allanice001
5377e521e9 fix: fix build error
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-10 14:19:10 +00:00
allanice001
a929561bc8 fix: bg jobs sequencing 2025-12-10 13:25:12 +00:00
allanice001
c63f9f1cf3 Merge remote-tracking branch 'origin/main' 2025-12-10 12:35:10 +00:00
allanice001
4c02179b70 fix: fix payload shape
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-10 12:34:11 +00:00
public-glueops-renovatebot[bot]
c8289c6936 chore: lock file maintenance (#435)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-10 11:38:00 +00:00
allanice001
c17caf22a2 fix: ui typo fix 2025-12-10 09:51:38 +00:00
allanice001
986eeb9bf9 fix: ui typo fix
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-10 09:51:13 +00:00
allanice001
b0bbc13946 fix: ui typo fix
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-10 09:51:07 +00:00
public-glueops-renovatebot[bot]
f50dcae823 feat: update github.com/aws/aws-sdk-go-v2/service/route53 to v1.62.0 #minor (#433)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-09 21:20:27 +00:00
public-glueops-renovatebot[bot]
ab9a77e1f5 chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.32.5 #patch (#432)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-09 20:41:11 +00:00
allanice001
416b2ff4e2 Merge remote-tracking branch 'origin/main' 2025-12-09 18:11:01 +00:00
allanice001
20bef7545c chore: log background jobs
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-09 18:10:50 +00:00
public-glueops-renovatebot[bot]
15e101439b chore: lock file maintenance (#430)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-09 17:05:07 +00:00
public-glueops-renovatebot[bot]
fb0901a812 chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.32.4 #patch (#428)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-09 16:56:56 +00:00
allanice001
fee4c64551 fix: fix background jobs
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-09 15:49:31 +00:00
allanice001
4d37a6363f feat: add docker_image and docker_tag to cluster api
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-08 17:04:10 +00:00
allanice001
1dbdd04808 Merge remote-tracking branch 'origin/main' 2025-12-08 16:19:03 +00:00
allanice001
45b55015ac fix: ensure jobs are running
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-08 16:18:54 +00:00
public-glueops-renovatebot[bot]
6b191089a5 chore: lock file maintenance (#418)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-08 14:42:04 +00:00
public-glueops-renovatebot[bot]
d2e6ff9812 feat: update lucide-react to 0.556.0 #minor (#408)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-08 13:46:35 +00:00
public-glueops-renovatebot[bot]
98a6cf7e51 feat: update golang.org/x/oauth2 to v0.34.0 #minor (#417)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-08 12:31:38 +00:00
public-glueops-renovatebot[bot]
fb4af74e3c chore: lock file maintenance (#414)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-07 14:38:05 +00:00
public-glueops-renovatebot[bot]
1021e06655 chore: lock file maintenance (#413)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-06 18:42:45 +00:00
public-glueops-renovatebot[bot]
c6be7bf8eb chore: lock file maintenance (#411)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-06 14:37:41 +00:00
public-glueops-renovatebot[bot]
1429c40b2b chore: lock file maintenance (#410)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-05 16:43:39 +00:00
public-glueops-renovatebot[bot]
73c4904a42 chore: lock file maintenance (#409)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-05 13:54:39 +00:00
allanice001
40df22c166 Merge remote-tracking branch 'origin/main' 2025-12-05 12:37:32 +00:00
allanice001
500a8d1095 chore: ensure build succeeds locally
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-05 12:37:22 +00:00
public-glueops-renovatebot[bot]
eff69ff4ce chore(pin): update typescript to #patch (#407)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-05 12:32:31 +00:00
allanice001
2cd6ee91eb fix: cluster page references
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-05 12:31:16 +00:00
allanice001
2d3800b576 chore: update dependencies
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-05 12:20:47 +00:00
allanice001
eb86f2ad3c Merge remote-tracking branch 'origin/main'
# Conflicts:
#	docker-compose.yml
#	go.sum
2025-12-05 12:18:18 +00:00
allanice001
0b342f2c65 fis: updates to remove Terraform Provider reserved word collisions
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-12-05 12:17:36 +00:00
public-glueops-renovatebot[bot]
e8725b1a7c chore(patch): update typescript-eslint to 8.48.1 #patch (#373)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-05 09:28:03 +00:00
public-glueops-renovatebot[bot]
5ec1d3bb0c chore: lock file maintenance (#406)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-05 07:26:59 +00:00
public-glueops-renovatebot[bot]
35efd1c0b9 chore: lock file maintenance (#404)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-04 19:25:02 +00:00
public-glueops-renovatebot[bot]
8febd35998 chore: lock file maintenance (#402)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-04 18:41:49 +00:00
public-glueops-renovatebot[bot]
94f668583e chore(patch): update prettier-plugin-tailwindcss to 0.7.2 #patch (#368)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-04 18:40:43 +00:00
public-glueops-renovatebot[bot]
9e1bca3dc3 chore: lock file maintenance (#401)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-04 14:02:27 +00:00
public-glueops-renovatebot[bot]
a9eb8cebd6 chore(patch): update vite to 7.2.6 #patch (#365)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-04 08:01:32 +00:00
public-glueops-renovatebot[bot]
fa5bbbe7b9 chore(fallback): update golang (#399)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-04 04:24:02 +00:00
public-glueops-renovatebot[bot]
8468317cf9 chore(patch): update github.com/spf13/cobra to v1.10.2 #patch (#398)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-04 00:41:00 +00:00
public-glueops-renovatebot[bot]
d315536956 chore: lock file maintenance (#397)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 23:26:28 +00:00
public-glueops-renovatebot[bot]
165fcb51ff chore: lock file maintenance (#396)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 23:25:21 +00:00
public-glueops-renovatebot[bot]
6f195aa0e6 chore: lock file maintenance (#395)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 22:49:35 +00:00
public-glueops-renovatebot[bot]
b07d56c93c chore: lock file maintenance (#393)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 22:06:05 +00:00
public-glueops-renovatebot[bot]
01fb6bfe61 feat: update alpine to 3.23 #minor (#392)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 20:44:33 +00:00
public-glueops-renovatebot[bot]
06bd37f8d4 chore: lock file maintenance (#391)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 16:58:04 +00:00
public-glueops-renovatebot[bot]
4f005b541d chore: lock file maintenance (#390)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 16:19:55 +00:00
public-glueops-renovatebot[bot]
8426f43c5e chore: lock file maintenance (#389)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 16:18:40 +00:00
public-glueops-renovatebot[bot]
963477a348 chore: lock file maintenance (#388)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 15:39:17 +00:00
public-glueops-renovatebot[bot]
cdec3896a7 chore: lock file maintenance (#387)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 14:24:17 +00:00
public-glueops-renovatebot[bot]
ba39f70e21 chore: lock file maintenance (#386)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 13:47:15 +00:00
public-glueops-renovatebot[bot]
571aa67f39 chore: lock file maintenance (#385)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-03 07:03:42 +00:00
public-glueops-renovatebot[bot]
8b01dfb8f0 chore: lock file maintenance (#383)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 19:02:33 +00:00
public-glueops-renovatebot[bot]
e177a07c94 chore(patch): update golang to 1.25.5 #patch (#382)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 19:01:22 +00:00
public-glueops-renovatebot[bot]
3ea6de756f chore(patch): update prettier to 3.7.3 #patch (#378)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 18:19:56 +00:00
public-glueops-renovatebot[bot]
be197a4ec5 feat: update github.com/aws/aws-sdk-go-v2/service/s3 to v1.93.0 #minor (#381)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 17:38:01 +00:00
public-glueops-renovatebot[bot]
fb92033555 chore(patch): update github.com/aws/aws-sdk-go-v2 to v1.40.1 #patch (#380)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 17:37:13 +00:00
public-glueops-renovatebot[bot]
4420ea8ffe chore: lock file maintenance (#379)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 16:42:41 +00:00
public-glueops-renovatebot[bot]
e1c59e2dbe chore(patch): update prettier to 3.7.2 #patch (#364)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 16:04:34 +00:00
public-glueops-renovatebot[bot]
d269f8fa52 chore: lock file maintenance (#377)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 14:06:49 +00:00
public-glueops-renovatebot[bot]
3ee9c0cddb chore: lock file maintenance (#376)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 09:51:28 +00:00
public-glueops-renovatebot[bot]
fa97e9411a chore: lock file maintenance (#375)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 09:17:34 +00:00
public-glueops-renovatebot[bot]
e2f91ffc8c chore: lock file maintenance (#374)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 09:16:25 +00:00
public-glueops-renovatebot[bot]
9bc47b7fdc chore: lock file maintenance (#372)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-02 07:25:20 +00:00
public-glueops-renovatebot[bot]
3d2f98bf7c chore: lock file maintenance (#371)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-01 21:04:18 +00:00
public-glueops-renovatebot[bot]
1c875f2634 chore: lock file maintenance (#370)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-01 16:48:00 +00:00
public-glueops-renovatebot[bot]
b752cf2aa9 chore: lock file maintenance (#369)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-01 16:13:19 +00:00
public-glueops-renovatebot[bot]
a52166af4a chore: lock file maintenance (#367)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-12-01 11:55:03 +00:00
public-glueops-renovatebot[bot]
83abb08534 chore(patch): update shadcn to 3.5.1 #patch (#356)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-30 17:17:55 +00:00
public-glueops-renovatebot[bot]
fefad47ab4 feat: update prettier to 3.7.1 #minor (#354)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-30 16:29:21 +00:00
public-glueops-renovatebot[bot]
125c896ef5 chore: lock file maintenance (#363)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-29 21:22:59 +00:00
public-glueops-renovatebot[bot]
bc04f4ed8e chore: lock file maintenance (#362)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-29 11:41:17 +00:00
public-glueops-renovatebot[bot]
25f2da417d feat: update lucide-react to 0.555.0 #minor (#349)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-29 08:48:38 +00:00
public-glueops-renovatebot[bot]
79e16c8bf7 chore: lock file maintenance (#360)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-29 08:00:20 +00:00
public-glueops-renovatebot[bot]
56a4899d2b chore: lock file maintenance (#359)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-28 20:56:37 +00:00
public-glueops-renovatebot[bot]
6e86da8939 chore: lock file maintenance (#358)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-28 16:09:59 +00:00
public-glueops-renovatebot[bot]
75dc2689c8 chore: lock file maintenance (#357)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-28 14:50:12 +00:00
public-glueops-renovatebot[bot]
2fcfa04a3f feat: update typescript-eslint to 8.48.0 #minor (#338)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-27 17:11:41 +00:00
public-glueops-renovatebot[bot]
78ec2744f1 feat: update docker/metadata-action to v5.10.0 #minor (#355)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-27 14:01:03 +00:00
public-glueops-renovatebot[bot]
6948fcfaec chore(patch): update @types/react to 19.2.7 #patch (#336)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-27 10:01:25 +00:00
public-glueops-renovatebot[bot]
4ac43b38d5 chore: lock file maintenance (#353)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-27 07:04:08 +00:00
public-glueops-renovatebot[bot]
590d3980be chore: lock file maintenance (#352)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-26 23:07:17 +00:00
public-glueops-renovatebot[bot]
e569b90fcf chore: lock file maintenance (#351)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-26 19:03:32 +00:00
public-glueops-renovatebot[bot]
49eb568990 chore: lock file maintenance (#350)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-26 17:57:41 +00:00
public-glueops-renovatebot[bot]
698c8263ca chore: lock file maintenance (#347)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-25 22:58:06 +00:00
public-glueops-renovatebot[bot]
b73f78dd74 chore: lock file maintenance (#346)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-25 21:51:54 +00:00
public-glueops-renovatebot[bot]
b250f1d8c2 feat: update github.com/aws/aws-sdk-go-v2/service/route53 to v1.61.0 #minor (#345)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-25 20:52:18 +00:00
public-glueops-renovatebot[bot]
1764cfa03d chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.32.2 #patch (#344)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-25 20:51:16 +00:00
public-glueops-renovatebot[bot]
7e33ae3e38 chore: lock file maintenance (#343)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-25 17:34:34 +00:00
public-glueops-renovatebot[bot]
590a39ef14 chore: lock file maintenance (#341)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-25 14:00:53 +00:00
public-glueops-renovatebot[bot]
154bd738ff chore: lock file maintenance (#340)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-24 23:51:08 +00:00
public-glueops-renovatebot[bot]
831c29a774 chore: lock file maintenance (#339)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-24 19:24:00 +00:00
public-glueops-renovatebot[bot]
8a5afc358c chore: lock file maintenance (#337)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-24 17:14:49 +00:00
public-glueops-renovatebot[bot]
93f4189885 chore: lock file maintenance (#335)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-24 08:01:15 +00:00
public-glueops-renovatebot[bot]
e1a53b122b chore: lock file maintenance (#334)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-24 03:06:41 +00:00
public-glueops-renovatebot[bot]
2fafe2d752 chore: lock file maintenance (#332)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-23 15:24:02 +00:00
public-glueops-renovatebot[bot]
1d82e562f4 chore: lock file maintenance (#331)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-23 14:40:27 +00:00
public-glueops-renovatebot[bot]
a6a4315b7c chore: lock file maintenance (#330)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-23 11:00:23 +00:00
public-glueops-renovatebot[bot]
f7aa67f522 chore(patch): update vite to 7.2.4 #patch (#316)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-23 07:45:30 +00:00
public-glueops-renovatebot[bot]
ddcba7e4e8 chore: lock file maintenance (#329)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-23 04:10:22 +00:00
public-glueops-renovatebot[bot]
f44ac86010 chore: lock file maintenance (#328)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-22 22:30:47 +00:00
public-glueops-renovatebot[bot]
c02eb401a9 feat: update github.com/sosedoff/pgweb to v0.17.0 #minor (#327)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-22 16:10:35 +00:00
public-glueops-renovatebot[bot]
bc24ae2553 chore(fallback): update sosedoff/pgweb (#326)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-22 16:09:46 +00:00
public-glueops-renovatebot[bot]
f423a53854 chore: lock file maintenance (#325)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-21 21:11:01 +00:00
public-glueops-renovatebot[bot]
f9ca5a85ae chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.32.1 #patch (#324)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-21 21:09:50 +00:00
public-glueops-renovatebot[bot]
a815cb8131 feat: update github.com/coreos/go-oidc/v3 to v3.17.0 #minor (#323)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-21 03:41:52 +00:00
public-glueops-renovatebot[bot]
e488974ca8 chore(patch): update @types/react to 19.2.6 #patch (#301)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-21 02:54:49 +00:00
public-glueops-renovatebot[bot]
55b5459854 chore: lock file maintenance (#322)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-20 23:17:56 +00:00
public-glueops-renovatebot[bot]
76e02ac8e0 feat: update github.com/aws/aws-sdk-go-v2/service/s3 to v1.92.0 #minor (#321)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-20 20:39:04 +00:00
public-glueops-renovatebot[bot]
af19648fe9 chore: lock file maintenance (#320)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-20 19:26:41 +00:00
public-glueops-renovatebot[bot]
28c8151b2c feat: update typescript-eslint to 8.47.0 #minor (#296)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-20 17:39:32 +00:00
public-glueops-renovatebot[bot]
fa831a74ce chore: lock file maintenance (#318)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-20 15:17:30 +00:00
public-glueops-renovatebot[bot]
3c2b7dade5 chore: lock file maintenance (#317)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-20 11:33:14 +00:00
public-glueops-renovatebot[bot]
78b9c97de1 feat: update lucide-react to 0.554.0 #minor (#295)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-20 09:17:36 +00:00
public-glueops-renovatebot[bot]
ae1aa591a2 chore: lock file maintenance (#315)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-20 03:03:01 +00:00
public-glueops-renovatebot[bot]
7cae40b00b feat: update github.com/aws/aws-sdk-go-v2/config to v1.32.0 #minor (#313)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-19 23:39:30 +00:00
public-glueops-renovatebot[bot]
59b158ca0a chore(patch): update github.com/aws/aws-sdk-go-v2/service/route53 to v1.60.1 #patch (#311)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-19 23:38:44 +00:00
public-glueops-renovatebot[bot]
915a9a4342 feat: update github.com/aws/aws-sdk-go-v2/service/s3 to v1.91.0 #minor (#309)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-19 20:42:54 +00:00
public-glueops-renovatebot[bot]
ffa59e66e1 chore: lock file maintenance (#306)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-19 20:36:23 +00:00
public-glueops-renovatebot[bot]
1e8d2e8410 feat: update golang.org/x/crypto to v0.45.0 #minor (#310)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-19 20:35:48 +00:00
public-glueops-renovatebot[bot]
686511e21b feat: update github.com/aws/aws-sdk-go-v2/service/route53 to v1.60.0 #minor (#308)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-19 20:35:32 +00:00
public-glueops-renovatebot[bot]
8cb1901227 chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.31.21 #patch (#307)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-19 20:34:24 +00:00
public-glueops-renovatebot[bot]
388eec726e chore: lock file maintenance (#305)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-18 23:46:13 +00:00
public-glueops-renovatebot[bot]
6f696b4e94 chore: lock file maintenance (#304)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-18 17:04:13 +00:00
public-glueops-renovatebot[bot]
5c07732b42 chore: lock file maintenance (#303)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-18 15:35:40 +00:00
public-glueops-renovatebot[bot]
7949a544a4 chore: lock file maintenance (#302)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-18 11:02:01 +00:00
public-glueops-renovatebot[bot]
3f0f6579ef chore: lock file maintenance (#300)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-17 20:48:03 +00:00
allanice001
efac33fba6 chore: updates in UI due to migration to OAS3.1 2025-11-17 19:57:04 +00:00
allanice001
22a411fed9 chore: updates in UI due to migration to OAS3.1
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 19:56:41 +00:00
allanice001
83c3116ed9 Merge remote-tracking branch 'origin/main' 2025-11-17 19:56:26 +00:00
allanice001
07974c1359 chore: updates in UI due to migration to OAS3.1
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 19:56:16 +00:00
public-glueops-renovatebot[bot]
d08528586c chore: lock file maintenance (#298)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-17 19:12:50 +00:00
public-glueops-renovatebot[bot]
bb745d6a4e chore: lock file maintenance (#294)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-17 18:23:43 +00:00
allanice001
0f0edf1007 Merge remote-tracking branch 'origin/main' 2025-11-17 18:21:57 +00:00
allanice001
56f86a11b4 feat: cluster page ui
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 18:21:48 +00:00
public-glueops-renovatebot[bot]
c9fe259a3a chore(fallback): update actions/checkout (#297)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-17 18:15:21 +00:00
allanice001
d163a050d8 feat: load balancers ui
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 15:16:20 +00:00
allanice001
9853d32b04 chore: update schema.sql from DB using atlas
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 05:03:55 +00:00
allanice001
d0c43df71c fix: package updates
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 05:02:11 +00:00
allanice001
219ce80e5b Merge remote-tracking branch 'origin/main'
# Conflicts:
#	go.mod
#	go.sum
#	ui/yarn.lock
2025-11-17 05:00:10 +00:00
allanice001
7985b310c5 feat: Complete AG Loadbalancer & Cluster API
Refactor routing logic (Chi can be a pain when you're managing large sets of routes, but its one of the better options when considering a potential gRPC future)
       Upgrade API Generation to fully support OAS3.1
      Update swagger interface to RapiDoc - the old swagger interface doesnt support OAS3.1 yet
      Docs are now embedded as part of the UI - once logged in they pick up the cookies and org id from what gets set by the UI, but you can override it
      Other updates include better portability of the db-studio

Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 04:59:39 +00:00
public-glueops-renovatebot[bot]
501785d471 chore(patch): update github.com/dyaksa/archer to v1.1.5 #patch (#293)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-17 01:31:12 +00:00
public-glueops-renovatebot[bot]
3ca32e9ed7 chore: lock file maintenance (#292)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-16 18:59:00 +00:00
public-glueops-renovatebot[bot]
b6e5d329a5 chore: lock file maintenance (#291)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-16 14:00:52 +00:00
public-glueops-renovatebot[bot]
c0821253ca chore: lock file maintenance (#290)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-16 05:18:07 +00:00
public-glueops-renovatebot[bot]
bb8b1f2773 chore: lock file maintenance (#289)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 23:12:05 +00:00
public-glueops-renovatebot[bot]
33b0dffba7 chore: lock file maintenance (#288)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 10:09:03 +00:00
public-glueops-renovatebot[bot]
04cc6facaa chore(patch): update @vitejs/plugin-react to 5.1.1 #patch (#265)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 08:02:08 +00:00
public-glueops-renovatebot[bot]
e414204ac9 chore(patch): update @types/react to 19.2.5 #patch (#285)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 05:16:15 +00:00
public-glueops-renovatebot[bot]
2975baafb9 chore: lock file maintenance (#287)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 04:24:58 +00:00
public-glueops-renovatebot[bot]
5819d69d3e chore(patch): update @types/node to 24.10.1 #patch (#263)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 23:40:14 +00:00
public-glueops-renovatebot[bot]
d0ab259047 chore: lock file maintenance (#286)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 20:20:18 +00:00
public-glueops-renovatebot[bot]
058b07993c chore(patch): update @types/react to 19.2.3 #patch (#261)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 17:30:05 +00:00
public-glueops-renovatebot[bot]
92fbf004c6 chore: lock file maintenance (#284)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 08:25:10 +00:00
public-glueops-renovatebot[bot]
1d89bc4312 chore: lock file maintenance (#283)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 07:39:11 +00:00
public-glueops-renovatebot[bot]
6626565a75 chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.31.20 #patch (#282)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 07:38:06 +00:00
allanice001
165d2a2af1 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	go.mod
2025-11-14 06:13:16 +00:00
allanice001
fc1c83ba18 chore: cleanup and route refactoring
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-14 06:12:59 +00:00
public-glueops-renovatebot[bot]
2be0eb8180 chore: lock file maintenance (#279)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 03:03:45 +00:00
public-glueops-renovatebot[bot]
81043419e1 chore(fallback): update postgres (#278)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 01:31:51 +00:00
public-glueops-renovatebot[bot]
f2ff08993a feat: update postgres to 17.7 #minor (#276)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-13 21:46:01 +00:00
public-glueops-renovatebot[bot]
fabf456786 chore: lock file maintenance (#275)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-13 20:51:37 +00:00
public-glueops-renovatebot[bot]
23c60d4ce8 chore: lock file maintenance (#274)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-13 17:40:05 +00:00
public-glueops-renovatebot[bot]
c9bc09ae92 chore(patch): update typescript-eslint to 8.46.4 #patch (#257)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-13 17:38:53 +00:00
public-glueops-renovatebot[bot]
6ec8305962 chore: lock file maintenance (#273)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-13 14:36:16 +00:00
public-glueops-renovatebot[bot]
8d34198477 chore: lock file maintenance (#272)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-12 21:47:38 +00:00
public-glueops-renovatebot[bot]
f79224b831 chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.31.20 #patch (#271)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-12 20:50:59 +00:00
public-glueops-renovatebot[bot]
cd8e3f2d86 chore: lock file maintenance (#270)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-12 16:25:54 +00:00
public-glueops-renovatebot[bot]
ad0ec48027 chore: lock file maintenance (#269)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-12 15:27:59 +00:00
public-glueops-renovatebot[bot]
7a3e56b3ff chore: lock file maintenance (#268)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-12 14:38:46 +00:00
public-glueops-renovatebot[bot]
015044abb1 chore: lock file maintenance (#267)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-12 13:56:14 +00:00
public-glueops-renovatebot[bot]
afd8c8ceb2 chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.31.19 #patch (#264)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-12 06:20:54 +00:00
allanice001
b358911b1b Merge remote-tracking branch 'origin/main' 2025-11-12 05:33:18 +00:00
allanice001
ad8141a497 feat: adding hourly backups to s3
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-12 05:33:09 +00:00
public-glueops-renovatebot[bot]
9485b2ae4f feat: update golang.org/x/crypto to v0.44.0 #minor (#262)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-11 19:04:59 +00:00
public-glueops-renovatebot[bot]
cc8e8b38c7 chore: lock file maintenance (#260)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-11 14:22:01 +00:00
allanice001
586e51b8cc fix: db-studio prefix fixes 2025-11-11 04:23:27 +00:00
allanice001
ea4c625269 fix: db-studio prefix fixes 2025-11-11 04:01:57 +00:00
allanice001
b4c108a5be Merge branch 'main' of github.com:GlueOps/autoglue 2025-11-11 03:20:10 +00:00
allanice001
3a1ce33bca feat: adding embedded db-studio
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-11 03:19:09 +00:00
public-glueops-renovatebot[bot]
dbb7ec398e chore: lock file maintenance (#259)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-11 03:03:05 +00:00
public-glueops-renovatebot[bot]
82847e5027 chore: lock file maintenance (#258)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-10 21:03:26 +00:00
public-glueops-renovatebot[bot]
4314599427 chore: lock file maintenance (#256)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-10 17:24:20 +00:00
public-glueops-renovatebot[bot]
9108ee8f8f breaking: the dependency sigstore/cosign-installer has been updated to a new major version (v4.0.0), which may include breaking changes. #major (#253)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-10 15:14:40 +00:00
allanice001
1feb3e29e1 chore: prettier 2025-11-10 14:47:39 +00:00
allanice001
0e9ce98624 fix: credentials page bugfix
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 14:47:24 +00:00
allanice001
96aef81959 fix: credentials page bugfix
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 14:46:56 +00:00
allanice001
62232e18f3 chore: prettier 2025-11-10 14:42:30 +00:00
allanice001
515327153c fix: credentials bugfix
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 14:41:54 +00:00
allanice001
5f2e885a8e Merge remote-tracking branch 'origin/main' 2025-11-10 14:41:30 +00:00
allanice001
01b48efba0 fix: credentials bugfix
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 14:41:16 +00:00
public-glueops-renovatebot[bot]
1c87566c5b chore: lock file maintenance (#255)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-10 09:12:44 +00:00
public-glueops-renovatebot[bot]
ad00a3c45d chore(fallback): update axllent/mailpit (#252)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-10 00:17:33 +00:00
public-glueops-renovatebot[bot]
158fdce780 chore(pin): update @types/react to #patch (#251)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-10 00:17:24 +00:00
allanice001
c4fd344364 fix: prettier
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 00:15:34 +00:00
allanice001
953e724ba0 fix: types fixed - credentials page
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 00:14:49 +00:00
allanice001
256acfd686 fix: types fixed - credentials
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 00:13:53 +00:00
allanice001
1dd0a39aad fix: types fixed - credentials
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 00:05:54 +00:00
allanice001
7ef0605c2b Merge remote-tracking branch 'origin/main' 2025-11-10 00:02:35 +00:00
allanice001
8a92727b88 fix: types fixed - credentials
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-10 00:02:21 +00:00
public-glueops-renovatebot[bot]
1f9920a04c chore: lock file maintenance (#248)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-09 22:37:46 +00:00
allanice001
5fd96ec40f fix: types fixed
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-09 22:01:44 +00:00
allanice001
bc72df3c9a feat: add credentials management
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-09 21:46:31 +00:00
allanice001
56ea963b47 feat: add version info
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-08 13:08:19 +00:00
allanice001
c9d5080d50 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	ui/yarn.lock
2025-11-08 11:03:44 +00:00
allanice001
4adad29c4f fix: update dependencies
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-08 11:03:27 +00:00
public-glueops-renovatebot[bot]
13c9ba0ec0 chore: lock file maintenance (#243)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-08 11:03:12 +00:00
public-glueops-renovatebot[bot]
e80d818e86 chore(fallback): update golang (#244)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-08 11:02:52 +00:00
allanice001
f1533eb325 fix: exclude terraform
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-08 11:01:28 +00:00
allanice001
334df457ce feat: complete node pool api, sdk and ui
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-08 10:22:45 +00:00
Alanis
c478a8d10f fix: ui dependencies update
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-06 13:04:29 +00:00
public-glueops-renovatebot[bot]
3463b2cb33 chore: lock file maintenance (#241)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-06 08:34:50 +00:00
allanice001
325e162d39 fix: add DockerIgnore to exclude terraform & sdk Directories, build process should reduce the container image size 2025-11-06 08:18:55 +00:00
allanice001
30e91bfd88 fix: add DockerIgnore to exclude terraform Directories 2025-11-06 08:17:05 +00:00
allanice001
12f2c5e1c5 feat: dynamically set swagger host, and some ui improvements on ssh page 2025-11-06 05:52:06 +00:00
public-glueops-renovatebot[bot]
7dc7d1a1f1 chore(pindigest): update golang to d2ede9f #patch (#239)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-06 05:16:38 +00:00
allanice001
6a16eccce5 fix: relax csp 2025-11-06 05:07:53 +00:00
allanice001
adb3c01382 Merge remote-tracking branch 'origin/main' 2025-11-06 04:43:36 +00:00
allanice001
35b42c6b19 fix: go upgrades didnt patch Dockerfile 2025-11-06 04:43:28 +00:00
public-glueops-renovatebot[bot]
ac12c48c27 chore(pindigest): update postgres to 00bc866 #patch (#237)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-06 04:38:07 +00:00
allanice001
04fc75a699 fix: go mod upgrades 2025-11-06 04:37:14 +00:00
allanice001
f4c41cfed7 Merge branch 'main' of github.com:GlueOps/autoglue
# Conflicts:
#	go.mod
#	go.sum
2025-11-06 04:31:49 +00:00
public-glueops-renovatebot[bot]
d7a87c3add feat: update github.com/swaggo/swag to v1.16.6 #minor (#236)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-06 04:23:21 +00:00
public-glueops-renovatebot[bot]
6b824769ba feat: update github.com/swaggo/swag to v1.9.0 #minor (#235)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-06 04:19:50 +00:00
613 changed files with 46538 additions and 57161 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
terraform
terraform-provider-autoglue
sdk

View File

@@ -33,13 +33,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: 'v2.2.4'
@@ -63,7 +63,7 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |

4
.gitignore vendored
View File

@@ -137,4 +137,6 @@ notes.txt
.terraform
.terraform.lock*
terraform.tfstate*
terraform.tfstate*
ui/src/sdk

100
.semgrep.yml Normal file
View File

@@ -0,0 +1,100 @@
# AutoGlue Semgrep configuration
# Use with: opengrep scan --config .semgrep.yml .
rules:
#
# 1. Suppress known benign “direct write to ResponseWriter” warnings
#
- id: autoglue.ignore.direct-write-static
message: Ignore direct writes for static or binary responses
languages: [go]
severity: INFO
metadata:
category: suppression
project: autoglue
patterns:
- pattern: |
_, _ = $W.Write($DATA)
pattern-inside: |
func $F($X...) {
...
}
paths:
include:
- internal/api/utils.go
- internal/handlers/ssh_keys.go
#
# 2. Enforce Allowed Origins checking in writePostMessageHTML
#
- id: autoglue.auth.require-origin-validation
message: >
writePostMessageHTML must validate `origin` against known allowed origins
to prevent token exfiltration via crafted state/redirect parameters.
languages: [go]
severity: ERROR
metadata:
category: security
project: autoglue
# Look for the JS snippet inside the Go string literal using a regex.
# This is NOT Go code, so we must use pattern-regex, not pattern.
pattern-regex: |
window\.opener\.postMessage\(\{ type: 'autoglue:auth', payload: data \}, .*?\);
paths:
include:
- internal/handlers/auth.go
#
# 3. Require httpOnly+Secure cookies for JWT cookies
#
- id: autoglue.cookies.ensure-secure-jwt
message: >
JWT cookies must always have a Secure field (true in prod, false only for localhost dev).
languages: [go]
severity: WARNING
metadata:
category: security
project: autoglue
patterns:
# 1) Find any SetCookie for ag_jwt
- pattern: |
http.SetCookie($W, &http.Cookie{
Name: "ag_jwt",
...
})
# 2) BUT ignore cases where the Secure field is present
- pattern-not: |
http.SetCookie($W, &http.Cookie{
Name: "ag_jwt",
Secure: $SECURE,
...
})
paths:
include:
- internal/handlers/auth.go
#
# 4. Ban path.Clean for user-controlled paths
#
- id: autoglue.filesystem.no-path-clean
message: Use securejoin instead of path.Clean() for file paths.
languages: [go]
severity: WARNING
metadata:
category: security
project: autoglue
pattern: |
path.Clean($X)
paths:
include:
- internal/web/static.go

View File

@@ -1,7 +1,7 @@
#################################
# Builder: Go + Node in one
#################################
FROM golang:1.25.3-alpine@sha256:aee43c3ccbf24fdffb7295693b6e33b21e01baec1b2a55acc351fde345e9ec34 AS builder
FROM golang:1.25.5-alpine@sha256:26111811bc967321e7b6f852e914d14bede324cd1accb7f81811929a6a57fea9 AS builder
RUN apk add --no-cache \
bash git ca-certificates tzdata \
@@ -15,12 +15,16 @@ RUN npm i -g yarn pnpm
WORKDIR /src
COPY . .
RUN make clean && make swagger && make -j3 sdk-all && make ui && make build
RUN make clean
RUN make swagger
RUN make sdk-ts-ui
RUN make ui
RUN make build
#################################
# Runtime
#################################
FROM alpine:3.22@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412
FROM alpine:3.23@sha256:51183f2cfa6320055da30872f211093f9ff1d3cf06f39a0bdb212314c5dc7375
RUN apk add --no-cache ca-certificates tzdata postgresql17-client \
&& addgroup -S app && adduser -S app -G app

View File

@@ -23,13 +23,14 @@ MODULE_PATH ?= $(GIT_HOST)/$(GIT_USER)/$(BIN)
# SDK / module settings (Go)
SDK_REPO ?= $(BIN)-sdk-go # repo name used for module path
SDK_OUTDIR ?= sdk/go # output directory (inside repo)
SDK_OUTDIR ?= ../autoglue-sdk-go # output directory (inside repo)
SDK_PKG ?= ${BIN} # package name inside the SDK
UI_SSG_ROUTES ?= /,/login,/docs,/pricing
# Go versioning (go.mod uses major.minor; youre on 1.25.3)
GO_VERSION ?= 1.25.3
# Go versioning (go.mod uses major.minor; youre on 1.25.4)
GO_VERSION ?= 1.25.4
SWAG_FLAGS ?= --v3.1 --outputTypes json,yaml,go
# SDK / package settings (TypeScript)
SDK_TS_OUTDIR ?= sdk/ts
@@ -70,7 +71,7 @@ export GO_POST_PROCESS_FILE := gofmt -w
.DEFAULT_GOAL := help
# --- version metadata (ldflags) ---
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
VERSION := $(shell git describe --tags --always 2>/dev/null || echo "dev")
COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "none")
DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
BUILT_BY := $(shell whoami)
@@ -98,12 +99,12 @@ SDK_PKG_CLEAN := $(call trim,$(SDK_PKG))
validate-spec check-tags doctor diff-swagger
# --- inputs/outputs for swagger (incremental) ---
DOCS_JSON := docs/swagger.json
DOCS_YAML := docs/swagger.yaml
DOCS_JSON := docs/openapi.json
DOCS_YAML := docs/openapi.yaml
# Prefer git for speed; fall back to find. Exclude UI dir.
#GO_SRCS := $(shell (git ls-files '*.go' ':!$(UI_DIR)/**' 2>/dev/null || find . -name '*.go' -not -path './$(UI_DIR)/*' -type f))
GO_SRCS := $(shell ( \
git ls-files '*.go' ':!$(UI_DIR)/**' ':!docs/**' ':!sdk/**' 2>/dev/null \
git ls-files '*.go' ':!$(UI_DIR)/**' ':!docs/**' ':!sdk/**' ':!terraform-provider-autoglue/**' 2>/dev/null \
|| find . -name '*.go' -not -path './$(UI_DIR)/*' -not -path './docs/*' -type f \
))
@@ -111,11 +112,14 @@ GO_SRCS := $(shell ( \
$(DOCS_JSON) $(DOCS_YAML): $(GO_SRCS)
@echo ">> Generating Swagger docs..."
@if ! command -v swag >/dev/null 2>&1; then \
echo "Installing swag/v2 CLI @v2.0.0-rc4..."; \
$(GOINSTALL) github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc4; \
echo "Installing swag/v2 CLI @latest..."; \
$(GOINSTALL) github.com/swaggo/swag/v2/cmd/swag@latest; \
fi
@rm -rf docs/swagger.* docs/docs.go
@swag init -g $(MAIN) -o docs
@rm -rf docs/openapi.* docs/docs.go
@swag fmt --exclude main.go -d .
@swag init $(SWAG_FLAGS) -g $(MAIN) -o docs
@mv docs/swagger.json $(DOCS_JSON)
@mv docs/swagger.yaml $(DOCS_YAML)
# --- spec validation + tag guard ---
validate-spec: $(DOCS_JSON) ## Validate docs/swagger.json and pin the core OpenAPI Generator version
@@ -200,6 +204,7 @@ swagger: $(DOCS_JSON) ## Generate Swagger docs if stale
# --- build ---
build: prepare ui swagger sdk-all ## Build everything: Go hygiene, UI, Swagger, SDKs, then Go binary
@echo ">> Building Go binary: $(BIN)"
@$(GOCMD) get github.com/swaggo/swag/v2@v2.0.0-rc4
@$(GOCMD) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) $(MAIN)
# Handy: print resolved version metadata
@@ -247,7 +252,6 @@ TS_PROPS := -p npmName=$(SDK_TS_NPM_NAME) -p npmVersion=$(SDK_TS_NPM_VER) $
# --- sdk generation (Go) ---
sdk-go: $(DOCS_JSON) validate-spec check-tags ## Generate Go SDK + tidy module
@echo ">> Generating Go SDK (module $(GIT_HOST_CLEAN)/$(GIT_USER_CLEAN)/$(SDK_REPO_CLEAN), Go $(GO_VERSION))..."
@rm -rf "$(SDK_OUTDIR_CLEAN)"; mkdir -p "$(SDK_OUTDIR_CLEAN)"
@$(call OGC_GENERATE,go,$(SDK_OUTDIR_CLEAN),--additional-properties=packageName=$(SDK_PKG_CLEAN) $(OAG_GIT_PROPS))
@cd "$(SDK_OUTDIR_CLEAN)"; \
$(GOCMD) mod edit -go=$(GO_VERSION); \
@@ -311,6 +315,9 @@ doctor: ## Print environment diagnostics (shell, versions, generator availabilit
$(OGC_BIN) version || true; \
}
fetch-pgweb: ## Fetch PGWeb Binaries for embedding
go run ./tools/pgweb_fetch.go
help: ## Show this help
@grep -hE '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

View File

@@ -60,6 +60,7 @@ Create your org (http://localhost:8080/me) - you should be redirected here after
Once you have an org - create a set of api keys for your org:
They will be in the format of:
Example values only; these are not real secrets.
```text
Org Key: org_lnJwmyyWH7JC-JgZo5v3Kw
Org Secret: fqd9yebGMfK6h5HSgWn4sXrwr9xlFbvbIYtNylRElMQ

20
atlas.hcl Normal file
View File

@@ -0,0 +1,20 @@
data "external_schema" "gorm" {
program = [
"go",
"run",
"-mod=mod",
"ariga.io/atlas-provider-gorm",
"load",
"--path", "./internal/models",
"--dialect", "postgres",
]
}
env "gorm" {
src = data.external_schema.gorm.url
dev = "postgres://autoglue:autoglue@localhost:5432/autoglue_dev"
}
env "gorm-src" {
src = data.external_schema.gorm.url
}

51
cmd/db.go Normal file
View File

@@ -0,0 +1,51 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"time"
"github.com/glueops/autoglue/internal/config"
"github.com/spf13/cobra"
)
var dbCmd = &cobra.Command{
Use: "db",
Short: "Database utilities",
}
var dbPsqlCmd = &cobra.Command{
Use: "psql",
Short: "Open a psql session to the app database",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
if cfg.DbURL == "" {
return errors.New("database.url is empty")
}
psql := "psql"
if runtime.GOOS == "windows" {
psql = "psql.exe"
}
ctx, cancel := context.WithTimeout(context.Background(), 72*time.Hour)
defer cancel()
psqlCmd := exec.CommandContext(ctx, psql, cfg.DbURL)
psqlCmd.Stdin, psqlCmd.Stdout, psqlCmd.Stderr = os.Stdin, os.Stdout, os.Stderr
fmt.Println("Launching psql…")
return psqlCmd.Run()
},
}
func init() {
dbCmd.AddCommand(dbPsqlCmd)
rootCmd.AddCommand(dbCmd)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
@@ -38,6 +39,8 @@ var serveCmd = &cobra.Command{
log.Fatalf("failed to init background jobs: %v", err)
}
rt.DB.Where("status IN ?", []string{"scheduled", "queued", "pending"}).Delete(&models.Job{})
// Start workers in background ONCE
go func() {
if err := jobs.Start(); err != nil {
@@ -50,7 +53,7 @@ var serveCmd = &cobra.Command{
{
// schedule next 03:30 local time
next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 30*time.Minute)
_, _ = jobs.Enqueue(
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"archer_cleanup",
@@ -58,10 +61,13 @@ var serveCmd = &cobra.Command{
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
if err != nil {
log.Fatalf("failed to enqueue archer cleanup job: %v", err)
}
// schedule next 03:45 local time
next2 := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 45*time.Minute)
_, _ = jobs.Enqueue(
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"tokens_cleanup",
@@ -69,46 +75,105 @@ var serveCmd = &cobra.Command{
archer.WithScheduleTime(next2),
archer.WithMaxRetries(1),
)
}
// Periodic scheduler
schedCtx, schedCancel := context.WithCancel(context.Background())
defer schedCancel()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
go func() {
for {
select {
case <-ticker.C:
_, err := jobs.Enqueue(
context.Background(),
uuid.NewString(),
"bootstrap_bastion",
bg.BastionBootstrapArgs{},
archer.WithMaxRetries(3),
// while debugging, avoid extra schedule delay:
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
)
if err != nil {
log.Printf("failed to enqueue bootstrap_bastion: %v", err)
}
/*
_, _ = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"tokens_cleanup",
bg.TokensCleanupArgs{},
archer.WithMaxRetries(3),
archer.WithScheduleTime(time.Now().Add(10*time.Second)),
)
*/
case <-schedCtx.Done():
return
}
if err != nil {
log.Fatalf("failed to enqueue token cleanup job: %v", err)
}
}()
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"db_backup_s3",
bg.DbBackupArgs{IntervalS: 3600},
archer.WithMaxRetries(1),
archer.WithScheduleTime(time.Now().Add(1*time.Hour)),
)
if err != nil {
log.Fatalf("failed to enqueue backup jobs: %v", err)
}
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"dns_reconcile",
bg.DNSReconcileArgs{MaxDomains: 25, MaxRecords: 100, IntervalS: 10},
archer.WithScheduleTime(time.Now().Add(5*time.Second)),
archer.WithMaxRetries(1),
)
if err != nil {
log.Fatalf("failed to enqueue dns reconcile: %v", err)
}
_, err := jobs.Enqueue(
context.Background(),
uuid.NewString(),
"bootstrap_bastion",
bg.BastionBootstrapArgs{IntervalS: 10},
archer.WithMaxRetries(3),
// while debugging, avoid extra schedule delay:
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
)
if err != nil {
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(),
"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(),
"org_key_sweeper",
bg.OrgKeySweeperArgs{
IntervalS: 3600,
RetentionDays: 10,
},
archer.WithMaxRetries(1),
archer.WithScheduleTime(time.Now()),
)
if err != nil {
log.Printf("failed to enqueue org_key_sweeper: %v", err)
}
}
_ = auth.Refresh(rt.DB, rt.Cfg.JWTPrivateEncKey)
go func() {
@@ -119,7 +184,26 @@ var serveCmd = &cobra.Command{
}
}()
r := api.NewRouter(rt.DB, jobs)
r := api.NewRouter(rt.DB, jobs, nil)
if cfg.DBStudioEnabled {
dbURL := cfg.DbURLRO
if dbURL == "" {
dbURL = cfg.DbURL
}
studio, err := api.MountDbStudio(
dbURL,
"db-studio",
false,
)
if err != nil {
log.Fatalf("failed to init db studio: %v", err)
} else {
r = api.NewRouter(rt.DB, jobs, studio)
log.Printf("pgweb mounted at /db-studio/")
}
}
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)

View File

@@ -1,18 +1,4 @@
services:
autoglue:
# image: ghcr.io/glueops/autoglue:latest
build: .
ports:
- 8080:8080
expose:
- 8080
env_file: .env
environment:
AUTOGLUE_DATABASE_DSN: postgres://$DB_USER:$DB_PASSWORD@postgres:5432/$DB_NAME
AUTOGLUE_BIND_ADDRESS: 0.0.0.0
depends_on:
- postgres
postgres:
build:
context: postgres
@@ -28,21 +14,8 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
pgweb:
image: sosedoff/pgweb@sha256:8f1ed22e10c9da0912169b98b62ddc54930dc39a5ae07b0f1354d2a93d44c6ed
restart: always
ports:
- "8081:8081"
links:
- postgres:postgres
env_file: .env
environment:
PGWEB_DATABASE_URL: postgres://$DB_USER:$DB_PASSWORD@postgres:5432/$DB_NAME
depends_on:
- postgres
mailpit:
image: axllent/mailpit@sha256:6abc8e633df15eaf785cfcf38bae48e66f64beecdc03121e249d0f9ec15f0707
image: axllent/mailpit@sha256:e22dce5b36f93c77082e204a3942fb6b283b7896e057458400a4c88344c3df68
restart: always
ports:
- "1025:1025"

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,8 @@ package docs
import _ "embed"
//go:embed swagger.json
//go:embed openapi.json
var SwaggerJSON []byte
//go:embed swagger.yaml
//go:embed openapi.yaml
var SwaggerYAML []byte

13
docs/openapi.json Normal file

File diff suppressed because one or more lines are too long

7198
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

94
go.mod
View File

@@ -1,24 +1,32 @@
module github.com/glueops/autoglue
go 1.25.3
go 1.25.4
require (
github.com/alexedwards/argon2id v1.0.0
github.com/coreos/go-oidc/v3 v3.16.0
github.com/dyaksa/archer v1.1.3
github.com/aws/aws-sdk-go-v2 v1.41.0
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.94.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
github.com/gin-gonic/gin v1.11.0
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.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
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.10.1
github.com/sosedoff/pgweb v0.17.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/swaggo/http-swagger/v2 v2.0.2
github.com/swaggo/swag/v2 v2.0.0-rc4
golang.org/x/crypto v0.43.0
golang.org/x/oauth2 v0.32.0
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
@@ -27,46 +35,100 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
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.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
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
github.com/fsnotify/fsnotify v1.9.0 // 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
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jessevdk/go-flags v1.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/sv-tools/openapi v0.2.1 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
github.com/swaggo/swag v1.8.1 // indirect
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
)

235
go.sum
View File

@@ -1,27 +1,102 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs=
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 h1:VauE2GcJNZFun2Och6tIT2zJZK1v6jxALQDA9BIji/E=
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
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/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.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/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=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
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.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.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=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dyaksa/archer v1.1.3 h1:jfe51tSNzzscFpu+Vilm4SKb0Lnv6FR1yaGspjab4x4=
github.com/dyaksa/archer v1.1.3/go.mod h1:IYSp67u14JHTNuvvy6gG1eaX2TPywXvfk1FiyZwVEK4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a h1:saTgr5tMLFnmy/yg3qDTft4rE5DY2uJ/cCxCe3q0XTU=
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a/go.mod h1:Bw9BbhOJVNR+t0jCqx2GC6zv0TGBsShs56Y3gfSCvl0=
github.com/dyaksa/archer v1.1.5 h1:e9ZrR8PnMYEax19Fd+QbvQqDL5cbi78luMEtaSAIHxU=
github.com/dyaksa/archer v1.1.5/go.mod h1:IYSp67u14JHTNuvvy6gG1eaX2TPywXvfk1FiyZwVEK4=
github.com/fergusstrange/embedded-postgres v1.33.0 h1:ka8vmRpm4IDsES7NPXQ/NThAp1fc/f+crcXYjCW7wK0=
github.com/fergusstrange/embedded-postgres v1.33.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
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.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/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=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -43,6 +118,19 @@ github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
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.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
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=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -50,6 +138,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@@ -57,8 +147,9 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -71,16 +162,22 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -89,6 +186,9 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -99,37 +199,63 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE=
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sosedoff/pgweb v0.17.0 h1:2WSPajNyqStS5oulvfdKIBaWQTy/qNBREBp51h4yiLU=
github.com/sosedoff/pgweb v0.17.0/go.mod h1:fY82HStJ/n/JCvzHsJmVT6BDYiWxSQG6CvqH+biuUbM=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -141,6 +267,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -152,46 +279,62 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/sv-tools/openapi v0.2.1 h1:ES1tMQMJFGibWndMagvdoo34T1Vllxr1Nlm5wz6b1aA=
github.com/sv-tools/openapi v0.2.1/go.mod h1:k5VuZamTw1HuiS9p2Wl5YIDWzYnHG6/FgPOSFXLAhGg=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI=
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
github.com/swaggo/swag/v2 v2.0.0-rc4 h1:SZ8cK68gcV6cslwrJMIOqPkJELRwq4gmjvk77MrvHvY=
github.com/swaggo/swag/v2 v2.0.0-rc4/go.mod h1:Ow7Y8gF16BTCDn8YxZbyKn8FkMLRUHekv1kROJZpbvE=
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u242MzDP6D0B5vGC+wxm5WRY+alQZy+dJk3bFI=
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/go.mod h1:a06d/M1pxWi51qiSrfGMHaEydtuXT06nha8N2aNQuXk=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -199,30 +342,32 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -237,8 +382,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=

View File

@@ -49,6 +49,14 @@ func AuthMiddleware(db *gorm.DB, requireOrg bool) func(http.Handler) http.Handle
} else if appKey := r.Header.Get("X-APP-KEY"); appKey != "" {
secret := r.Header.Get("X-APP-SECRET")
user = auth.ValidateAppKeyPair(appKey, secret, db)
} else if c, err := r.Cookie("ag_jwt"); err == nil {
tok := strings.TrimSpace(c.Value)
if strings.HasPrefix(strings.ToLower(tok), "bearer ") {
tok = tok[7:]
}
if tok != "" {
user = auth.ValidateJWT(tok, db)
}
}
if user == nil {

View File

@@ -0,0 +1,37 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountAdminRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authUser func(http.Handler) http.Handler) {
r.Route("/admin", func(admin chi.Router) {
admin.Route("/archer", func(archer chi.Router) {
archer.Use(authUser)
archer.Use(httpmiddleware.RequirePlatformAdmin())
archer.Get("/jobs", handlers.AdminListArcherJobs(db))
archer.Post("/jobs", handlers.AdminEnqueueArcherJob(db, jobs))
archer.Post("/jobs/{id}/retry", handlers.AdminRetryArcherJob(db))
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

@@ -0,0 +1,20 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountAnnotationRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/annotations", func(a chi.Router) {
a.Use(authOrg)
a.Get("/", handlers.ListAnnotations(db))
a.Post("/", handlers.CreateAnnotation(db))
a.Get("/{id}", handlers.GetAnnotation(db))
a.Patch("/{id}", handlers.UpdateAnnotation(db))
a.Delete("/{id}", handlers.DeleteAnnotation(db))
})
}

View File

@@ -0,0 +1,39 @@
package api
import (
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
r.Route("/api", func(api chi.Router) {
api.Route("/v1", func(v1 chi.Router) {
authUser := httpmiddleware.AuthMiddleware(db, false)
authOrg := httpmiddleware.AuthMiddleware(db, true)
// shared basics
mountMetaRoutes(v1)
mountAuthRoutes(v1, db)
// admin
mountAdminRoutes(v1, db, jobs, authUser)
// user/org scoped
mountMeRoutes(v1, db, authUser)
mountOrgRoutes(v1, db, authUser, authOrg)
mountCredentialRoutes(v1, db, authOrg)
mountSSHRoutes(v1, db, authOrg)
mountServerRoutes(v1, db, authOrg)
mountTaintRoutes(v1, db, authOrg)
mountLabelRoutes(v1, db, authOrg)
mountAnnotationRoutes(v1, db, authOrg)
mountNodePoolRoutes(v1, db, authOrg)
mountDNSRoutes(v1, db, authOrg)
mountLoadBalancerRoutes(v1, db, authOrg)
mountClusterRoutes(v1, db, jobs, authOrg)
})
})
}

View File

@@ -0,0 +1,16 @@
package api
import (
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountAuthRoutes(r chi.Router, db *gorm.DB) {
r.Route("/auth", func(a chi.Router) {
a.Post("/{provider}/start", handlers.AuthStart(db))
a.Get("/{provider}/callback", handlers.AuthCallback(db))
a.Post("/refresh", handlers.Refresh(db))
a.Post("/logout", handlers.Logout(db))
})
}

View File

@@ -0,0 +1,46 @@
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, jobs *bg.Jobs, authOrg func(http.Handler) http.Handler) {
r.Route("/clusters", func(c chi.Router) {
c.Use(authOrg)
c.Get("/", handlers.ListClusters(db))
c.Post("/", handlers.CreateCluster(db))
c.Get("/{clusterID}", handlers.GetCluster(db))
c.Patch("/{clusterID}", handlers.UpdateCluster(db))
c.Delete("/{clusterID}", handlers.DeleteCluster(db))
c.Post("/{clusterID}/captain-domain", handlers.AttachCaptainDomain(db))
c.Delete("/{clusterID}/captain-domain", handlers.DetachCaptainDomain(db))
c.Post("/{clusterID}/control-plane-record-set", handlers.AttachControlPlaneRecordSet(db))
c.Delete("/{clusterID}/control-plane-record-set", handlers.DetachControlPlaneRecordSet(db))
c.Post("/{clusterID}/apps-load-balancer", handlers.AttachAppsLoadBalancer(db))
c.Delete("/{clusterID}/apps-load-balancer", handlers.DetachAppsLoadBalancer(db))
c.Post("/{clusterID}/glueops-load-balancer", handlers.AttachGlueOpsLoadBalancer(db))
c.Delete("/{clusterID}/glueops-load-balancer", handlers.DetachGlueOpsLoadBalancer(db))
c.Post("/{clusterID}/bastion", handlers.AttachBastionServer(db))
c.Delete("/{clusterID}/bastion", handlers.DetachBastionServer(db))
c.Post("/{clusterID}/kubeconfig", handlers.SetClusterKubeconfig(db))
c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(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

@@ -0,0 +1,21 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountCredentialRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/credentials", func(c chi.Router) {
c.Use(authOrg)
c.Get("/", handlers.ListCredentials(db))
c.Post("/", handlers.CreateCredential(db))
c.Get("/{id}", handlers.GetCredential(db))
c.Patch("/{id}", handlers.UpdateCredential(db))
c.Delete("/{id}", handlers.DeleteCredential(db))
c.Post("/{id}/reveal", handlers.RevealCredential(db))
})
}

View File

@@ -0,0 +1,53 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
pgapi "github.com/sosedoff/pgweb/pkg/api"
pgclient "github.com/sosedoff/pgweb/pkg/client"
pgcmd "github.com/sosedoff/pgweb/pkg/command"
)
func MountDbStudio(dbURL, prefix string, readonly bool) (http.Handler, error) {
// Normalize prefix for pgweb:
// - no leading slash
// - always trailing slash if not empty
prefix = strings.Trim(prefix, "/")
if prefix != "" {
prefix = prefix + "/"
}
pgcmd.Opts = pgcmd.Options{
URL: dbURL,
Prefix: prefix, // e.g. "db-studio/"
ReadOnly: readonly,
Sessions: false,
LockSession: true,
SkipOpen: true,
}
cli, err := pgclient.NewFromUrl(dbURL, nil)
if err != nil {
return nil, err
}
if readonly {
_ = cli.SetReadOnlyMode()
}
if err := cli.Test(); err != nil {
return nil, err
}
pgapi.DbClient = cli
gin.SetMode(gin.ReleaseMode)
g := gin.New()
g.Use(gin.Recovery())
pgapi.SetupRoutes(g)
pgapi.SetupMetrics(g)
return g, nil
}

View File

@@ -0,0 +1,26 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountDNSRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/dns", func(d chi.Router) {
d.Use(authOrg)
d.Get("/domains", handlers.ListDomains(db))
d.Post("/domains", handlers.CreateDomain(db))
d.Get("/domains/{id}", handlers.GetDomain(db))
d.Patch("/domains/{id}", handlers.UpdateDomain(db))
d.Delete("/domains/{id}", handlers.DeleteDomain(db))
d.Get("/domains/{domain_id}/records", handlers.ListRecordSets(db))
d.Post("/domains/{domain_id}/records", handlers.CreateRecordSet(db))
d.Patch("/records/{id}", handlers.UpdateRecordSet(db))
d.Delete("/records/{id}", handlers.DeleteRecordSet(db))
})
}

View File

@@ -0,0 +1,20 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountLabelRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/labels", func(l chi.Router) {
l.Use(authOrg)
l.Get("/", handlers.ListLabels(db))
l.Post("/", handlers.CreateLabel(db))
l.Get("/{id}", handlers.GetLabel(db))
l.Patch("/{id}", handlers.UpdateLabel(db))
l.Delete("/{id}", handlers.DeleteLabel(db))
})
}

View File

@@ -0,0 +1,20 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountLoadBalancerRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/load-balancers", func(l chi.Router) {
l.Use(authOrg)
l.Get("/", handlers.ListLoadBalancers(db))
l.Post("/", handlers.CreateLoadBalancer(db))
l.Get("/{id}", handlers.GetLoadBalancer(db))
l.Patch("/{id}", handlers.UpdateLoadBalancer(db))
l.Delete("/{id}", handlers.DeleteLoadBalancer(db))
})
}

View File

@@ -0,0 +1,22 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountMeRoutes(r chi.Router, db *gorm.DB, authUser func(http.Handler) http.Handler) {
r.Route("/me", func(me chi.Router) {
me.Use(authUser)
me.Get("/", handlers.GetMe(db))
me.Patch("/", handlers.UpdateMe(db))
me.Get("/api-keys", handlers.ListUserAPIKeys(db))
me.Post("/api-keys", handlers.CreateUserAPIKey(db))
me.Delete("/api-keys/{id}", handlers.DeleteUserAPIKey(db))
})
}

View File

@@ -0,0 +1,13 @@
package api
import (
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
)
func mountMetaRoutes(r chi.Router) {
// Versioned JWKS for swagger
r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
r.Get("/healthz", handlers.HealthCheck)
r.Get("/version", handlers.Version)
}

View File

@@ -0,0 +1,40 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountNodePoolRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/node-pools", func(n chi.Router) {
n.Use(authOrg)
n.Get("/", handlers.ListNodePools(db))
n.Post("/", handlers.CreateNodePool(db))
n.Get("/{id}", handlers.GetNodePool(db))
n.Patch("/{id}", handlers.UpdateNodePool(db))
n.Delete("/{id}", handlers.DeleteNodePool(db))
// Servers
n.Get("/{id}/servers", handlers.ListNodePoolServers(db))
n.Post("/{id}/servers", handlers.AttachNodePoolServers(db))
n.Delete("/{id}/servers/{serverId}", handlers.DetachNodePoolServer(db))
// Taints
n.Get("/{id}/taints", handlers.ListNodePoolTaints(db))
n.Post("/{id}/taints", handlers.AttachNodePoolTaints(db))
n.Delete("/{id}/taints/{taintId}", handlers.DetachNodePoolTaint(db))
// Labels
n.Get("/{id}/labels", handlers.ListNodePoolLabels(db))
n.Post("/{id}/labels", handlers.AttachNodePoolLabels(db))
n.Delete("/{id}/labels/{labelId}", handlers.DetachNodePoolLabel(db))
// Annotations
n.Get("/{id}/annotations", handlers.ListNodePoolAnnotations(db))
n.Post("/{id}/annotations", handlers.AttachNodePoolAnnotations(db))
n.Delete("/{id}/annotations/{annotationId}", handlers.DetachNodePoolAnnotation(db))
})
}

View File

@@ -0,0 +1,35 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountOrgRoutes(r chi.Router, db *gorm.DB, authUser, authOrg func(http.Handler) http.Handler) {
r.Route("/orgs", func(o chi.Router) {
o.Use(authUser)
o.Get("/", handlers.ListMyOrgs(db))
o.Post("/", handlers.CreateOrg(db))
o.Group(func(og chi.Router) {
og.Use(authOrg)
og.Get("/{id}", handlers.GetOrg(db))
og.Patch("/{id}", handlers.UpdateOrg(db))
og.Delete("/{id}", handlers.DeleteOrg(db))
// members
og.Get("/{id}/members", handlers.ListMembers(db))
og.Post("/{id}/members", handlers.AddOrUpdateMember(db))
og.Delete("/{id}/members/{user_id}", handlers.RemoveMember(db))
// org-scoped key/secret pair
og.Get("/{id}/api-keys", handlers.ListOrgKeys(db))
og.Post("/{id}/api-keys", handlers.CreateOrgKey(db))
og.Delete("/{id}/api-keys/{key_id}", handlers.DeleteOrgKey(db))
})
})
}

View File

@@ -0,0 +1,24 @@
package api
import (
httpPprof "net/http/pprof"
"github.com/go-chi/chi/v5"
)
func mountPprofRoutes(r chi.Router) {
r.Route("/debug/pprof", func(pr chi.Router) {
pr.Get("/", httpPprof.Index)
pr.Get("/cmdline", httpPprof.Cmdline)
pr.Get("/profile", httpPprof.Profile)
pr.Get("/symbol", httpPprof.Symbol)
pr.Get("/trace", httpPprof.Trace)
pr.Handle("/allocs", httpPprof.Handler("allocs"))
pr.Handle("/block", httpPprof.Handler("block"))
pr.Handle("/goroutine", httpPprof.Handler("goroutine"))
pr.Handle("/heap", httpPprof.Handler("heap"))
pr.Handle("/mutex", httpPprof.Handler("mutex"))
pr.Handle("/threadcreate", httpPprof.Handler("threadcreate"))
})
}

View File

@@ -0,0 +1,21 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountServerRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/servers", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListServers(db))
s.Post("/", handlers.CreateServer(db))
s.Get("/{id}", handlers.GetServer(db))
s.Patch("/{id}", handlers.UpdateServer(db))
s.Delete("/{id}", handlers.DeleteServer(db))
s.Post("/{id}/reset-hostkey", handlers.ResetServerHostKey(db))
})
}

View File

@@ -0,0 +1,20 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountSSHRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/ssh", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListPublicSshKeys(db))
s.Post("/", handlers.CreateSSHKey(db))
s.Get("/{id}", handlers.GetSSHKey(db))
s.Delete("/{id}", handlers.DeleteSSHKey(db))
s.Get("/{id}/download", handlers.DownloadSSHKey(db))
})
}

View File

@@ -0,0 +1,87 @@
package api
import (
"fmt"
"html/template"
"net/http"
"github.com/glueops/autoglue/docs"
"github.com/go-chi/chi/v5"
)
func mountSwaggerRoutes(r chi.Router) {
r.Get("/swagger", RapidDocHandler("/swagger/swagger.yaml"))
r.Get("/swagger/index.html", RapidDocHandler("/swagger/swagger.yaml"))
r.Get("/swagger/openapi.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/json"))
r.Get("/swagger/openapi.yaml", serveSwaggerFromEmbed(docs.SwaggerYAML, "application/x-yaml"))
}
var rapidDocTmpl = template.Must(template.New("redoc").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>AutoGlue API Docs</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8">
<style>
body { margin: 0; padding: 0; }
.redoc-container { height: 100vh; }
</style>
</head>
<body>
<rapi-doc
id="autoglue-docs"
spec-url="{{.SpecURL}}"
render-style="read"
theme="dark"
show-header="false"
persist-auth="true"
allow-advanced-search="true"
schema-description-expanded="true"
allow-schema-description-expand-toggle="false"
allow-spec-file-download="true"
allow-spec-file-load="false"
allow-spec-url-load="false"
allow-try="true"
schema-style="tree"
fetch-credentials="include"
default-api-server="{{.DefaultServer}}"
api-key-name="X-ORG-ID"
api-key-location="header"
api-key-value=""
/>
<script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
const rd = document.getElementById('autoglue-docs');
if (!rd) return;
const storedOrg = localStorage.getItem('autoglue.org');
if (storedOrg) {
rd.setAttribute('api-key-value', storedOrg);
}
}
</script>
</body>
</html>`))
func RapidDocHandler(specURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := r.Host
defaultServer := fmt.Sprintf("%s://%s/api/v1", scheme, host)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := rapidDocTmpl.Execute(w, map[string]string{
"SpecURL": specURL,
"DefaultServer": defaultServer,
}); err != nil {
http.Error(w, "failed to render docs", http.StatusInternalServerError)
return
}
}
}

View File

@@ -0,0 +1,20 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountTaintRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/taints", func(t chi.Router) {
t.Use(authOrg)
t.Get("/", handlers.ListTaints(db))
t.Post("/", handlers.CreateTaint(db))
t.Get("/{id}", handlers.GetTaint(db))
t.Patch("/{id}", handlers.UpdateTaint(db))
t.Delete("/{id}", handlers.DeleteTaint(db))
})
}

View File

@@ -29,14 +29,14 @@ func SecurityHeaders(next http.Handler) http.Handler {
"base-uri 'self'",
"form-action 'self'",
// Vite dev & inline preamble/eval:
"script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173 https://unpkg.com",
// allow dev style + Google Fonts
"style-src 'self' 'unsafe-inline' http://localhost:5173 https://fonts.googleapis.com",
"img-src 'self' data: blob:",
// Google font files
"font-src 'self' data: https://fonts.gstatic.com",
// HMR connections
"connect-src 'self' http://localhost:5173 ws://localhost:5173 ws://localhost:8080",
"connect-src 'self' http://localhost:5173 ws://localhost:5173 ws://localhost:8080 https://api.github.com https://unpkg.com",
"frame-ancestors 'none'",
}, "; "))
} else {
@@ -49,11 +49,11 @@ func SecurityHeaders(next http.Handler) http.Handler {
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"script-src 'self'",
"script-src 'self' 'unsafe-inline' https://unpkg.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self'",
"connect-src 'self' ws://localhost:8080 https://api.github.com https://unpkg.com",
"frame-ancestors 'none'",
}, "; "))
}

View File

@@ -3,11 +3,10 @@ package api
import (
"fmt"
"net/http"
httpPprof "net/http/pprof"
"os"
"strings"
"time"
"github.com/glueops/autoglue/docs"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/config"
@@ -23,11 +22,9 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
httpSwagger "github.com/swaggo/http-swagger/v2"
)
func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
zerolog.TimeFieldFormat = time.RFC3339
l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"})
@@ -41,6 +38,7 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
r.Use(SecurityHeaders)
r.Use(requestBodyLimit(10 << 20))
r.Use(httprate.LimitByIP(100, 1*time.Minute))
r.Use(middleware.StripSlashes)
allowed := getAllowedOrigins()
r.Use(cors.Handler(cors.Options{
@@ -59,145 +57,44 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
MaxAge: 600,
}))
r.Use(middleware.AllowContentType("application/json"))
r.Use(middleware.Maybe(
middleware.AllowContentType("application/json"),
func(r *http.Request) bool {
// return true => run AllowContentType
// return false => skip AllowContentType for this request
return !strings.HasPrefix(r.URL.Path, "/db-studio")
}))
//r.Use(middleware.AllowContentType("application/json"))
// Unversioned, non-auth endpoints
r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
r.Route("/api", func(api chi.Router) {
api.Route("/v1", func(v1 chi.Router) {
// Versioned API
mountAPIRoutes(r, db, jobs)
// Optional DB studio
if studio != nil {
r.Group(func(gr chi.Router) {
authUser := httpmiddleware.AuthMiddleware(db, false)
authOrg := httpmiddleware.AuthMiddleware(db, true)
// Also serving a versioned JWKS for swagger, which uses BasePath
v1.Get("/.well-known/jwks.json", handlers.JWKSHandler)
v1.Get("/healthz", handlers.HealthCheck)
v1.Route("/auth", func(a chi.Router) {
a.Post("/{provider}/start", handlers.AuthStart(db))
a.Get("/{provider}/callback", handlers.AuthCallback(db))
a.Post("/refresh", handlers.Refresh(db))
a.Post("/logout", handlers.Logout(db))
})
v1.Route("/admin/archer", func(a chi.Router) {
a.Use(authUser)
a.Use(httpmiddleware.RequirePlatformAdmin())
a.Get("/jobs", handlers.AdminListArcherJobs(db))
a.Post("/jobs", handlers.AdminEnqueueArcherJob(db, jobs))
a.Post("/jobs/{id}/retry", handlers.AdminRetryArcherJob(db))
a.Post("/jobs/{id}/cancel", handlers.AdminCancelArcherJob(db))
a.Get("/queues", handlers.AdminListArcherQueues(db))
})
v1.Route("/me", func(me chi.Router) {
me.Use(authUser)
me.Get("/", handlers.GetMe(db))
me.Patch("/", handlers.UpdateMe(db))
me.Get("/api-keys", handlers.ListUserAPIKeys(db))
me.Post("/api-keys", handlers.CreateUserAPIKey(db))
me.Delete("/api-keys/{id}", handlers.DeleteUserAPIKey(db))
})
v1.Route("/orgs", func(o chi.Router) {
o.Use(authUser)
o.Get("/", handlers.ListMyOrgs(db))
o.Post("/", handlers.CreateOrg(db))
o.Group(func(og chi.Router) {
og.Use(authOrg)
og.Get("/{id}", handlers.GetOrg(db))
og.Patch("/{id}", handlers.UpdateOrg(db))
og.Delete("/{id}", handlers.DeleteOrg(db))
// members
og.Get("/{id}/members", handlers.ListMembers(db))
og.Post("/{id}/members", handlers.AddOrUpdateMember(db))
og.Delete("/{id}/members/{user_id}", handlers.RemoveMember(db))
// org-scoped key/secret pair
og.Get("/{id}/api-keys", handlers.ListOrgKeys(db))
og.Post("/{id}/api-keys", handlers.CreateOrgKey(db))
og.Delete("/{id}/api-keys/{key_id}", handlers.DeleteOrgKey(db))
})
})
v1.Route("/ssh", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListPublicSshKeys(db))
s.Post("/", handlers.CreateSSHKey(db))
s.Get("/{id}", handlers.GetSSHKey(db))
s.Delete("/{id}", handlers.DeleteSSHKey(db))
s.Get("/{id}/download", handlers.DownloadSSHKey(db))
})
v1.Route("/servers", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListServers(db))
s.Post("/", handlers.CreateServer(db))
s.Get("/{id}", handlers.GetServer(db))
s.Patch("/{id}", handlers.UpdateServer(db))
s.Delete("/{id}", handlers.DeleteServer(db))
})
v1.Route("/taints", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListTaints(db))
s.Post("/", handlers.CreateTaint(db))
s.Get("/{id}", handlers.GetTaint(db))
s.Patch("/{id}", handlers.UpdateTaint(db))
s.Delete("/{id}", handlers.DeleteTaint(db))
})
v1.Route("/labels", func(l chi.Router) {
l.Use(authOrg)
l.Get("/", handlers.ListLabels(db))
l.Post("/", handlers.CreateLabel(db))
l.Get("/{id}", handlers.GetLabel(db))
l.Patch("/{id}", handlers.UpdateLabel(db))
l.Delete("/{id}", handlers.DeleteLabel(db))
})
v1.Route("/annotations", func(a chi.Router) {
a.Use(authOrg)
a.Get("/", handlers.ListAnnotations(db))
a.Post("/", handlers.CreateAnnotation(db))
a.Get("/{id}", handlers.GetAnnotation(db))
a.Patch("/{id}", handlers.UpdateAnnotation(db))
a.Delete("/{id}", handlers.DeleteAnnotation(db))
})
adminOnly := httpmiddleware.RequirePlatformAdmin()
gr.Use(authUser, adminOnly)
gr.Mount("/db-studio", studio)
})
})
}
// pprof
if config.IsDebug() {
r.Route("/debug/pprof", func(pr chi.Router) {
pr.Get("/", httpPprof.Index)
pr.Get("/cmdline", httpPprof.Cmdline)
pr.Get("/profile", httpPprof.Profile)
pr.Get("/symbol", httpPprof.Symbol)
pr.Get("/trace", httpPprof.Trace)
pr.Handle("/allocs", httpPprof.Handler("allocs"))
pr.Handle("/block", httpPprof.Handler("block"))
pr.Handle("/goroutine", httpPprof.Handler("goroutine"))
pr.Handle("/heap", httpPprof.Handler("heap"))
pr.Handle("/mutex", httpPprof.Handler("mutex"))
pr.Handle("/threadcreate", httpPprof.Handler("threadcreate"))
})
mountPprofRoutes(r)
}
// Swagger
if config.IsSwaggerEnabled() {
r.Get("/swagger/*", httpSwagger.Handler(
httpSwagger.URL("swagger.json"),
))
r.Get("/swagger/swagger.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/json"))
r.Get("/swagger/swagger.yaml", serveSwaggerFromEmbed(docs.SwaggerYAML, "application/x-yaml"))
mountSwaggerRoutes(r)
}
// UI dev/prod
if config.IsUIDev() {
fmt.Println("Running in development mode")
// Dev: isolate proxy from chi middlewares so WS upgrade can hijack.
proxy, err := web.DevProxy("http://localhost:5173")
if err != nil {
log.Error().Err(err).Msg("dev proxy init failed")
@@ -205,14 +102,13 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
}
mux := http.NewServeMux()
// Send API/Swagger/pprof to chi
mux.Handle("/api/", r)
mux.Handle("/api", r)
mux.Handle("/swagger", r)
mux.Handle("/swagger/", r)
mux.Handle("/db-studio/", r)
mux.Handle("/debug/pprof/", r)
// Everything else (/, /brand-preview, assets) → proxy (no middlewares)
mux.Handle("/", proxy)
return mux
} else {
fmt.Println("Running in production mode")

View File

@@ -40,6 +40,7 @@ func serveSwaggerFromEmbed(data []byte, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
// nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter
_, _ = w.Write(data)
}
}

View File

@@ -38,7 +38,17 @@ func NewRuntime() *Runtime {
&models.Taint{},
&models.Label{},
&models.Annotation{},
&models.NodePool{},
&models.Credential{},
&models.Domain{},
&models.RecordSet{},
&models.LoadBalancer{},
&models.Cluster{},
&models.Action{},
&models.Cluster{},
&models.ClusterRun{},
)
if err != nil {
log.Fatalf("Error initializing database: %v", err)
}

264
internal/bg/backup_s3.go Normal file
View File

@@ -0,0 +1,264 @@
package bg
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/config"
"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 DbBackupArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type s3Scope struct {
Service string `json:"service"`
Region string `json:"region"`
}
type encAWS struct {
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
}
func DbBackupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := DbBackupArgs{IntervalS: 3600}
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 3600
}
if err := DbBackup(ctx, db); err != nil {
return nil, err
}
queue := j.QueueName
if strings.TrimSpace(queue) == "" {
queue = "db_backup_s3"
}
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
payload := DbBackupArgs{}
opts := []archer.FnOptions{
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
}
if _, err := jobs.Enqueue(ctx, uuid.NewString(), queue, payload, opts...); err != nil {
log.Error().Err(err).Str("queue", queue).Time("next", next).Msg("failed to enqueue next db backup")
} else {
log.Info().Str("queue", queue).Time("next", next).Msg("scheduled next db backup")
}
return nil, nil
}
}
func DbBackup(ctx context.Context, db *gorm.DB) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cred, sc, err := loadS3Credential(ctx, db)
if err != nil {
return fmt.Errorf("load credential: %w", err)
}
ak, sk, err := decryptAwsAccessKeys(ctx, db, cred)
if err != nil {
return fmt.Errorf("decrypt aws keys: %w", err)
}
region := sc.Region
if strings.TrimSpace(region) == "" {
region = cred.Region
if strings.TrimSpace(region) == "" {
region = "us-west-1"
}
}
bucket := strings.ToLower(fmt.Sprintf("%s-autoglue-backups-%s", cred.OrganizationID, region))
s3cli, err := makeS3Client(ctx, ak, sk, region)
if err != nil {
return err
}
if err := ensureBucket(ctx, s3cli, bucket, region); err != nil {
return fmt.Errorf("ensure bucket: %w", err)
}
tmpDir := os.TempDir()
now := time.Now().UTC()
key := fmt.Sprintf("%04d/%02d/%02d/backup-%02d.sql", now.Year(), now.Month(), now.Day(), now.Hour())
outPath := filepath.Join(tmpDir, "autoglue-backup-"+now.Format("20060102T150405Z")+".sql")
if err := runPgDump(ctx, cfg.DbURL, outPath); err != nil {
return fmt.Errorf("pg_dump: %w", err)
}
defer os.Remove(outPath)
if err := uploadFileToS3(ctx, s3cli, bucket, key, outPath); err != nil {
return fmt.Errorf("s3 upload: %w", err)
}
log.Info().Str("bucket", bucket).Str("key", key).Msg("backup uploaded")
return nil
}
// --- Helpers
func loadS3Credential(ctx context.Context, db *gorm.DB) (models.Credential, s3Scope, error) {
var c models.Credential
err := db.
WithContext(ctx).
Where("provider = ? AND kind = ? AND scope_kind = ?", "aws", "aws_access_key", "service").
Where("scope ->> 'service' = ?", "s3").
Order("created_at DESC").
First(&c).Error
if err != nil {
return models.Credential{}, s3Scope{}, fmt.Errorf("load credential: %w", err)
}
var sc s3Scope
_ = json.Unmarshal(c.Scope, &sc)
return c, sc, nil
}
func decryptAwsAccessKeys(ctx context.Context, db *gorm.DB, c models.Credential) (string, string, error) {
plain, err := utils.DecryptForOrg(c.OrganizationID, c.EncryptedData, c.IV, c.Tag, db)
if err != nil {
return "", "", err
}
var payload encAWS
if err := json.Unmarshal([]byte(plain), &payload); err != nil {
return "", "", fmt.Errorf("parse decrypted payload: %w", err)
}
if payload.AccessKeyID == "" || payload.SecretAccessKey == "" {
return "", "", errors.New("decrypted payload missing keys")
}
return payload.AccessKeyID, payload.SecretAccessKey, nil
}
func makeS3Client(ctx context.Context, accessKey, secret, region string) (*s3.Client, error) {
staticCredentialsProvider := credentials.NewStaticCredentialsProvider(accessKey, secret, "")
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithCredentialsProvider(staticCredentialsProvider), awsconfig.WithRegion(region))
if err != nil {
return nil, fmt.Errorf("aws config: %w", err)
}
return s3.NewFromConfig(cfg), nil
}
func ensureBucket(ctx context.Context, s3cli *s3.Client, bucket, region string) error {
_, err := s3cli.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
if err == nil {
return nil
}
if out, err := s3cli.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: aws.String(bucket)}); err == nil {
existing := string(out.LocationConstraint)
if existing == "" {
existing = "us-east-1"
}
if existing != region {
return fmt.Errorf("bucket %q already exists in region %q (requested %q)", bucket, existing, region)
}
}
// Create; LocationConstraint except us-east-1
in := &s3.CreateBucketInput{Bucket: aws.String(bucket)}
if region != "us-east-1" {
in.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{
LocationConstraint: s3types.BucketLocationConstraint(region),
}
}
if _, err := s3cli.CreateBucket(ctx, in); err != nil {
return fmt.Errorf("create bucket: %w", err)
}
// default SSE (best-effort)
_, _ = s3cli.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
Bucket: aws.String(bucket),
ServerSideEncryptionConfiguration: &s3types.ServerSideEncryptionConfiguration{
Rules: []s3types.ServerSideEncryptionRule{
{ApplyServerSideEncryptionByDefault: &s3types.ServerSideEncryptionByDefault{
SSEAlgorithm: s3types.ServerSideEncryptionAes256,
}},
},
},
})
return nil
}
func runPgDump(ctx context.Context, dbURL, outPath string) error {
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return err
}
args := []string{
"--no-owner",
"--no-privileges",
"--format=plain",
"--file", outPath,
dbURL,
}
cmd := exec.CommandContext(ctx, "pg_dump", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("pg_dump failed: %v | %s", err, stderr.String())
}
return nil
}
func uploadFileToS3(ctx context.Context, s3cli *s3.Client, bucket, key, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
info, _ := f.Stat()
_, err = s3cli.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: f,
ContentLength: aws.Int64(info.Size()),
ContentType: aws.String(mime.TypeByExtension(filepath.Ext(path))),
ServerSideEncryption: s3types.ServerSideEncryptionAes256,
})
return err
}

View File

@@ -2,8 +2,8 @@ package bg
import (
"context"
"encoding/base64"
"fmt"
"log"
"net"
"strings"
"time"
@@ -13,13 +13,16 @@ import (
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
// ----- Public types -----
type BastionBootstrapArgs struct{}
type BastionBootstrapArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type BastionBootstrapFailure struct {
ID uuid.UUID `json:"id"`
@@ -39,11 +42,17 @@ type BastionBootstrapResult struct {
// ----- Worker -----
func BastionBootstrapWorker(db *gorm.DB) archer.WorkerFn {
func BastionBootstrapWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := BastionBootstrapArgs{IntervalS: 120}
jobID := j.ID
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 120
}
var servers []models.Server
if err := db.
Preload("SshKey").
@@ -105,7 +114,7 @@ func BastionBootstrapWorker(db *gorm.DB) archer.WorkerFn {
// 4) SSH + install docker
host := net.JoinHostPort(*s.PublicIPAddress, "22")
runCtx, cancel := context.WithTimeout(ctx, perHostTimeout)
out, err := sshInstallDockerWithOutput(runCtx, host, s.SSHUser, []byte(privKey))
out, err := sshInstallDockerWithOutput(runCtx, db, s, host, s.SSHUser, []byte(privKey))
cancel()
if err != nil {
@@ -131,10 +140,7 @@ func BastionBootstrapWorker(db *gorm.DB) archer.WorkerFn {
_ = setServerStatus(db, s.ID, "failed")
continue
}
ok++
// logHostInfo(jobID, s, "done", "host completed",
// "elapsed_ms", time.Since(hostStart).Milliseconds())
}
res := BastionBootstrapResult{
@@ -147,9 +153,17 @@ func BastionBootstrapWorker(db *gorm.DB) archer.WorkerFn {
Failures: failures,
}
// log.Printf("[bastion] level=INFO job=%s step=finish processed=%d ready=%d failed=%d elapsed_ms=%d",
// jobID, proc, ok, fail, res.ElapsedMs)
log.Debug().Int("processed", proc).Int("ready", ok).Int("failed", fail).Msg("[bastion] reconcile tick ok")
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"bootstrap_bastion",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return res, nil
}
}
@@ -187,16 +201,24 @@ func logHostInfo(jobID string, s *models.Server, step, msg string, kv ...any) {
// ----- SSH & command execution -----
// returns combined stdout/stderr so caller can log it on error
func sshInstallDockerWithOutput(ctx context.Context, host, user string, privateKeyPEM []byte) (string, error) {
func sshInstallDockerWithOutput(
ctx context.Context,
db *gorm.DB,
s *models.Server,
host, user string,
privateKeyPEM []byte,
) (string, error) {
signer, err := ssh.ParsePrivateKey(privateKeyPEM)
if err != nil {
return "", fmt.Errorf("parse private key: %w", err)
}
hkcb := makeDBHostKeyCallback(db, s)
config := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: known_hosts verification
HostKeyCallback: hkcb,
Timeout: 30 * time.Second,
}
@@ -225,19 +247,242 @@ func sshInstallDockerWithOutput(ctx context.Context, host, user string, privateK
script := `
set -euxo pipefail
if ! command -v docker >/dev/null 2>&1; then
curl -fsSL https://get.docker.com | sh
# ----------- toggles (set to 0 to skip) -----------
: "${BASELINE_PKGS:=1}"
: "${INSTALL_DOCKER:=1}"
: "${SSH_HARDEN:=1}"
: "${FIREWALL:=1}"
: "${AUTO_UPDATES:=1}"
: "${TIME_SYNC:=1}"
: "${FAIL2BAN:=1}"
: "${BANNER:=1}"
# ----------- helpers -----------
have() { command -v "$1" >/dev/null 2>&1; }
pm=""
if have apt-get; then pm="apt"
elif have dnf; then pm="dnf"
elif have yum; then pm="yum"
elif have zypper; then pm="zypper"
elif have apk; then pm="apk"
fi
# try to enable/start (handles distros with systemd)
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl enable --now docker || true
pm_update_install() {
case "$pm" in
apt)
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@"
;;
dnf) sudo dnf install -y "$@" ;;
yum) sudo yum install -y "$@" ;;
zypper) sudo zypper --non-interactive install -y "$@" || true ;;
apk) sudo apk add --no-cache "$@" ;;
*)
echo "Unsupported distro: couldn't detect package manager" >&2
return 1
;;
esac
}
systemd_enable_now() {
if have systemctl; then
sudo systemctl enable --now "$1" || true
fi
}
sshd_reload() {
if have systemctl && systemctl is-enabled ssh >/dev/null 2>&1; then
sudo systemctl reload ssh || true
elif have systemctl && systemctl is-enabled sshd >/dev/null 2>&1; then
sudo systemctl reload sshd || true
fi
}
# ----------- baseline packages -----------
if [ "$BASELINE_PKGS" = "1" ] && [ -n "$pm" ]; then
pkgs_common="curl ca-certificates gnupg git jq unzip tar vim tmux htop net-tools"
case "$pm" in
apt) pkgs="$pkgs_common ufw openssh-client" ;;
dnf|yum) pkgs="$pkgs_common firewalld openssh-clients" ;;
zypper) pkgs="$pkgs_common firewalld openssh" ;;
apk) pkgs="$pkgs_common openssh-client" ;;
esac
pm_update_install $pkgs || true
fi
# add current ssh user to docker group if exists
if getent group docker >/dev/null 2>&1; then
sudo usermod -aG docker "$(id -un)" || true
# ----------- docker & compose v2 -----------
if [ "$INSTALL_DOCKER" = "1" ]; then
if ! have docker; then
curl -fsSL https://get.docker.com | sh
fi
# try to enable/start (handles distros with systemd)
if have systemctl; then
sudo systemctl enable --now docker || true
fi
# add current ssh user to docker group if exists
if getent group docker >/dev/null 2>&1; then
sudo usermod -aG docker "$(id -un)" || true
fi
# docker compose v2 (plugin) if missing
if ! docker compose version >/dev/null 2>&1; then
# Try package first (Debian/Ubuntu name)
if [ "$pm" = "apt" ]; then
sudo apt-get update -y
sudo apt-get install -y docker-compose-plugin || true
fi
# Fallback: install static plugin binary under ~/.docker/cli-plugins
if ! docker compose version >/dev/null 2>&1; then
mkdir -p ~/.docker/cli-plugins
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) arch="x86_64" ;;
aarch64|arm64) arch="aarch64" ;;
esac
curl -fsSL -o ~/.docker/cli-plugins/docker-compose \
"https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-$(uname -s)-$arch"
chmod +x ~/.docker/cli-plugins/docker-compose
fi
fi
fi
# ----------- SSH hardening (non-destructive: separate conf file) -----------
if [ "$SSH_HARDEN" = "1" ]; then
confd="/etc/ssh/sshd_config.d"
if [ -d "$confd" ] && [ -w "$confd" ]; then
sudo tee "$confd/10-bastion.conf" >/dev/null <<'EOF'
# Bastion hardening
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
PermitEmptyPasswords no
PubkeyAuthentication yes
ClientAliveInterval 300
ClientAliveCountMax 2
LoginGraceTime 20
MaxAuthTries 3
MaxSessions 10
AllowAgentForwarding no
X11Forwarding no
EOF
sshd_reload
else
echo "Skipping SSH hardening: $confd not present or not writable" >&2
fi
# lock root password (no effect if already locked)
if have passwd; then
sudo passwd -l root || true
fi
fi
# ----------- firewall -----------
if [ "$FIREWALL" = "1" ]; then
if have ufw; then
# Keep it minimal: allow SSH and rate-limit
sudo ufw --force reset || true
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH || sudo ufw allow 22/tcp
sudo ufw limit OpenSSH || true
sudo ufw --force enable
elif have firewall-cmd; then
systemd_enable_now firewalld
sudo firewall-cmd --permanent --add-service=ssh || sudo firewall-cmd --permanent --add-port=22/tcp
sudo firewall-cmd --reload || true
else
echo "No supported firewall tool detected; skipping." >&2
fi
fi
# ----------- unattended / automatic updates -----------
if [ "$AUTO_UPDATES" = "1" ] && [ -n "$pm" ]; then
case "$pm" in
apt)
pm_update_install unattended-upgrades apt-listchanges || true
sudo dpkg-reconfigure -f noninteractive unattended-upgrades || true
sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
;;
dnf)
pm_update_install dnf-automatic || true
sudo sed -i 's/^apply_updates = .*/apply_updates = yes/' /etc/dnf/automatic.conf || true
systemd_enable_now dnf-automatic.timer
;;
yum)
pm_update_install yum-cron || true
sudo sed -i 's/apply_updates = no/apply_updates = yes/' /etc/yum/yum-cron.conf || true
systemd_enable_now yum-cron
;;
zypper)
pm_update_install pkgconf-pkg-config || true
# SUSE has automatic updates via transactional-update / yast2-online-update; skipping heavy config.
;;
apk)
# Alpine: no official unattended updater; consider periodic 'apk upgrade' via cron (skipped by default).
;;
esac
fi
# ----------- time sync -----------
if [ "$TIME_SYNC" = "1" ]; then
if have timedatectl; then
# Prefer systemd-timesyncd if available; else install/enable chrony
if [ -f /lib/systemd/system/systemd-timesyncd.service ] || [ -f /usr/lib/systemd/system/systemd-timesyncd.service ]; then
systemd_enable_now systemd-timesyncd
else
pm_update_install chrony || true
systemd_enable_now chronyd || systemd_enable_now chrony || true
fi
timedatectl set-ntp true || true
else
pm_update_install chrony || true
systemd_enable_now chronyd || systemd_enable_now chrony || true
fi
fi
# ----------- fail2ban (basic sshd jail) -----------
if [ "$FAIL2BAN" = "1" ]; then
pm_update_install fail2ban || true
if [ -d /etc/fail2ban ]; then
sudo tee /etc/fail2ban/jail.d/sshd.local >/dev/null <<'EOF'
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
maxretry = 4
bantime = 1h
findtime = 10m
EOF
systemd_enable_now fail2ban
fi
fi
# ----------- SSH banner / MOTD -----------
if [ "$BANNER" = "1" ]; then
if [ -w /etc/issue.net ] || sudo test -w /etc/issue.net; then
sudo tee /etc/issue.net >/dev/null <<'EOF'
NOTICE: Authorized use only. Activity may be monitored and reported.
EOF
# Ensure banner is enabled via our bastion conf
if [ -d /etc/ssh/sshd_config.d ]; then
if ! grep -q '^Banner ' /etc/ssh/sshd_config.d/10-bastion.conf 2>/dev/null; then
echo 'Banner /etc/issue.net' | sudo tee -a /etc/ssh/sshd_config.d/10-bastion.conf >/dev/null
sshd_reload
fi
fi
fi
fi
echo "Bootstrap complete. If you were added to the docker group, log out and back in to apply."
`
// Send script via stdin to avoid quoting/escaping issues
@@ -271,3 +516,38 @@ func wrapSSHError(err error, output string) error {
func sshEscape(s string) string {
return fmt.Sprintf("%q", s)
}
// makeDBHostKeyCallback returns a HostKeyCallback bound to a specific server row.
// TOFU semantics:
// - If s.SSHHostKey is empty: store the current key in DB and accept.
// - If s.SSHHostKey is set: require exact match, else error (possible MITM/reinstall).
func makeDBHostKeyCallback(db *gorm.DB, s *models.Server) ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
algo := key.Type()
enc := base64.StdEncoding.EncodeToString(key.Marshal())
// First-time connect: persist key (TOFU).
if s.SSHHostKey == "" {
if err := db.Model(&models.Server{}).
Where("id = ? AND (ssh_host_key IS NULL or ssh_host_key = '')", s.ID).
Updates(map[string]any{
"ssh_host_key": enc,
"ssh_host_key_algo": algo,
}).Error; err != nil {
return fmt.Errorf("store new host key for %s (%s): %w", hostname, s.ID, err)
}
s.SSHHostKey = enc
s.SSHHostKeyAlgo = algo
return nil
}
if s.SSHHostKeyAlgo != algo || s.SSHHostKey != enc {
return fmt.Errorf(
"host key mismatch for %s (server_id=%s, stored=%s/%s, got=%s/%s) - POSSIBLE MITM or host reinstalled",
hostname, s.ID, s.SSHHostKeyAlgo, s.SSHHostKey, algo, enc,
)
}
return nil
}
}

View File

@@ -67,7 +67,7 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
archer.WithSetTableName("jobs"), // <- ensure correct table
archer.WithSleepInterval(1*time.Second), // fast poll while debugging
archer.WithErrHandler(func(err error) { // bubble up worker SQL errors
log.Printf("[archer] ERROR: %v", err)
log.Error().Err(err).Msg("[archer] worker error")
}),
)
@@ -75,7 +75,7 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
c.Register(
"bootstrap_bastion",
BastionBootstrapWorker(gdb),
BastionBootstrapWorker(gdb, jobs),
archer.WithInstances(instances),
archer.WithTimeout(time.Duration(timeoutSec)*time.Second),
)
@@ -94,6 +94,51 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
archer.WithTimeout(5*time.Minute),
)
c.Register(
"db_backup_s3",
DbBackupWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(15*time.Minute),
)
c.Register(
"dns_reconcile",
DNSReconsileWorker(gdb, jobs),
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(
"org_key_sweeper",
OrgKeySweeperWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(5*time.Minute),
)
c.Register("cluster_action", ClusterActionWorker(gdb))
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["finised_at"] = time.Now().UTC()
}
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

@@ -0,0 +1,121 @@
package bg
import (
"context"
"fmt"
"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 ClusterBootstrapArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type ClusterBootstrapResult struct {
Status string `json:"status"`
Processed int `json:"processed"`
Ready int `json:"ready"`
Failed int `json:"failed"`
ElapsedMs int `json:"elapsed_ms"`
FailedIDs []uuid.UUID `json:"failed_cluster_ids"`
}
func ClusterBootstrapWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := ClusterBootstrapArgs{IntervalS: 120}
jobID := j.ID
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 120
}
var clusters []models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Where("status = ?", clusterStatusProvisioning).
Find(&clusters).Error; err != nil {
log.Error().Err(err).Msg("[cluster_bootstrap] query clusters failed")
return nil, err
}
proc, ready, failCount := 0, 0, 0
var failedIDs []uuid.UUID
perClusterTimeout := 60 * time.Minute
for i := range clusters {
c := &clusters[i]
proc++
if c.BastionServer.ID == uuid.Nil || c.BastionServer.Status != "ready" {
continue
}
logger := log.With().
Str("job", jobID).
Str("cluster_id", c.ID.String()).
Str("cluster_name", c.Name).
Logger()
logger.Info().Msg("[cluster_bootstrap] running make bootstrap")
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
out, err := runMakeOnBastion(runCtx, db, c, "setup")
cancel()
if err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
logger.Error().Err(err).Str("output", out).Msg("[cluster_bootstrap] make setup failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make setup: %v", err))
continue
}
// you can choose a different terminal status here if you like
if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
logger.Error().Err(err).Msg("[cluster_bootstrap] failed to mark cluster ready")
continue
}
ready++
logger.Info().Msg("[cluster_bootstrap] cluster marked ready")
}
res := ClusterBootstrapResult{
Status: "ok",
Processed: proc,
Ready: ready,
Failed: failCount,
ElapsedMs: int(time.Since(start).Milliseconds()),
FailedIDs: failedIDs,
}
log.Info().
Int("processed", proc).
Int("ready", ready).
Int("failed", failCount).
Msg("[cluster_bootstrap] reconcile tick ok")
// self-reschedule
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"cluster_bootstrap",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return res, nil
}
}

View File

@@ -0,0 +1,120 @@
package bg
import (
"context"
"fmt"
"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 ClusterSetupArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type ClusterSetupResult struct {
Status string `json:"status"`
Processed int `json:"processed"`
Provisioning int `json:"provisioning"`
Failed int `json:"failed"`
ElapsedMs int `json:"elapsed_ms"`
FailedCluster []uuid.UUID `json:"failed_cluster_ids"`
}
func ClusterSetupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := ClusterSetupArgs{IntervalS: 120}
jobID := j.ID
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 120
}
var clusters []models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Where("status = ?", clusterStatusPending).
Find(&clusters).Error; err != nil {
log.Error().Err(err).Msg("[cluster_setup] query clusters failed")
return nil, err
}
proc, prov, failCount := 0, 0, 0
var failedIDs []uuid.UUID
perClusterTimeout := 30 * time.Minute
for i := range clusters {
c := &clusters[i]
proc++
if c.BastionServer.ID == uuid.Nil || c.BastionServer.Status != "ready" {
continue
}
logger := log.With().
Str("job", jobID).
Str("cluster_id", c.ID.String()).
Str("cluster_name", c.Name).
Logger()
logger.Info().Msg("[cluster_setup] running make setup")
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
out, err := runMakeOnBastion(runCtx, db, c, "ping-servers")
cancel()
if err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
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
}
if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
logger.Error().Err(err).Msg("[cluster_setup] failed to mark cluster provisioning")
continue
}
prov++
logger.Info().Msg("[cluster_setup] cluster moved to provisioning")
}
res := ClusterSetupResult{
Status: "ok",
Processed: proc,
Provisioning: prov,
Failed: failCount,
ElapsedMs: int(time.Since(start).Milliseconds()),
FailedCluster: failedIDs,
}
log.Info().
Int("processed", proc).
Int("provisioning", prov).
Int("failed", failCount).
Msg("[cluster_setup] reconcile tick ok")
// self-reschedule
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"cluster_setup",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return res, nil
}
}

597
internal/bg/dns.go Normal file
View File

@@ -0,0 +1,597 @@
package bg
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"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"
)
/************* args & small DTOs *************/
type DNSReconcileArgs struct {
MaxDomains int `json:"max_domains,omitempty"`
MaxRecords int `json:"max_records,omitempty"`
IntervalS int `json:"interval_seconds,omitempty"`
}
// TXT marker content (compact)
type ownershipMarker struct {
Ver string `json:"v"` // "ag1"
Org string `json:"org"` // org UUID
Rec string `json:"rec"` // record UUID
Fp string `json:"fp"` // short fp (first 16 of sha256)
}
// ExternalDNS poison owner id MUST NOT match any real external-dns --txt-owner-id
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"
/************* entrypoint worker *************/
func DNSReconsileWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := DNSReconcileArgs{MaxDomains: 25, MaxRecords: 100, IntervalS: 30}
_ = j.ParseArguments(&args)
if args.MaxDomains <= 0 {
args.MaxDomains = 25
}
if args.MaxRecords <= 0 {
args.MaxRecords = 100
}
if args.IntervalS <= 0 {
args.IntervalS = 30
}
processedDomains, processedRecords, err := reconcileDNSOnce(ctx, db, args)
if err != nil {
log.Error().Err(err).Msg("[dns] reconcile tick failed")
} else {
log.Debug().
Int("domains", processedDomains).
Int("records", processedRecords).
Msg("[dns] reconcile tick ok")
}
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(ctx, uuid.NewString(), "dns_reconcile", args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return map[string]any{
"domains_processed": processedDomains,
"records_processed": processedRecords,
}, nil
}
}
/************* core tick *************/
func reconcileDNSOnce(ctx context.Context, db *gorm.DB, args DNSReconcileArgs) (int, int, error) {
var domains []models.Domain
// 1) validate/backfill pending domains
if err := db.
Where("status = ?", "pending").
Order("created_at ASC").
Limit(args.MaxDomains).
Find(&domains).Error; err != nil {
return 0, 0, err
}
domainsProcessed := 0
for i := range domains {
if err := processDomain(ctx, db, &domains[i]); err != nil {
log.Error().Err(err).Str("domain", domains[i].DomainName).Msg("[dns] domain processing failed")
} else {
domainsProcessed++
}
}
// 2) apply pending record sets for ready domains
var readyDomains []models.Domain
if err := db.Where("status = ?", "ready").Find(&readyDomains).Error; err != nil {
return domainsProcessed, 0, err
}
recordsProcessed := 0
for i := range readyDomains {
n, err := processPendingRecordsForDomain(ctx, db, &readyDomains[i], args.MaxRecords)
if err != nil {
log.Error().Err(err).Str("domain", readyDomains[i].DomainName).Msg("[dns] record processing failed")
continue
}
recordsProcessed += n
}
return domainsProcessed, recordsProcessed, nil
}
/************* domain processing *************/
func processDomain(ctx context.Context, db *gorm.DB, d *models.Domain) error {
orgID := d.OrganizationID
// 1) Load credential (org-guarded)
var cred models.Credential
if err := db.Where("id = ? AND organization_id = ?", d.CredentialID, orgID).First(&cred).Error; err != nil {
return setDomainFailed(db, d, fmt.Errorf("credential not found: %w", err))
}
// 2) Decrypt → dto.AWSCredential
secret, err := utils.DecryptForOrg(orgID, cred.EncryptedData, cred.IV, cred.Tag, db)
if err != nil {
return setDomainFailed(db, d, fmt.Errorf("decrypt: %w", err))
}
var awsCred dto.AWSCredential
if err := jsonUnmarshalStrict([]byte(secret), &awsCred); err != nil {
return setDomainFailed(db, d, fmt.Errorf("secret decode: %w", err))
}
// 3) Client
r53c, _, err := newRoute53Client(ctx, awsCred)
if err != nil {
return setDomainFailed(db, d, err)
}
// 4) Backfill zone id if missing
zoneID := strings.TrimSpace(d.ZoneID)
if zoneID == "" {
zid, err := findHostedZoneID(ctx, r53c, d.DomainName)
if err != nil {
return setDomainFailed(db, d, fmt.Errorf("discover zone id: %w", err))
}
zoneID = zid
d.ZoneID = zoneID
}
// 5) Sanity: can fetch zone
if _, err := r53c.GetHostedZone(ctx, &r53.GetHostedZoneInput{Id: aws.String(zoneID)}); err != nil {
return setDomainFailed(db, d, fmt.Errorf("get hosted zone: %w", err))
}
// 6) Mark ready
d.Status = "ready"
d.LastError = ""
if err := db.Save(d).Error; err != nil {
return err
}
return nil
}
func setDomainFailed(db *gorm.DB, d *models.Domain, cause error) error {
d.Status = "failed"
d.LastError = truncateErr(cause.Error())
_ = db.Save(d).Error
return cause
}
/************* record processing *************/
func processPendingRecordsForDomain(ctx context.Context, db *gorm.DB, d *models.Domain, max int) (int, error) {
orgID := d.OrganizationID
// reload credential
var cred models.Credential
if err := db.Where("id = ? AND organization_id = ?", d.CredentialID, orgID).First(&cred).Error; err != nil {
return 0, err
}
secret, err := utils.DecryptForOrg(orgID, cred.EncryptedData, cred.IV, cred.Tag, db)
if err != nil {
return 0, err
}
var awsCred dto.AWSCredential
if err := jsonUnmarshalStrict([]byte(secret), &awsCred); err != nil {
return 0, err
}
r53c, _, err := newRoute53Client(ctx, awsCred)
if err != nil {
return 0, err
}
var records []models.RecordSet
if err := db.
Where("domain_id = ? AND status = ?", d.ID, "pending").
Order("created_at ASC").
Limit(max).
Find(&records).Error; err != nil {
return 0, err
}
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")
_ = setRecordFailed(db, &records[i], err)
continue
}
applied++
}
return applied, nil
}
// core write + ownership + external-dns hardening
func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.Domain, r *models.RecordSet) error {
zoneID := strings.TrimSpace(d.ZoneID)
if zoneID == "" {
return errors.New("domain has no zone_id")
}
rt := strings.ToUpper(r.Type)
// FQDN & marker
fq := recordFQDN(r.Name, d.DomainName) // ends with "."
mname := markerName(fq)
expected := buildMarkerValue(d.OrganizationID.String(), r.ID.String(), r.Fingerprint)
// ---- ExternalDNS preflight ----
extOwned, err := hasExternalDNSOwnership(ctx, r53c, zoneID, fq, rt)
if err != nil {
return fmt.Errorf("external_dns_lookup: %w", err)
}
if extOwned {
r.Owner = "external"
_ = db.Save(r).Error
return fmt.Errorf("ownership_conflict: external-dns claims %s; refusing to modify", strings.TrimSuffix(fq, "."))
}
// ---- Autoglue ownership preflight via _autoglue.<fqdn> TXT ----
markerVals, err := getMarkerTXTValues(ctx, r53c, zoneID, mname)
if err != nil {
return fmt.Errorf("marker lookup: %w", err)
}
hasForeignOwner := false
hasOurExact := false
for _, v := range markerVals {
mk, ok := parseMarkerValue(v)
if !ok {
continue
}
switch {
case mk.Org == d.OrganizationID.String() && mk.Rec == r.ID.String() && mk.Fp == shortFP(r.Fingerprint):
hasOurExact = true
case mk.Org != d.OrganizationID.String() || mk.Rec != r.ID.String():
hasForeignOwner = true
}
}
if hasForeignOwner {
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 {
return fmt.Errorf("values decode: %w", err)
}
}
// Quote TXT values as required by Route53
recs := make([]r53types.ResourceRecord, 0, len(userVals))
for _, v := range userVals {
v = strings.TrimSpace(v)
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)
}
// Build marker TXT change (UPSERT)
markerChange := r53types.Change{
Action: r53types.ChangeActionUpsert,
ResourceRecordSet: &r53types.ResourceRecordSet{
Name: aws.String(mname),
Type: r53types.RRTypeTxt,
TTL: aws.Int64(300),
ResourceRecords: []r53types.ResourceRecord{
{Value: aws.String(strconv.Quote(expected))},
},
},
}
// Build external-dns poison TXT changes
poisonChanges := buildExternalDNSPoisonTXTChanges(fq, rt)
// Apply all in one batch (atomic-ish)
changes := []r53types.Change{rrChange, markerChange}
changes = append(changes, poisonChanges...)
_, err = r53c.ChangeResourceRecordSets(ctx, &r53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String(zoneID),
ChangeBatch: &r53types.ChangeBatch{Changes: changes},
})
if err != nil {
return err
}
// Success → mark ready & ownership
r.Status = "ready"
r.LastError = ""
r.Owner = "autoglue"
if err := db.Save(r).Error; err != nil {
return err
}
_ = hasOurExact // could be used to skip marker write in future
return nil
}
func setRecordFailed(db *gorm.DB, r *models.RecordSet, cause error) error {
msg := truncateErr(cause.Error())
r.Status = "failed"
r.LastError = msg
// classify ownership on conflict
if strings.HasPrefix(msg, "ownership_conflict") {
r.Owner = "external"
} else if r.Owner == "" || r.Owner == "unknown" {
r.Owner = "unknown"
}
_ = db.Save(r).Error
return cause
}
/************* AWS helpers *************/
func newRoute53Client(ctx context.Context, cred dto.AWSCredential) (*r53.Client, *aws.Config, error) {
// Route53 is global, but config still wants a region
region := strings.TrimSpace(cred.Region)
if region == "" {
region = "us-east-1"
}
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cred.AccessKeyID, cred.SecretAccessKey, "",
)),
)
if err != nil {
return nil, nil, err
}
return r53.NewFromConfig(cfg), &cfg, nil
}
func findHostedZoneID(ctx context.Context, c *r53.Client, domain string) (string, error) {
d := normalizeDomain(domain)
out, err := c.ListHostedZonesByName(ctx, &r53.ListHostedZonesByNameInput{
DNSName: aws.String(d),
})
if err != nil {
return "", err
}
for _, hz := range out.HostedZones {
if strings.TrimSuffix(aws.ToString(hz.Name), ".") == d {
return trimZoneID(aws.ToString(hz.Id)), nil
}
}
return "", fmt.Errorf("hosted zone not found for %q", d)
}
func trimZoneID(id string) string {
return strings.TrimPrefix(id, "/hostedzone/")
}
func normalizeDomain(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
return strings.TrimSuffix(s, ".")
}
func recordFQDN(name, domain string) string {
name = strings.TrimSpace(name)
if name == "" || name == "@" {
return normalizeDomain(domain) + "."
}
if strings.HasSuffix(name, ".") {
return name
}
return fmt.Sprintf("%s.%s.", name, normalizeDomain(domain))
}
/************* TXT marker / external-dns helpers *************/
func markerName(fqdn string) string {
trimmed := strings.TrimSuffix(fqdn, ".")
return "_autoglue." + trimmed + "."
}
func shortFP(full string) string {
if len(full) > 16 {
return full[:16]
}
return full
}
func buildMarkerValue(orgID, recID, fp string) string {
return "v=ag1 org=" + orgID + " rec=" + recID + " fp=" + shortFP(fp)
}
func parseMarkerValue(s string) (ownershipMarker, bool) {
out := ownershipMarker{}
fields := strings.Fields(s)
if len(fields) < 4 {
return out, false
}
kv := map[string]string{}
for _, f := range fields {
parts := strings.SplitN(f, "=", 2)
if len(parts) == 2 {
kv[parts[0]] = parts[1]
}
}
if kv["v"] == "" || kv["org"] == "" || kv["rec"] == "" || kv["fp"] == "" {
return out, false
}
out.Ver, out.Org, out.Rec, out.Fp = kv["v"], kv["org"], kv["rec"], kv["fp"]
return out, true
}
func getMarkerTXTValues(ctx context.Context, c *r53.Client, zoneID, marker string) ([]string, error) {
return getTXTValues(ctx, c, zoneID, marker)
}
// generic TXT fetcher
func getTXTValues(ctx context.Context, c *r53.Client, zoneID, name string) ([]string, error) {
out, err := c.ListResourceRecordSets(ctx, &r53.ListResourceRecordSetsInput{
HostedZoneId: aws.String(zoneID),
StartRecordName: aws.String(name),
StartRecordType: r53types.RRTypeTxt,
MaxItems: aws.Int32(1),
})
if err != nil {
return nil, err
}
if len(out.ResourceRecordSets) == 0 {
return nil, nil
}
rrset := out.ResourceRecordSets[0]
if aws.ToString(rrset.Name) != name || rrset.Type != r53types.RRTypeTxt {
return nil, nil
}
vals := make([]string, 0, len(rrset.ResourceRecords))
for _, rr := range rrset.ResourceRecords {
vals = append(vals, aws.ToString(rr.Value))
}
return vals, nil
}
// detect external-dns-style ownership for this fqdn/type
func hasExternalDNSOwnership(ctx context.Context, c *r53.Client, zoneID, fqdn, rrType string) (bool, error) {
base := strings.TrimSuffix(fqdn, ".")
candidates := []string{
// with txtPrefix=extdns-, external-dns writes both:
// extdns-<fqdn> and extdns-<rrtype-lc>-<fqdn>
"extdns-" + base + ".",
"extdns-" + strings.ToLower(rrType) + "-" + base + ".",
}
for _, name := range candidates {
vals, err := getTXTValues(ctx, c, zoneID, name)
if err != nil {
return false, err
}
for _, raw := range vals {
v := strings.TrimSpace(raw)
// strip surrounding quotes if present
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
if unq, err := strconv.Unquote(v); err == nil {
v = unq
}
}
meta := parseExternalDNSMeta(v)
if meta == nil {
continue
}
if meta["heritage"] == "external-dns" &&
meta["external-dns/owner"] != "" &&
meta["external-dns/owner"] != externalDNSPoisonOwner {
return true, nil
}
}
}
return false, nil
}
// parseExternalDNSMeta parses the comma-separated external-dns TXT format into a small map.
func parseExternalDNSMeta(v string) map[string]string {
parts := strings.Split(v, ",")
if len(parts) == 0 {
return nil
}
meta := make(map[string]string, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 {
continue
}
meta[kv[0]] = kv[1]
}
if len(meta) == 0 {
return nil
}
return meta
}
// build poison TXT records so external-dns thinks some *other* owner manages this
func buildExternalDNSPoisonTXTChanges(fqdn, rrType string) []r53types.Change {
base := strings.TrimSuffix(fqdn, ".")
names := []string{
"extdns-" + base + ".",
"extdns-" + strings.ToLower(rrType) + "-" + base + ".",
}
val := strconv.Quote(externalDNSPoisonValue)
changes := make([]r53types.Change, 0, len(names))
for _, n := range names {
changes = append(changes, r53types.Change{
Action: r53types.ChangeActionUpsert,
ResourceRecordSet: &r53types.ResourceRecordSet{
Name: aws.String(n),
Type: r53types.RRTypeTxt,
TTL: aws.Int64(300),
ResourceRecords: []r53types.ResourceRecord{
{Value: aws.String(val)},
},
},
})
}
return changes
}
/************* misc utils *************/
func truncateErr(s string) string {
const max = 2000
if len(s) > max {
return s[:max]
}
return s
}
// Strict unmarshal that treats "null" -> zero value correctly.
func jsonUnmarshalStrict(b []byte, dst any) error {
if len(b) == 0 {
return errors.New("empty json")
}
return json.Unmarshal(b, dst)
}

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

@@ -0,0 +1,658 @@
package bg
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"strings"
"time"
"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"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
type ClusterPrepareArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type ClusterPrepareFailure struct {
ClusterID uuid.UUID `json:"cluster_id"`
Step string `json:"step"`
Reason string `json:"reason"`
}
type ClusterPrepareResult struct {
Status string `json:"status"`
Processed int `json:"processed"`
MarkedPending int `json:"marked_pending"`
Failed int `json:"failed"`
ElapsedMs int `json:"elapsed_ms"`
FailedIDs []uuid.UUID `json:"failed_cluster_ids"`
Failures []ClusterPrepareFailure `json:"failures"`
}
// 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
clusterStatusBootstrapping = models.ClusterStatusBootstrapping
)
func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := ClusterPrepareArgs{IntervalS: 120}
jobID := j.ID
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 120
}
// Load all clusters that are pre_pending; well filter for bastion.ready in memory.
var clusters []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("status = ?", clusterStatusPrePending).
Find(&clusters).Error; err != nil {
log.Error().Err(err).Msg("[cluster_prepare] query clusters failed")
return nil, err
}
proc, ok, fail := 0, 0, 0
var failedIDs []uuid.UUID
var failures []ClusterPrepareFailure
perClusterTimeout := 8 * time.Minute
for i := range clusters {
c := &clusters[i]
proc++
// bastion must exist and be ready
if c.BastionServer == nil || c.BastionServerID == nil || *c.BastionServerID == uuid.Nil || c.BastionServer.Status != "ready" {
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()).
Str("cluster_name", c.Name).
Logger()
clusterLog.Info().Msg("[cluster_prepare] starting")
if err := validateClusterForPrepare(c); err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "validate",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] validation failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
allServers := flattenClusterServers(c)
keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, c, allServers)
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "build_ssh_assets",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] build ssh assets failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
dtoCluster := mapper.ClusterToDTO(*c)
if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(
c.OrganizationID,
c.EncryptedKubeconfig,
c.KubeIV,
c.KubeTag,
db,
)
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "decrypt_kubeconfig",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] decrypt kubeconfig failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
dtoCluster.Kubeconfig = &kubeconfig
}
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(
db,
c.OrganizationID,
c.ID,
24*time.Hour,
)
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "create_org_key",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] create org key for payload failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
dtoCluster.OrgKey = &orgKey
dtoCluster.OrgSecret = &orgSecret
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "marshal_payload",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] json marshal failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
err = pushAssetsToBastion(runCtx, db, c, sshConfig, keyPayloads, payloadJSON)
cancel()
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "ssh_push",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] failed to push assets to bastion")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "set_pending",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] failed to mark cluster pending")
continue
}
ok++
clusterLog.Info().Msg("[cluster_prepare] cluster marked pending")
}
res := ClusterPrepareResult{
Status: "ok",
Processed: proc,
MarkedPending: ok,
Failed: fail,
ElapsedMs: int(time.Since(start).Milliseconds()),
FailedIDs: failedIDs,
Failures: failures,
}
log.Info().
Int("processed", proc).
Int("pending", ok).
Int("failed", fail).
Msg("[cluster_prepare] reconcile tick ok")
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"prepare_cluster",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return res, nil
}
}
// ---------- helpers ----------
func validateClusterForPrepare(c *models.Cluster) error {
if c.BastionServer == nil || c.BastionServerID == nil || *c.BastionServerID == uuid.Nil {
return fmt.Errorf("missing bastion server")
}
if c.BastionServer.Status != "ready" {
return fmt.Errorf("bastion server not ready (status=%s)", c.BastionServer.Status)
}
// CaptainDomain is a value type; presence is via *ID
if c.CaptainDomainID == nil || *c.CaptainDomainID == uuid.Nil {
return fmt.Errorf("missing captain domain for cluster")
}
// ControlPlaneRecordSet is a pointer; presence is via *ID + non-nil struct
if c.ControlPlaneRecordSetID == nil || *c.ControlPlaneRecordSetID == uuid.Nil || c.ControlPlaneRecordSet == nil {
return fmt.Errorf("missing control_plane_record_set for cluster")
}
if len(c.NodePools) == 0 {
return fmt.Errorf("cluster has no node pools")
}
hasServer := false
for i := range c.NodePools {
if len(c.NodePools[i].Servers) > 0 {
hasServer = true
break
}
}
if !hasServer {
return fmt.Errorf("cluster has no servers attached to node pools")
}
return nil
}
func flattenClusterServers(c *models.Cluster) []*models.Server {
var out []*models.Server
for i := range c.NodePools {
for j := range c.NodePools[i].Servers {
s := &c.NodePools[i].Servers[j]
out = append(out, s)
}
}
return out
}
type keyPayload struct {
FileName string
PrivateKeyB64 string
}
// build ssh-config for all servers + decrypt keys.
// ssh-config is intended to live on the bastion and connect via *private* IPs.
func buildSSHAssetsForCluster(db *gorm.DB, c *models.Cluster, servers []*models.Server) (map[uuid.UUID]keyPayload, string, error) {
var sb strings.Builder
keys := make(map[uuid.UUID]keyPayload)
for _, s := range servers {
// Defensive checks
if strings.TrimSpace(s.PrivateIPAddress) == "" {
return nil, "", fmt.Errorf("server %s missing private ip", s.ID)
}
if s.SshKeyID == uuid.Nil {
return nil, "", fmt.Errorf("server %s missing ssh key relation", s.ID)
}
// de-dupe keys: many servers may share the same ssh key
if _, ok := keys[s.SshKeyID]; !ok {
priv, err := utils.DecryptForOrg(
s.OrganizationID,
s.SshKey.EncryptedPrivateKey,
s.SshKey.PrivateIV,
s.SshKey.PrivateTag,
db,
)
if err != nil {
return nil, "", fmt.Errorf("decrypt key for server %s: %w", s.ID, err)
}
fname := fmt.Sprintf("%s.pem", s.SshKeyID.String())
keys[s.SshKeyID] = keyPayload{
FileName: fname,
PrivateKeyB64: base64.StdEncoding.EncodeToString([]byte(priv)),
}
}
// ssh config entry per server
keyFile := keys[s.SshKeyID].FileName
hostAlias := s.Hostname
if hostAlias == "" {
hostAlias = s.ID.String()
}
sb.WriteString(fmt.Sprintf("Host %s\n", hostAlias))
sb.WriteString(fmt.Sprintf(" HostName %s\n", s.PrivateIPAddress))
sb.WriteString(fmt.Sprintf(" User %s\n", s.SSHUser))
sb.WriteString(fmt.Sprintf(" IdentityFile ~/.ssh/autoglue/keys/%s\n", keyFile))
sb.WriteString(" IdentitiesOnly yes\n")
sb.WriteString(" StrictHostKeyChecking accept-new\n\n")
}
return keys, sb.String(), nil
}
func pushAssetsToBastion(
ctx context.Context,
db *gorm.DB,
c *models.Cluster,
sshConfig string,
keyPayloads map[uuid.UUID]keyPayload,
payloadJSON []byte,
) error {
bastion := c.BastionServer
if bastion == nil {
return fmt.Errorf("bastion server is nil")
}
if bastion.PublicIPAddress == nil || strings.TrimSpace(*bastion.PublicIPAddress) == "" {
return fmt.Errorf("bastion server missing public ip")
}
privKey, err := utils.DecryptForOrg(
bastion.OrganizationID,
bastion.SshKey.EncryptedPrivateKey,
bastion.SshKey.PrivateIV,
bastion.SshKey.PrivateTag,
db,
)
if err != nil {
return fmt.Errorf("decrypt bastion key: %w", err)
}
signer, err := ssh.ParsePrivateKey([]byte(privKey))
if err != nil {
return fmt.Errorf("parse bastion private key: %w", err)
}
hkcb := makeDBHostKeyCallback(db, bastion)
config := &ssh.ClientConfig{
User: bastion.SSHUser,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: hkcb,
Timeout: 30 * time.Second,
}
host := net.JoinHostPort(*bastion.PublicIPAddress, "22")
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", host)
if err != nil {
return fmt.Errorf("dial bastion: %w", err)
}
defer conn.Close()
cconn, chans, reqs, err := ssh.NewClientConn(conn, host, config)
if err != nil {
return fmt.Errorf("ssh handshake bastion: %w", err)
}
client := ssh.NewClient(cconn, chans, reqs)
defer client.Close()
sess, err := client.NewSession()
if err != nil {
return fmt.Errorf("ssh session: %w", err)
}
defer sess.Close()
// build one shot script to:
// - mkdir ~/.ssh/autoglue/keys
// - write cluster-specific ssh-config
// - write all private keys
// - write payload.json
clusterDir := fmt.Sprintf("$HOME/autoglue/clusters/%s", c.ID.String())
configPath := fmt.Sprintf("$HOME/.ssh/autoglue/cluster-%s.config", c.ID.String())
var script bytes.Buffer
script.WriteString("set -euo pipefail\n")
script.WriteString("mkdir -p \"$HOME/.ssh/autoglue/keys\"\n")
script.WriteString("mkdir -p " + clusterDir + "\n")
script.WriteString("chmod 700 \"$HOME/.ssh\" || true\n")
// ssh-config
script.WriteString("cat > " + configPath + " <<'EOF_CFG'\n")
script.WriteString(sshConfig)
script.WriteString("EOF_CFG\n")
script.WriteString("chmod 600 " + configPath + "\n")
// keys
for id, kp := range keyPayloads {
tag := "KEY_" + id.String()
target := fmt.Sprintf("$HOME/.ssh/autoglue/keys/%s", kp.FileName)
script.WriteString("cat <<'" + tag + "' | base64 -d > " + target + "\n")
script.WriteString(kp.PrivateKeyB64 + "\n")
script.WriteString(tag + "\n")
script.WriteString("chmod 600 " + target + "\n")
}
// payload.json
payloadPath := clusterDir + "/payload.json"
script.WriteString("cat > " + payloadPath + " <<'EOF_PAYLOAD'\n")
script.Write(payloadJSON)
script.WriteString("\nEOF_PAYLOAD\n")
script.WriteString("chmod 600 " + payloadPath + "\n")
// If you later want to always include cluster configs automatically, you can
// optionally manage ~/.ssh/config here (kept simple for now).
sess.Stdin = strings.NewReader(script.String())
out, runErr := sess.CombinedOutput("bash -s")
if runErr != nil {
return wrapSSHError(runErr, string(out))
}
return nil
}
func setClusterStatus(db *gorm.DB, id uuid.UUID, status, lastError string) error {
updates := map[string]any{
"status": status,
"updated_at": time.Now(),
}
if lastError != "" {
updates["last_error"] = lastError
}
return db.Model(&models.Cluster{}).
Where("id = ?", id).
Updates(updates).Error
}
// runMakeOnBastion runs `make <target>` from the cluster's directory on the bastion.
func runMakeOnBastion(
ctx context.Context,
db *gorm.DB,
c *models.Cluster,
target string,
) (string, error) {
logger := log.With().
Str("cluster_id", c.ID.String()).
Str("cluster_name", c.Name).
Logger()
bastion := c.BastionServer
if bastion == nil {
return "", fmt.Errorf("bastion server is nil")
}
if bastion.PublicIPAddress == nil || strings.TrimSpace(*bastion.PublicIPAddress) == "" {
return "", fmt.Errorf("bastion server missing public ip")
}
privKey, err := utils.DecryptForOrg(
bastion.OrganizationID,
bastion.SshKey.EncryptedPrivateKey,
bastion.SshKey.PrivateIV,
bastion.SshKey.PrivateTag,
db,
)
if err != nil {
return "", fmt.Errorf("decrypt bastion key: %w", err)
}
signer, err := ssh.ParsePrivateKey([]byte(privKey))
if err != nil {
return "", fmt.Errorf("parse bastion private key: %w", err)
}
hkcb := makeDBHostKeyCallback(db, bastion)
config := &ssh.ClientConfig{
User: bastion.SSHUser,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: hkcb,
Timeout: 30 * time.Second,
}
host := net.JoinHostPort(*bastion.PublicIPAddress, "22")
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", host)
if err != nil {
return "", fmt.Errorf("dial bastion: %w", err)
}
defer conn.Close()
cconn, chans, reqs, err := ssh.NewClientConn(conn, host, config)
if err != nil {
return "", fmt.Errorf("ssh handshake bastion: %w", err)
}
client := ssh.NewClient(cconn, chans, reqs)
defer client.Close()
sess, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("ssh session: %w", err)
}
defer sess.Close()
clusterDir := fmt.Sprintf("$HOME/autoglue/clusters/%s", c.ID.String())
sshDir := fmt.Sprintf("$HOME/.ssh")
cmd := fmt.Sprintf("cd %s && docker run -v %s:/root/.ssh -v ./payload.json:/opt/gluekube/platform.json %s:%s make %s", clusterDir, sshDir, c.DockerImage, c.DockerTag, target)
logger.Info().
Str("cmd", cmd).
Msg("[runMakeOnBastion] executing remote command")
out, runErr := sess.CombinedOutput(cmd)
if runErr != nil {
return string(out), wrapSSHError(runErr, string(out))
}
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

@@ -13,6 +13,7 @@ import (
type Config struct {
DbURL string
DbURLRO string
Port string
Host string
JWTIssuer string
@@ -24,10 +25,17 @@ type Config struct {
GithubClientID string
GithubClientSecret string
UIDev bool
Env string
Debug bool
Swagger bool
UIDev bool
Env string
Debug bool
Swagger bool
SwaggerHost string
DBStudioEnabled bool
DBStudioBind string
DBStudioPort string
DBStudioUser string
DBStudioPass string
}
var (
@@ -47,11 +55,18 @@ func Load() (Config, error) {
v.SetDefault("bind.address", "127.0.0.1")
v.SetDefault("bind.port", "8080")
v.SetDefault("database.url", "postgres://user:pass@localhost:5432/db?sslmode=disable")
v.SetDefault("database.url_ro", "")
v.SetDefault("db_studio.enabled", false)
v.SetDefault("db_studio.bind", "127.0.0.1")
v.SetDefault("db_studio.port", "0") // 0 = random
v.SetDefault("db_studio.user", "")
v.SetDefault("db_studio.pass", "")
v.SetDefault("ui.dev", false)
v.SetDefault("env", "development")
v.SetDefault("debug", false)
v.SetDefault("swagger", false)
v.SetDefault("swagger.host", "localhost:8080")
// Env setup and binding
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
@@ -61,6 +76,7 @@ func Load() (Config, error) {
"bind.address",
"bind.port",
"database.url",
"database.url_ro",
"jwt.issuer",
"jwt.audience",
"jwt.private.enc.key",
@@ -73,6 +89,12 @@ func Load() (Config, error) {
"env",
"debug",
"swagger",
"swagger.host",
"db_studio.enabled",
"db_studio.bind",
"db_studio.port",
"db_studio.user",
"db_studio.pass",
}
for _, k := range keys {
_ = v.BindEnv(k)
@@ -81,6 +103,7 @@ func Load() (Config, error) {
// Build config
cfg := Config{
DbURL: v.GetString("database.url"),
DbURLRO: v.GetString("database.url_ro"),
Port: v.GetString("bind.port"),
Host: v.GetString("bind.address"),
JWTIssuer: v.GetString("jwt.issuer"),
@@ -92,10 +115,17 @@ func Load() (Config, error) {
GithubClientID: v.GetString("github.client.id"),
GithubClientSecret: v.GetString("github.client.secret"),
UIDev: v.GetBool("ui.dev"),
Env: v.GetString("env"),
Debug: v.GetBool("debug"),
Swagger: v.GetBool("swagger"),
UIDev: v.GetBool("ui.dev"),
Env: v.GetString("env"),
Debug: v.GetBool("debug"),
Swagger: v.GetBool("swagger"),
SwaggerHost: v.GetString("swagger.host"),
DBStudioEnabled: v.GetBool("db_studio.enabled"),
DBStudioBind: v.GetString("db_studio.bind"),
DBStudioPort: v.GetString("db_studio.port"),
DBStudioUser: v.GetString("db_studio.user"),
DBStudioPass: v.GetString("db_studio.pass"),
}
// Validate

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

@@ -22,7 +22,6 @@ import (
// @Summary List annotations (org scoped)
// @Description Returns annotations for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
// @Tags Annotations
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key"
@@ -75,7 +74,6 @@ func ListAnnotations(db *gorm.DB) http.HandlerFunc {
// @Summary Get annotation by ID (org scoped)
// @Description Returns one annotation. Add `include=node_pools` to include node pools.
// @Tags Annotations
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Annotation ID (UUID)"
@@ -255,11 +253,10 @@ func UpdateAnnotation(db *gorm.DB) http.HandlerFunc {
// @Summary Delete annotation (org scoped)
// @Description Permanently deletes the annotation.
// @Tags Annotations
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Annotation ID (UUID)"
// @Success 204 {string} string "No Content"
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Annotation ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"

View File

@@ -2,7 +2,9 @@ package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"html/template"
"net/http"
"net/url"
"strings"
@@ -252,10 +254,11 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
accessTTL := 1 * time.Hour
refreshTTL := 30 * 24 * time.Hour
cfgLoaded, _ := config.Load()
access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: user.ID.String(),
Issuer: cfg.JWTIssuer,
Audience: cfg.JWTAudience,
Issuer: cfgLoaded.JWTIssuer,
Audience: cfgLoaded.JWTAudience,
TTL: accessTTL,
Claims: map[string]any{
"email": email,
@@ -273,17 +276,28 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
return
}
secure := true
if u, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(u) {
secure = false
}
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
secure = strings.EqualFold(xf, "https")
}
http.SetCookie(w, &http.Cookie{
Name: "ag_jwt",
Value: "Bearer " + access,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
MaxAge: int((time.Hour * 8).Seconds()),
})
// If the state indicates SPA popup mode, postMessage tokens to the opener and close
state := r.URL.Query().Get("state")
if strings.Contains(state, "mode=spa") {
origin := ""
for _, part := range strings.Split(state, "|") {
if strings.HasPrefix(part, "origin=") {
origin, _ = url.QueryUnescape(strings.TrimPrefix(part, "origin="))
break
}
}
// fallback: restrict to backend origin if none supplied
origin := canonicalOrigin(cfg.OAuthRedirectBase)
if origin == "" {
origin = cfg.OAuthRedirectBase
}
@@ -356,6 +370,24 @@ func Refresh(db *gorm.DB) http.HandlerFunc {
return
}
secure := true
if uParsed, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(uParsed) {
secure = false
}
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
secure = strings.EqualFold(xf, "https")
}
http.SetCookie(w, &http.Cookie{
Name: "ag_jwt",
Value: "Bearer " + access,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
MaxAge: int((time.Hour * 8).Seconds()),
})
utils.WriteJSON(w, 200, dto.TokenPair{
AccessToken: access,
RefreshToken: newPair.Plain,
@@ -377,6 +409,7 @@ func Refresh(db *gorm.DB) http.HandlerFunc {
// @Router /auth/logout [post]
func Logout(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
var req dto.LogoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
@@ -385,12 +418,30 @@ func Logout(db *gorm.DB) http.HandlerFunc {
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
if err != nil {
w.WriteHeader(204) // already invalid/revoked
return
goto clearCookie
}
if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
utils.WriteError(w, 500, "revoke_failed", err.Error())
return
}
clearCookie:
secure := true
if uParsed, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(uParsed) {
secure = false
}
http.SetCookie(w, &http.Cookie{
Name: "ag_jwt",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
w.WriteHeader(204)
}
}
@@ -461,21 +512,63 @@ func ensureAutoMembership(db *gorm.DB, userID uuid.UUID, email string) error {
}).Error
}
// postMessage HTML template
var postMessageTpl = template.Must(template.New("postmsg").Parse(`<!doctype html>
<html>
<body>
<script>
(function(){
try {
var data = JSON.parse(atob("{{.PayloadB64}}"));
if (window.opener) {
window.opener.postMessage(
{ type: 'autoglue:auth', payload: data },
"{{.Origin}}"
);
}
} catch (e) {}
window.close();
})();
</script>
</body>
</html>`))
type postMessageData struct {
Origin string
PayloadB64 string
}
// writePostMessageHTML sends a tiny HTML page that posts tokens to the SPA and closes the window.
func writePostMessageHTML(w http.ResponseWriter, origin string, payload dto.TokenPair) {
b, _ := json.Marshal(payload)
data := postMessageData{
Origin: origin,
PayloadB64: base64.StdEncoding.EncodeToString(b),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<!doctype html><html><body><script>
(function(){
try {
var data = ` + string(b) + `;
if (window.opener) {
window.opener.postMessage({ type: 'autoglue:auth', payload: data }, '` + origin + `');
}
} catch (e) {}
window.close();
})();
</script></body></html>`))
_ = postMessageTpl.Execute(w, data)
}
// canonicalOrigin returns scheme://host[:port] for a given URL, or "" if invalid.
func canonicalOrigin(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
// Normalize: no path/query/fragment — just the origin.
return (&url.URL{
Scheme: u.Scheme,
Host: u.Host,
}).String()
}
func isLocalDev(u *url.URL) bool {
host := strings.ToLower(u.Hostname())
return u.Scheme == "http" &&
(host == "localhost" || host == "127.0.0.1")
}

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(3),
)
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,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,564 @@
package handlers
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"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/datatypes"
"gorm.io/gorm"
)
// ListCredentials godoc
//
// @ID ListCredentials
// @Summary List credentials (metadata only)
// @Description Returns credential metadata for the current org. Secrets are never returned.
// @Tags Credentials
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param credential_provider query string false "Filter by provider (e.g., aws)"
// @Param kind query string false "Filter by kind (e.g., aws_access_key)"
// @Param scope_kind query string false "Filter by scope kind (credential_provider/service/resource)"
// @Success 200 {array} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListCredentials(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
}
q := db.Where("organization_id = ?", orgID)
if v := r.URL.Query().Get("credential_provider"); v != "" {
q = q.Where("provider = ?", v)
}
if v := r.URL.Query().Get("kind"); v != "" {
q = q.Where("kind = ?", v)
}
if v := r.URL.Query().Get("scope_kind"); v != "" {
q = q.Where("scope_kind = ?", v)
}
var rows []models.Credential
if err := q.Order("updated_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
out := make([]dto.CredentialOut, 0, len(rows))
for i := range rows {
out = append(out, credOut(&rows[i]))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetCredential godoc
//
// @ID GetCredential
// @Summary Get credential by ID (metadata only)
// @Tags Credentials
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 200 {object} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetCredential(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
}
idStr := chi.URLParam(r, "id")
id, err := uuid.Parse(idStr)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, credOut(&row))
}
}
// CreateCredential godoc
//
// @ID CreateCredential
// @Summary Create a credential (encrypts secret)
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param body body dto.CreateCredentialRequest true "Credential payload"
// @Success 201 {object} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateCredential(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
}
var in dto.CreateCredentialRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.Validate.Struct(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
cred, err := SaveCredentialWithScope(
r.Context(), db, orgID,
in.CredentialProvider, in.Kind, in.SchemaVersion,
in.ScopeKind, in.ScopeVersion, json.RawMessage(in.Scope), json.RawMessage(in.Secret),
in.Name, in.AccountID, in.Region,
)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "save_failed", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, credOut(cred))
}
}
// UpdateCredential godoc
//
// @ID UpdateCredential
// @Summary Update credential metadata and/or rotate secret
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Param body body dto.UpdateCredentialRequest true "Fields to update"
// @Success 200 {object} dto.CredentialOut
// @Failure 403 {string} string "X-Org-ID required"
// @Failure 404 {string} string "not found"
// @Router /credentials/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateCredential(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.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateCredentialRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
// Update metadata
if in.Name != nil {
row.Name = *in.Name
}
if in.AccountID != nil {
row.AccountID = *in.AccountID
}
if in.Region != nil {
row.Region = *in.Region
}
// Update scope (re-validate + fingerprint)
if in.ScopeKind != nil || in.Scope != nil || in.ScopeVersion != nil {
newKind := row.ScopeKind
if in.ScopeKind != nil {
newKind = *in.ScopeKind
}
newVersion := row.ScopeVersion
if in.ScopeVersion != nil {
newVersion = *in.ScopeVersion
}
if in.Scope == nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "scope must be provided when changing scope kind/version")
return
}
prScopes := dto.ScopeRegistry[row.Provider]
kScopes := prScopes[newKind]
sdef := kScopes[newVersion]
dst := sdef.New()
if err := json.Unmarshal(*in.Scope, dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_scope_json", err.Error())
return
}
if err := sdef.Validate(dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_scope", err.Error())
return
}
canonScope, err := canonicalJSON(dst)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "canon_error", err.Error())
return
}
row.Scope = canonScope
row.ScopeKind = newKind
row.ScopeVersion = newVersion
row.ScopeFingerprint = sha256Hex(canonScope)
}
// Rotate secret
if in.Secret != nil {
// validate against current Provider/Kind/SchemaVersion
def := dto.CredentialRegistry[row.Provider][row.Kind][row.SchemaVersion]
dst := def.New()
if err := json.Unmarshal(*in.Secret, dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_secret_json", err.Error())
return
}
if err := def.Validate(dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_secret", err.Error())
return
}
canonSecret, err := canonicalJSON(dst)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "canon_error", err.Error())
return
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, canonSecret, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "encrypt_error", err.Error())
return
}
row.EncryptedData = cipher
row.IV = iv
row.Tag = tag
}
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, credOut(&row))
}
}
// DeleteCredential godoc
//
// @ID DeleteCredential
// @Summary Delete credential
// @Tags Credentials
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 204
// @Failure 404 {string} string "not found"
// @Router /credentials/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteCredential(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
}
res := db.Where("organization_id = ? AND id = ?", orgID, id).Delete(&models.Credential{})
if res.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// RevealCredential godoc
//
// @ID RevealCredential
// @Summary Reveal decrypted secret (one-time read)
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 200 {object} map[string]any
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /credentials/{id}/reveal [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func RevealCredential(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.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
plain, err := utils.DecryptForOrg(orgID, row.EncryptedData, row.IV, row.Tag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "decrypt_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, plain)
}
}
// -- Helpers
func canonicalJSON(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var m any
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
return marshalSorted(m)
}
func marshalSorted(v any) ([]byte, error) {
switch vv := v.(type) {
case map[string]any:
keys := make([]string, 0, len(vv))
for k := range vv {
keys = append(keys, k)
}
sort.Strings(keys)
buf := bytes.NewBufferString("{")
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
kb, _ := json.Marshal(k)
buf.Write(kb)
buf.WriteByte(':')
b, err := marshalSorted(vv[k])
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte('}')
return buf.Bytes(), nil
case []any:
buf := bytes.NewBufferString("[")
for i, e := range vv {
if i > 0 {
buf.WriteByte(',')
}
b, err := marshalSorted(e)
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte(']')
return buf.Bytes(), nil
default:
return json.Marshal(v)
}
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// SaveCredentialWithScope validates secret+scope, encrypts, fingerprints, and stores.
func SaveCredentialWithScope(
ctx context.Context,
db *gorm.DB,
orgID uuid.UUID,
provider, kind string,
schemaVersion int,
scopeKind string,
scopeVersion int,
rawScope json.RawMessage,
rawSecret json.RawMessage,
name, accountID, region string,
) (*models.Credential, error) {
// 1) secret shape
pv, ok := dto.CredentialRegistry[provider]
if !ok {
return nil, fmt.Errorf("unknown provider %q", provider)
}
kv, ok := pv[kind]
if !ok {
return nil, fmt.Errorf("unknown kind %q for provider %q", kind, provider)
}
def, ok := kv[schemaVersion]
if !ok {
return nil, fmt.Errorf("unsupported schema version %d for %s/%s", schemaVersion, provider, kind)
}
secretDst := def.New()
if err := json.Unmarshal(rawSecret, secretDst); err != nil {
return nil, fmt.Errorf("payload is not valid JSON for %s/%s: %w", provider, kind, err)
}
if err := def.Validate(secretDst); err != nil {
return nil, fmt.Errorf("invalid %s/%s: %w", provider, kind, err)
}
// 2) scope shape
prScopes, ok := dto.ScopeRegistry[provider]
if !ok {
return nil, fmt.Errorf("no scopes registered for provider %q", provider)
}
kScopes, ok := prScopes[scopeKind]
if !ok {
return nil, fmt.Errorf("invalid scope_kind %q for provider %q", scopeKind, provider)
}
sdef, ok := kScopes[scopeVersion]
if !ok {
return nil, fmt.Errorf("unsupported scope version %d for %s/%s", scopeVersion, provider, scopeKind)
}
scopeDst := sdef.New()
if err := json.Unmarshal(rawScope, scopeDst); err != nil {
return nil, fmt.Errorf("invalid scope JSON: %w", err)
}
if err := sdef.Validate(scopeDst); err != nil {
return nil, fmt.Errorf("invalid scope: %w", err)
}
// 3) canonicalize scope (also what we persist in plaintext)
canonScope, err := canonicalJSON(scopeDst)
if err != nil {
return nil, err
}
fp := sha256Hex(canonScope) // or HMAC if you have a server-side key
// 4) canonicalize + encrypt secret
canonSecret, err := canonicalJSON(secretDst)
if err != nil {
return nil, err
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, canonSecret, db)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
cred := &models.Credential{
OrganizationID: orgID,
Provider: provider,
Kind: kind,
SchemaVersion: schemaVersion,
Name: name,
ScopeKind: scopeKind,
Scope: datatypes.JSON(canonScope),
ScopeVersion: scopeVersion,
AccountID: accountID,
Region: region,
ScopeFingerprint: fp,
EncryptedData: cipher,
IV: iv,
Tag: tag,
}
if err := db.WithContext(ctx).Create(cred).Error; err != nil {
return nil, err
}
return cred, nil
}
// credOut converts model → response DTO
func credOut(c *models.Credential) dto.CredentialOut {
return dto.CredentialOut{
ID: c.ID.String(),
CredentialProvider: c.Provider,
Kind: c.Kind,
SchemaVersion: c.SchemaVersion,
Name: c.Name,
ScopeKind: c.ScopeKind,
ScopeVersion: c.ScopeVersion,
Scope: dto.RawJSON(c.Scope),
AccountID: c.AccountID,
Region: c.Region,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339),
}
}

797
internal/handlers/dns.go Normal file
View File

@@ -0,0 +1,797 @@
package handlers
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"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"
"github.com/rs/zerolog/log"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// ---------- Helpers ----------
func normLowerNoDot(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
return strings.TrimSuffix(s, ".")
}
func fqdn(domain string, rel string) string {
d := normLowerNoDot(domain)
r := normLowerNoDot(rel)
if r == "" || r == "@" {
return d
}
return r + "." + d
}
func canonicalJSONAny(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var anyv any
if err := json.Unmarshal(b, &anyv); err != nil {
return nil, err
}
return marshalSortedDNS(anyv)
}
func marshalSortedDNS(v any) ([]byte, error) {
switch vv := v.(type) {
case map[string]any:
keys := make([]string, 0, len(vv))
for k := range vv {
keys = append(keys, k)
}
sortStrings(keys)
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
kb, _ := json.Marshal(k)
buf.Write(kb)
buf.WriteByte(':')
b, err := marshalSortedDNS(vv[k])
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte('}')
return buf.Bytes(), nil
case []any:
var buf bytes.Buffer
buf.WriteByte('[')
for i, e := range vv {
if i > 0 {
buf.WriteByte(',')
}
b, err := marshalSortedDNS(e)
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte(']')
return buf.Bytes(), nil
default:
return json.Marshal(v)
}
}
func sortStrings(a []string) {
for i := 0; i < len(a); i++ {
for j := i + 1; j < len(a); j++ {
if a[j] < a[i] {
a[i], a[j] = a[j], a[i]
}
}
}
}
func sha256HexBytes(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
/* Fingerprint (provider-agnostic) */
type desiredRecord struct {
ZoneID string `json:"zone_id"`
FQDN string `json:"fqdn"`
Type string `json:"type"`
TTL *int `json:"ttl,omitempty"`
Values []string `json:"values,omitempty"`
}
func computeFingerprint(zoneID, fqdn, typ string, ttl *int, values datatypes.JSON) (string, error) {
var vals []string
if len(values) > 0 && string(values) != "null" {
if err := json.Unmarshal(values, &vals); err != nil {
return "", err
}
sortStrings(vals)
}
payload := &desiredRecord{
ZoneID: zoneID, FQDN: fqdn, Type: strings.ToUpper(typ), TTL: ttl, Values: vals,
}
can, err := canonicalJSONAny(payload)
if err != nil {
return "", err
}
return sha256HexBytes(can), nil
}
func mustSameOrgDomainWithCredential(db *gorm.DB, orgID uuid.UUID, credID uuid.UUID) error {
var cred models.Credential
if err := db.Where("id = ? AND organization_id = ?", credID, orgID).First(&cred).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("credential not found or belongs to different org")
}
return err
}
if cred.Provider != "aws" || cred.ScopeKind != "service" {
return fmt.Errorf("credential must be AWS Route 53 service scoped")
}
var scope map[string]any
if err := json.Unmarshal(cred.Scope, &scope); err != nil {
return fmt.Errorf("credential scope invalid json: %w", err)
}
if strings.ToLower(fmt.Sprint(scope["service"])) != "route53" {
return fmt.Errorf("credential scope.service must be route53")
}
return nil
}
// ---------- Domain Handlers ----------
// ListDomains godoc
//
// @ID ListDomains
// @Summary List domains (org scoped)
// @Description Returns domains for X-Org-ID. Filters: `domain_name`, `status`, `q` (contains).
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param domain_name query string false "Exact domain name (lowercase, no trailing dot)"
// @Param status query string false "pending|provisioning|ready|failed"
// @Param q query string false "Domain contains (case-insensitive)"
// @Success 200 {array} dto.DomainResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "db error"
// @Router /dns/domains [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListDomains(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
}
q := db.Model(&models.Domain{}).Where("organization_id = ?", orgID)
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("domain_name"))); v != "" {
q = q.Where("LOWER(domain_name) = ?", v)
}
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); v != "" {
q = q.Where("status = ?", v)
}
if needle := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))); needle != "" {
q = q.Where("LOWER(domain_name) LIKE ?", "%"+needle+"%")
}
var rows []models.Domain
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.DomainResponse, 0, len(rows))
for i := range rows {
out = append(out, domainOut(&rows[i]))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetDomain godoc
//
// @ID GetDomain
// @Summary Get a domain (org scoped)
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Domain ID (UUID)"
// @Success 200 {object} dto.DomainResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/domains/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetDomain(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.Domain
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, domainOut(&row))
}
}
// CreateDomain godoc
//
// @ID CreateDomain
// @Summary Create a domain (org scoped)
// @Description Creates a domain bound to a Route 53 scoped credential. Archer will backfill ZoneID if omitted.
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateDomainRequest true "Domain payload"
// @Success 201 {object} dto.DomainResponse
// @Failure 400 {string} string "validation error"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "db error"
// @Router /dns/domains [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateDomain(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
}
var in dto.CreateDomainRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.DNSValidate(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
credID, _ := uuid.Parse(in.CredentialID)
if err := mustSameOrgDomainWithCredential(db, orgID, credID); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_credential", err.Error())
return
}
row := &models.Domain{
OrganizationID: orgID,
DomainName: normLowerNoDot(in.DomainName),
ZoneID: strings.TrimSpace(in.ZoneID),
Status: "pending",
LastError: "",
CredentialID: credID,
}
if err := db.Create(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, domainOut(row))
}
}
// UpdateDomain godoc
//
// @ID UpdateDomain
// @Summary Update a domain (org scoped)
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Domain ID (UUID)"
// @Param body body dto.UpdateDomainRequest true "Fields to update"
// @Success 200 {object} dto.DomainResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/domains/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateDomain(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.Domain
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateDomainRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.DNSValidate(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
if in.DomainName != nil {
row.DomainName = normLowerNoDot(*in.DomainName)
}
if in.CredentialID != nil {
credID, _ := uuid.Parse(*in.CredentialID)
if err := mustSameOrgDomainWithCredential(db, orgID, credID); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_credential", err.Error())
return
}
row.CredentialID = credID
row.Status = "pending"
row.LastError = ""
}
if in.ZoneID != nil {
row.ZoneID = strings.TrimSpace(*in.ZoneID)
}
if in.Status != nil {
row.Status = *in.Status
if row.Status == "pending" {
row.LastError = ""
}
}
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, domainOut(&row))
}
}
// DeleteDomain godoc
//
// @ID DeleteDomain
// @Summary Delete a domain
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Domain ID (UUID)"
// @Success 204
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/domains/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteDomain(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
}
res := db.Where("organization_id = ? AND id = ?", orgID, id).Delete(&models.Domain{})
if res.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// ---------- Record Set Handlers ----------
// ListRecordSets godoc
//
// @ID ListRecordSets
// @Summary List record sets for a domain
// @Description Filters: `name`, `type`, `status`.
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param domain_id path string true "Domain ID (UUID)"
// @Param name query string false "Exact relative name or FQDN (server normalizes)"
// @Param type query string false "RR type (A, AAAA, CNAME, TXT, MX, NS, SRV, CAA)"
// @Param status query string false "pending|provisioning|ready|failed"
// @Success 200 {array} dto.RecordSetResponse
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "domain not found"
// @Router /dns/domains/{domain_id}/records [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListRecordSets(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
}
did, err := uuid.Parse(chi.URLParam(r, "domain_id"))
if err != nil {
log.Info().Msg(err.Error())
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid domain UUID:")
return
}
var domain models.Domain
if err := db.Where("organization_id = ? AND id = ?", orgID, did).First(&domain).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
q := db.Model(&models.RecordSet{}).Where("domain_id = ?", did)
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("name"))); v != "" {
dn := strings.ToLower(domain.DomainName)
rel := v
// normalize apex or FQDN into relative
if v == dn || v == dn+"." {
rel = ""
} else {
rel = strings.TrimSuffix(v, "."+dn)
rel = normLowerNoDot(rel)
}
q = q.Where("LOWER(name) = ?", rel)
}
if v := strings.TrimSpace(strings.ToUpper(r.URL.Query().Get("type"))); v != "" {
q = q.Where("type = ?", v)
}
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); v != "" {
q = q.Where("status = ?", v)
}
var rows []models.RecordSet
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.RecordSetResponse, 0, len(rows))
for i := range rows {
out = append(out, recordOut(&rows[i]))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateRecordSet godoc
//
// @ID CreateRecordSet
// @Summary Create a record set (pending; Archer will UPSERT to Route 53)
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param domain_id path string true "Domain ID (UUID)"
// @Param body body dto.CreateRecordSetRequest true "Record set payload"
// @Success 201 {object} dto.RecordSetResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "domain not found"
// @Router /dns/domains/{domain_id}/records [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateRecordSet(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
}
did, err := uuid.Parse(chi.URLParam(r, "domain_id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid domain UUID")
return
}
var domain models.Domain
if err := db.Where("organization_id = ? AND id = ?", orgID, did).First(&domain).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.CreateRecordSetRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.DNSValidate(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
t := strings.ToUpper(in.Type)
if t == "CNAME" && len(in.Values) != 1 {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "CNAME requires exactly one value")
return
}
rel := normLowerNoDot(in.Name)
fq := fqdn(domain.DomainName, rel)
// Pre-flight: block duplicate tuple and protect from non-autoglue rows
var existing models.RecordSet
if err := db.Where("domain_id = ? AND LOWER(name) = ? AND type = ?",
domain.ID, strings.ToLower(rel), t).First(&existing).Error; err == nil {
if existing.Owner != "" && existing.Owner != "autoglue" {
utils.WriteError(w, http.StatusConflict, "ownership_conflict",
"record with the same (name,type) exists but is not owned by autoglue")
return
}
utils.WriteError(w, http.StatusConflict, "already_exists",
"a record with the same (name,type) already exists; use PATCH to modify")
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
valuesJSON, _ := json.Marshal(in.Values)
fp, err := computeFingerprint(domain.ZoneID, fq, t, in.TTL, datatypes.JSON(valuesJSON))
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "fingerprint_error", err.Error())
return
}
row := &models.RecordSet{
DomainID: domain.ID,
Name: rel,
Type: t,
TTL: in.TTL,
Values: datatypes.JSON(valuesJSON),
Fingerprint: fp,
Status: "pending",
LastError: "",
Owner: "autoglue",
}
if err := db.Create(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, recordOut(row))
}
}
// UpdateRecordSet godoc
//
// @ID UpdateRecordSet
// @Summary Update a record set (flips to pending for reconciliation)
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Record Set ID (UUID)"
// @Param body body dto.UpdateRecordSetRequest true "Fields to update"
// @Success 200 {object} dto.RecordSetResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/records/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateRecordSet(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
}
var domain models.Domain
if err := db.Where("id = ?", row.DomainID).First(&domain).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateRecordSetRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.DNSValidate(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
if row.Owner != "" && row.Owner != "autoglue" {
utils.WriteError(w, http.StatusConflict, "ownership_conflict",
"record is not owned by autoglue; refuse to modify")
return
}
// Mutations
if in.Name != nil {
row.Name = normLowerNoDot(*in.Name)
}
if in.Type != nil {
row.Type = strings.ToUpper(*in.Type)
}
if in.TTL != nil {
row.TTL = in.TTL
}
if in.Values != nil {
t := row.Type
if in.Type != nil {
t = strings.ToUpper(*in.Type)
}
if t == "CNAME" && len(*in.Values) != 1 {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "CNAME requires exactly one value")
return
}
b, _ := json.Marshal(*in.Values)
row.Values = datatypes.JSON(b)
}
if in.Status != nil {
row.Status = *in.Status
} else {
row.Status = "pending"
row.LastError = ""
}
fq := fqdn(domain.DomainName, row.Name)
fp, err := computeFingerprint(domain.ZoneID, fq, row.Type, row.TTL, row.Values)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "fingerprint_error", err.Error())
return
}
row.Fingerprint = fp
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, recordOut(&row))
}
}
// DeleteRecordSet godoc
//
// @ID DeleteRecordSet
// @Summary Delete a record set (API removes row; worker can optionally handle external deletion policy)
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Record Set ID (UUID)"
// @Success 204
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/records/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteRecordSet(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
}
sub := db.Model(&models.RecordSet{}).
Select("record_sets.id").
Joins("JOIN domains ON domains.id = record_sets.domain_id").
Where("record_sets.id = ? AND domains.organization_id = ?", id, orgID)
res := db.Where("id IN (?)", sub).Delete(&models.RecordSet{})
if res.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// ---------- Out mappers ----------
func domainOut(m *models.Domain) dto.DomainResponse {
return dto.DomainResponse{
ID: m.ID.String(),
OrganizationID: m.OrganizationID.String(),
DomainName: m.DomainName,
ZoneID: m.ZoneID,
Status: m.Status,
LastError: m.LastError,
CredentialID: m.CredentialID.String(),
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func recordOut(r *models.RecordSet) dto.RecordSetResponse {
vals := r.Values
if len(vals) == 0 {
vals = datatypes.JSON("[]")
}
return dto.RecordSetResponse{
ID: r.ID.String(),
DomainID: r.DomainID.String(),
Name: r.Name,
Type: r.Type,
TTL: r.TTL,
Values: []byte(vals),
Fingerprint: r.Fingerprint,
Status: r.Status,
LastError: r.LastError,
Owner: r.Owner,
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
}
}

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

@@ -0,0 +1,72 @@
package dto
import (
"time"
"github.com/google/uuid"
)
type ClusterResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
CaptainDomain *DomainResponse `json:"captain_domain,omitempty"`
ControlPlaneRecordSet *RecordSetResponse `json:"control_plane_record_set,omitempty"`
ControlPlaneFQDN *string `json:"control_plane_fqdn,omitempty"`
AppsLoadBalancer *LoadBalancerResponse `json:"apps_load_balancer,omitempty"`
GlueOpsLoadBalancer *LoadBalancerResponse `json:"glueops_load_balancer,omitempty"`
BastionServer *ServerResponse `json:"bastion_server,omitempty"`
Provider string `json:"cluster_provider"`
Region string `json:"region"`
Status string `json:"status"`
LastError string `json:"last_error"`
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"`
NodePools []NodePoolResponse `json:"node_pools,omitempty"`
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"`
}
type CreateClusterRequest struct {
Name string `json:"name"`
ClusterProvider string `json:"cluster_provider"`
Region string `json:"region"`
DockerImage string `json:"docker_image"`
DockerTag string `json:"docker_tag"`
}
type UpdateClusterRequest struct {
Name *string `json:"name,omitempty"`
ClusterProvider *string `json:"cluster_provider,omitempty"`
Region *string `json:"region,omitempty"`
DockerImage *string `json:"docker_image,omitempty"`
DockerTag *string `json:"docker_tag,omitempty"`
}
type AttachCaptainDomainRequest struct {
DomainID uuid.UUID `json:"domain_id"`
}
type AttachRecordSetRequest struct {
RecordSetID uuid.UUID `json:"record_set_id"`
}
type AttachLoadBalancerRequest struct {
LoadBalancerID uuid.UUID `json:"load_balancer_id"`
}
type AttachBastionRequest struct {
ServerID uuid.UUID `json:"server_id"`
}
type SetKubeconfigRequest struct {
Kubeconfig string `json:"kubeconfig"`
}
type AttachNodePoolRequest struct {
NodePoolID uuid.UUID `json:"node_pool_id"`
}

View File

@@ -0,0 +1,138 @@
package dto
import (
"encoding/json"
"github.com/go-playground/validator/v10"
)
// RawJSON is a swagger-friendly wrapper for json.RawMessage.
type RawJSON = json.RawMessage
var Validate = validator.New()
func init() {
_ = Validate.RegisterValidation("awsarn", func(fl validator.FieldLevel) bool {
v := fl.Field().String()
return len(v) > 10 && len(v) < 2048 && len(v) >= 4 && v[:4] == "arn:"
})
}
/*** Shapes for secrets ***/
type AWSCredential struct {
AccessKeyID string `json:"access_key_id" validate:"required,alphanum,len=20"`
SecretAccessKey string `json:"secret_access_key" validate:"required"`
Region string `json:"region" validate:"omitempty"`
}
type BasicAuth struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}
type APIToken struct {
Token string `json:"token" validate:"required"`
}
type OAuth2Credential struct {
ClientID string `json:"client_id" validate:"required"`
ClientSecret string `json:"client_secret" validate:"required"`
RefreshToken string `json:"refresh_token" validate:"required"`
}
/*** Shapes for scopes ***/
type AWSProviderScope struct{}
type AWSServiceScope struct {
Service string `json:"service" validate:"required,oneof=route53 s3 ec2 iam rds dynamodb"`
}
type AWSResourceScope struct {
ARN string `json:"arn" validate:"required,awsarn"`
}
/*** Registries ***/
type ProviderDef struct {
New func() any
Validate func(any) error
}
type ScopeDef struct {
New func() any
Validate func(any) error
Specificity int // 0=provider, 1=service, 2=resource
}
// Secret shapes per provider/kind/version
var CredentialRegistry = map[string]map[string]map[int]ProviderDef{
"aws": {
"aws_access_key": {
1: {New: func() any { return &AWSCredential{} }, Validate: func(x any) error { return Validate.Struct(x) }},
},
},
"cloudflare": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"hetzner": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"digitalocean": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"generic": {
"basic_auth": {1: {New: func() any { return &BasicAuth{} }, Validate: func(x any) error { return Validate.Struct(x) }}},
"oauth2": {1: {New: func() any { return &OAuth2Credential{} }, Validate: func(x any) error { return Validate.Struct(x) }}},
},
}
// Scope shapes per provider/scopeKind/version
var ScopeRegistry = map[string]map[string]map[int]ScopeDef{
"aws": {
"provider": {1: {New: func() any { return &AWSProviderScope{} }, Validate: func(any) error { return nil }, Specificity: 0}},
"service": {1: {New: func() any { return &AWSServiceScope{} }, Validate: func(x any) error { return Validate.Struct(x) }, Specificity: 1}},
"resource": {1: {New: func() any { return &AWSResourceScope{} }, Validate: func(x any) error { return Validate.Struct(x) }, Specificity: 2}},
},
}
/*** API DTOs used by swagger ***/
// CreateCredentialRequest represents the POST /credentials payload
type CreateCredentialRequest struct {
CredentialProvider string `json:"credential_provider" validate:"required,oneof=aws cloudflare hetzner digitalocean generic"`
Kind string `json:"kind" validate:"required"` // aws_access_key, api_token, basic_auth, oauth2
SchemaVersion int `json:"schema_version" validate:"required,gte=1"` // secret schema version
Name string `json:"name" validate:"omitempty,max=100"` // human label
ScopeKind string `json:"scope_kind" validate:"required,oneof=credential_provider service resource"`
ScopeVersion int `json:"scope_version" validate:"required,gte=1"` // scope schema version
Scope RawJSON `json:"scope" validate:"required" swaggertype:"object"` // {"service":"route53"} or {"arn":"..."}
AccountID string `json:"account_id,omitempty" validate:"omitempty,max=32"`
Region string `json:"region,omitempty" validate:"omitempty,max=32"`
Secret RawJSON `json:"secret" validate:"required" swaggertype:"object"` // encrypted later
}
// UpdateCredentialRequest represents PATCH /credentials/{id}
type UpdateCredentialRequest struct {
Name *string `json:"name,omitempty"`
AccountID *string `json:"account_id,omitempty"`
Region *string `json:"region,omitempty"`
ScopeKind *string `json:"scope_kind,omitempty"`
ScopeVersion *int `json:"scope_version,omitempty"`
Scope *RawJSON `json:"scope,omitempty" swaggertype:"object"`
Secret *RawJSON `json:"secret,omitempty" swaggertype:"object"` // set if rotating
}
// CredentialOut is what we return (no secrets)
type CredentialOut struct {
ID string `json:"id"`
CredentialProvider string `json:"credential_provider"`
Kind string `json:"kind"`
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
ScopeKind string `json:"scope_kind"`
ScopeVersion int `json:"scope_version"`
Scope RawJSON `json:"scope" swaggertype:"object"`
AccountID string `json:"account_id,omitempty"`
Region string `json:"region,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

View File

@@ -0,0 +1,103 @@
package dto
import (
"encoding/json"
"strings"
"github.com/go-playground/validator/v10"
)
var dnsValidate = validator.New()
func init() {
_ = dnsValidate.RegisterValidation("fqdn", func(fl validator.FieldLevel) bool {
s := strings.TrimSpace(fl.Field().String())
if s == "" || len(s) > 253 {
return false
}
// Minimal: lower-cased, no trailing dot in our API (normalize server-side)
// You can add stricter checks later.
return !strings.HasPrefix(s, ".") && !strings.Contains(s, "..")
})
_ = dnsValidate.RegisterValidation("rrtype", func(fl validator.FieldLevel) bool {
switch strings.ToUpper(fl.Field().String()) {
case "A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA":
return true
default:
return false
}
})
}
// ---- Domains ----
type CreateDomainRequest struct {
DomainName string `json:"domain_name" validate:"required,fqdn"`
CredentialID string `json:"credential_id" validate:"required,uuid4"`
ZoneID string `json:"zone_id,omitempty" validate:"omitempty,max=128"`
}
type UpdateDomainRequest struct {
CredentialID *string `json:"credential_id,omitempty" validate:"omitempty,uuid4"`
ZoneID *string `json:"zone_id,omitempty" validate:"omitempty,max=128"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending provisioning ready failed"`
DomainName *string `json:"domain_name,omitempty" validate:"omitempty,fqdn"`
}
type DomainResponse struct {
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
DomainName string `json:"domain_name"`
ZoneID string `json:"zone_id"`
Status string `json:"status"`
LastError string `json:"last_error"`
CredentialID string `json:"credential_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ---- Record Sets ----
type AliasTarget struct {
HostedZoneID string `json:"hosted_zone_id" validate:"required"`
DNSName string `json:"dns_name" validate:"required"`
EvaluateTargetHealth bool `json:"evaluate_target_health"`
}
type CreateRecordSetRequest struct {
// Name relative to domain ("endpoint") OR FQDN ("endpoint.example.com").
// Server normalizes to relative.
Name string `json:"name" validate:"required,max=253"`
Type string `json:"type" validate:"required,rrtype"`
TTL *int `json:"ttl,omitempty" validate:"omitempty,gte=1,lte=86400"`
Values []string `json:"values" validate:"omitempty,dive,min=1,max=1024"`
}
type UpdateRecordSetRequest struct {
// Any change flips status back to pending (worker will UPSERT)
Name *string `json:"name,omitempty" validate:"omitempty,max=253"`
Type *string `json:"type,omitempty" validate:"omitempty,rrtype"`
TTL *int `json:"ttl,omitempty" validate:"omitempty,gte=1,lte=86400"`
Values *[]string `json:"values,omitempty" validate:"omitempty,dive,min=1,max=1024"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending provisioning ready failed"`
}
type RecordSetResponse struct {
ID string `json:"id"`
DomainID string `json:"domain_id"`
Name string `json:"name"`
Type string `json:"type"`
TTL *int `json:"ttl,omitempty"`
Values json.RawMessage `json:"values" swaggertype:"object"` // []string JSON
Fingerprint string `json:"fingerprint"`
Status string `json:"status"`
LastError string `json:"last_error"`
Owner string `json:"owner"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DNSValidate Quick helper to validate DTOs in handlers
func DNSValidate(i any) error {
return dnsValidate.Struct(i)
}

View File

@@ -57,6 +57,6 @@ type PageJob struct {
type EnqueueRequest struct {
Queue string `json:"queue" example:"default"`
Type string `json:"type" example:"email.send"`
Payload json.RawMessage `json:"payload"`
Payload json.RawMessage `json:"payload" swaggertype:"object"`
RunAt *time.Time `json:"run_at" example:"2025-11-05T08:00:00Z"`
}

View File

@@ -0,0 +1,32 @@
package dto
import (
"time"
"github.com/google/uuid"
)
type LoadBalancerResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Name string `json:"name"`
Kind string `json:"kind"`
PublicIPAddress string `json:"public_ip_address"`
PrivateIPAddress string `json:"private_ip_address"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateLoadBalancerRequest struct {
Name string `json:"name" example:"glueops"`
Kind string `json:"kind" example:"public" enums:"glueops,public"`
PublicIPAddress string `json:"public_ip_address" example:"8.8.8.8"`
PrivateIPAddress string `json:"private_ip_address" example:"192.168.0.2"`
}
type UpdateLoadBalancerRequest struct {
Name *string `json:"name" example:"glue"`
Kind *string `json:"kind" example:"public" enums:"glueops,public"`
PublicIPAddress *string `json:"public_ip_address" example:"8.8.8.8"`
PrivateIPAddress *string `json:"private_ip_address" example:"192.168.0.2"`
}

View File

@@ -0,0 +1,46 @@
package dto
import "github.com/glueops/autoglue/internal/common"
type NodeRole string
const (
NodeRoleMaster NodeRole = "master"
NodeRoleWorker NodeRole = "worker"
)
type CreateNodePoolRequest struct {
Name string `json:"name"`
Role NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
}
type UpdateNodePoolRequest struct {
Name *string `json:"name"`
Role *NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
}
type NodePoolResponse struct {
common.AuditFields
Name string `json:"name"`
Role NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
Servers []ServerResponse `json:"servers"`
Annotations []AnnotationResponse `json:"annotations"`
Labels []LabelResponse `json:"labels"`
Taints []TaintResponse `json:"taints"`
}
type AttachServersRequest struct {
ServerIDs []string `json:"server_ids"`
}
type AttachTaintsRequest struct {
TaintIDs []string `json:"taint_ids"`
}
type AttachLabelsRequest struct {
LabelIDs []string `json:"label_ids"`
}
type AttachAnnotationsRequest struct {
AnnotationIDs []string `json:"annotation_ids"`
}

View File

@@ -8,8 +8,8 @@ type CreateServerRequest struct {
PrivateIPAddress string `json:"private_ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID string `json:"ssh_key_id"`
Role string `json:"role" example:"master|worker|bastion"`
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
Role string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
}
type UpdateServerRequest struct {
@@ -18,8 +18,8 @@ type UpdateServerRequest struct {
PrivateIPAddress *string `json:"private_ip_address,omitempty"`
SSHUser *string `json:"ssh_user,omitempty"`
SshKeyID *string `json:"ssh_key_id,omitempty"`
Role *string `json:"role,omitempty" example:"master|worker|bastion"`
Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
Role *string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
}
type ServerResponse struct {
@@ -30,8 +30,8 @@ type ServerResponse struct {
PrivateIPAddress string `json:"private_ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID uuid.UUID `json:"ssh_key_id"`
Role string `json:"role"`
Status string `json:"status"`
Role string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}

View File

@@ -16,7 +16,6 @@ type HealthStatus struct {
// @Description Returns 200 OK when the service is up
// @Tags Health
// @ID HealthCheck // operationId
// @Accept json
// @Produce json
// @Success 200 {object} HealthStatus
// @Router /healthz [get]

View File

@@ -25,7 +25,6 @@ import (
// @Summary List Archer jobs (admin)
// @Description Paginated background jobs with optional filters. Search `q` may match id, type, error, payload (implementation-dependent).
// @Tags ArcherAdmin
// @Accept json
// @Produce json
// @Param status query string false "Filter by status" Enums(queued,running,succeeded,failed,canceled,retrying,scheduled)
// @Param queue query string false "Filter by queue name / worker name"
@@ -283,7 +282,6 @@ func AdminCancelArcherJob(db *gorm.DB) http.HandlerFunc {
// @Summary List Archer queues (admin)
// @Description Summary metrics per queue (pending, running, failed, scheduled).
// @Tags ArcherAdmin
// @Accept json
// @Produce json
// @Success 200 {array} dto.QueueInfo
// @Failure 401 {string} string "Unauthorized"

View File

@@ -22,7 +22,6 @@ import (
// @Summary List node labels (org scoped)
// @Description Returns node labels for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node groups.
// @Tags Labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key"
@@ -74,7 +73,6 @@ func ListLabels(db *gorm.DB) http.HandlerFunc {
// @Summary Get label by ID (org scoped)
// @Description Returns one label.
// @Tags Labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label ID (UUID)"
@@ -253,11 +251,10 @@ func UpdateLabel(db *gorm.DB) http.HandlerFunc {
// @Summary Delete label (org scoped)
// @Description Permanently deletes the label.
// @Tags Labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label ID (UUID)"
// @Success 204 {string} string "No Content"
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"

View File

@@ -0,0 +1,286 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"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"
)
// ListLoadBalancers godoc
//
// @ID ListLoadBalancers
// @Summary List load balancers (org scoped)
// @Description Returns load balancers for the organization in X-Org-ID.
// @Tags LoadBalancers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Success 200 {array} dto.LoadBalancerResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list clusters"
// @Router /load-balancers [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListLoadBalancers(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
}
var rows []models.LoadBalancer
if err := db.Where("organization_id = ?", orgID).Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.LoadBalancerResponse, 0, len(rows))
for _, row := range rows {
out = append(out, loadBalancerOut(&row))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetLoadBalancer godoc
//
// @ID GetLoadBalancers
// @Summary Get a load balancer (org scoped)
// @Description Returns load balancer for the organization in X-Org-ID.
// @Tags LoadBalancers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "LoadBalancer ID (UUID)"
// @Success 200 {array} dto.LoadBalancerResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list clusters"
// @Router /load-balancers/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetLoadBalancer(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.LoadBalancer
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := loadBalancerOut(&row)
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateLoadBalancer godoc
//
// @ID CreateLoadBalancer
// @Summary Create a load balancer
// @Tags LoadBalancers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateLoadBalancerRequest true "Record set payload"
// @Success 201 {object} dto.LoadBalancerResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "domain not found"
// @Router /load-balancers [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateLoadBalancer(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
}
var in dto.CreateLoadBalancerRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if strings.ToLower(in.Kind) != "glueops" && strings.ToLower(in.Kind) != "public" {
fmt.Println(in.Kind)
utils.WriteError(w, http.StatusBadRequest, "bad_kind", "invalid kind only 'glueops' or 'public'")
return
}
row := &models.LoadBalancer{
OrganizationID: orgID,
Name: in.Name,
Kind: strings.ToLower(in.Kind),
PublicIPAddress: in.PublicIPAddress,
PrivateIPAddress: in.PrivateIPAddress,
}
if err := db.Create(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, loadBalancerOut(row))
}
}
// UpdateLoadBalancer godoc
//
// @ID UpdateLoadBalancer
// @Summary Update a load balancer (org scoped)
// @Tags LoadBalancers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Load Balancer ID (UUID)"
// @Param body body dto.UpdateLoadBalancerRequest true "Fields to update"
// @Success 200 {object} dto.LoadBalancerResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /load-balancers/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateLoadBalancer(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
}
row := &models.LoadBalancer{}
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateLoadBalancerRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if in.Name != nil {
row.Name = *in.Name
}
if in.Kind != nil {
fmt.Println(*in.Kind)
if strings.ToLower(*in.Kind) != "glueops" && strings.ToLower(*in.Kind) != "public" {
utils.WriteError(w, http.StatusBadRequest, "bad_kind", "invalid kind only 'glueops' or 'public'")
return
}
row.Kind = strings.ToLower(*in.Kind)
}
if in.PublicIPAddress != nil {
row.PublicIPAddress = *in.PublicIPAddress
}
if in.PrivateIPAddress != nil {
row.PrivateIPAddress = *in.PrivateIPAddress
}
if err := db.Save(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, loadBalancerOut(row))
}
}
// DeleteLoadBalancer godoc
//
// @ID DeleteLoadBalancer
// @Summary Delete a load balancer
// @Tags LoadBalancers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Load Balancer ID (UUID)"
// @Success 204
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /load-balancers/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteLoadBalancer(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
}
row := &models.LoadBalancer{}
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
if err := db.Delete(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// ---------- Out mappers ----------
func loadBalancerOut(m *models.LoadBalancer) dto.LoadBalancerResponse {
return dto.LoadBalancerResponse{
ID: m.ID,
OrganizationID: m.OrganizationID,
Name: m.Name,
Kind: m.Kind,
PublicIPAddress: m.PublicIPAddress,
PrivateIPAddress: m.PrivateIPAddress,
CreatedAt: m.CreatedAt.UTC(),
UpdatedAt: m.UpdatedAt.UTC(),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,381 @@
package handlers
import (
"os"
"testing"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/testutil/pgtest"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestMain(m *testing.M) {
code := m.Run()
pgtest.Stop()
os.Exit(code)
}
func TestParseUUIDs_Success(t *testing.T) {
u1 := uuid.New()
u2 := uuid.New()
got, err := parseUUIDs([]string{u1.String(), u2.String()})
if err != nil {
t.Fatalf("parseUUIDs returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 UUIDs, got %d", len(got))
}
if got[0] != u1 || got[1] != u2 {
t.Fatalf("unexpected UUIDs: got=%v", got)
}
}
func TestParseUUIDs_Invalid(t *testing.T) {
_, err := parseUUIDs([]string{"not-a-uuid"})
if err == nil {
t.Fatalf("expected error for invalid UUID, got nil")
}
}
// --- ensureServersBelongToOrg ---
func TestEnsureServersBelongToOrg_AllBelong(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-a"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
sshKey := createTestSshKey(t, db, org.ID, "org-a-key")
s1 := models.Server{
OrganizationID: org.ID,
Hostname: "srv-1",
SSHUser: "ubuntu",
SshKeyID: sshKey.ID,
Role: "worker",
Status: "pending",
}
s2 := models.Server{
OrganizationID: org.ID,
Hostname: "srv-2",
SSHUser: "ubuntu",
SshKeyID: sshKey.ID,
Role: "worker",
Status: "pending",
}
if err := db.Create(&s1).Error; err != nil {
t.Fatalf("create server 1: %v", err)
}
if err := db.Create(&s2).Error; err != nil {
t.Fatalf("create server 2: %v", err)
}
ids := []uuid.UUID{s1.ID, s2.ID}
if err := ensureServersBelongToOrg(org.ID, ids, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureServersBelongToOrg_ForeignOrgFails(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
sshKeyA := createTestSshKey(t, db, orgA.ID, "org-a-key")
sshKeyB := createTestSshKey(t, db, orgB.ID, "org-b-key")
s1 := models.Server{
OrganizationID: orgA.ID,
Hostname: "srv-a-1",
SSHUser: "ubuntu",
SshKeyID: sshKeyA.ID,
Role: "worker",
Status: "pending",
}
s2 := models.Server{
OrganizationID: orgB.ID,
Hostname: "srv-b-1",
SSHUser: "ubuntu",
SshKeyID: sshKeyB.ID,
Role: "worker",
Status: "pending",
}
if err := db.Create(&s1).Error; err != nil {
t.Fatalf("create server s1: %v", err)
}
if err := db.Create(&s2).Error; err != nil {
t.Fatalf("create server s2: %v", err)
}
ids := []uuid.UUID{s1.ID, s2.ID}
if err := ensureServersBelongToOrg(orgA.ID, ids, db); err == nil {
t.Fatalf("expected error when one server belongs to a different org")
}
}
// --- ensureTaintsBelongToOrg ---
func TestEnsureTaintsBelongToOrg_AllBelong(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-taints"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
t1 := models.Taint{
OrganizationID: org.ID,
Key: "key1",
Value: "val1",
Effect: "NoSchedule",
}
t2 := models.Taint{
OrganizationID: org.ID,
Key: "key2",
Value: "val2",
Effect: "PreferNoSchedule",
}
if err := db.Create(&t1).Error; err != nil {
t.Fatalf("create taint 1: %v", err)
}
if err := db.Create(&t2).Error; err != nil {
t.Fatalf("create taint 2: %v", err)
}
ids := []uuid.UUID{t1.ID, t2.ID}
if err := ensureTaintsBelongToOrg(org.ID, ids, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureTaintsBelongToOrg_ForeignOrgFails(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
t1 := models.Taint{
OrganizationID: orgA.ID,
Key: "key1",
Value: "val1",
Effect: "NoSchedule",
}
t2 := models.Taint{
OrganizationID: orgB.ID,
Key: "key2",
Value: "val2",
Effect: "NoSchedule",
}
if err := db.Create(&t1).Error; err != nil {
t.Fatalf("create taint 1: %v", err)
}
if err := db.Create(&t2).Error; err != nil {
t.Fatalf("create taint 2: %v", err)
}
ids := []uuid.UUID{t1.ID, t2.ID}
if err := ensureTaintsBelongToOrg(orgA.ID, ids, db); err == nil {
t.Fatalf("expected error when a taint belongs to another org")
}
}
// --- ensureLabelsBelongToOrg ---
func TestEnsureLabelsBelongToOrg_AllBelong(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-labels"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
l1 := models.Label{
AuditFields: common.AuditFields{
OrganizationID: org.ID,
},
Key: "env",
Value: "dev",
}
l2 := models.Label{
AuditFields: common.AuditFields{
OrganizationID: org.ID,
},
Key: "env",
Value: "prod",
}
if err := db.Create(&l1).Error; err != nil {
t.Fatalf("create label 1: %v", err)
}
if err := db.Create(&l2).Error; err != nil {
t.Fatalf("create label 2: %v", err)
}
ids := []uuid.UUID{l1.ID, l2.ID}
if err := ensureLabelsBelongToOrg(org.ID, ids, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureLabelsBelongToOrg_ForeignOrgFails(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
l1 := models.Label{
AuditFields: common.AuditFields{
OrganizationID: orgA.ID,
},
Key: "env",
Value: "dev",
}
l2 := models.Label{
AuditFields: common.AuditFields{
OrganizationID: orgB.ID,
},
Key: "env",
Value: "prod",
}
if err := db.Create(&l1).Error; err != nil {
t.Fatalf("create label 1: %v", err)
}
if err := db.Create(&l2).Error; err != nil {
t.Fatalf("create label 2: %v", err)
}
ids := []uuid.UUID{l1.ID, l2.ID}
if err := ensureLabelsBelongToOrg(orgA.ID, ids, db); err == nil {
t.Fatalf("expected error when a label belongs to another org")
}
}
// --- ensureAnnotaionsBelongToOrg (typo in original name is preserved) ---
func TestEnsureAnnotationsBelongToOrg_AllBelong(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-annotations"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
a1 := models.Annotation{
AuditFields: common.AuditFields{
OrganizationID: org.ID,
},
Key: "team",
Value: "core",
}
a2 := models.Annotation{
AuditFields: common.AuditFields{
OrganizationID: org.ID,
},
Key: "team",
Value: "platform",
}
if err := db.Create(&a1).Error; err != nil {
t.Fatalf("create annotation 1: %v", err)
}
if err := db.Create(&a2).Error; err != nil {
t.Fatalf("create annotation 2: %v", err)
}
ids := []uuid.UUID{a1.ID, a2.ID}
if err := ensureAnnotaionsBelongToOrg(org.ID, ids, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureAnnotationsBelongToOrg_ForeignOrgFails(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
a1 := models.Annotation{
AuditFields: common.AuditFields{
OrganizationID: orgA.ID,
},
Key: "team",
Value: "core",
}
a2 := models.Annotation{
AuditFields: common.AuditFields{
OrganizationID: orgB.ID,
},
Key: "team",
Value: "platform",
}
if err := db.Create(&a1).Error; err != nil {
t.Fatalf("create annotation 1: %v", err)
}
if err := db.Create(&a2).Error; err != nil {
t.Fatalf("create annotation 2: %v", err)
}
ids := []uuid.UUID{a1.ID, a2.ID}
if err := ensureAnnotaionsBelongToOrg(orgA.ID, ids, db); err == nil {
t.Fatalf("expected error when an annotation belongs to another org")
}
}
func createTestSshKey(t *testing.T, db *gorm.DB, orgID uuid.UUID, name string) models.SshKey {
t.Helper()
key := models.SshKey{
AuditFields: common.AuditFields{
OrganizationID: orgID,
},
Name: name,
PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey",
EncryptedPrivateKey: "encrypted",
PrivateIV: "iv",
PrivateTag: "tag",
Fingerprint: "fp-" + name,
}
if err := db.Create(&key).Error; err != nil {
t.Fatalf("create ssh key %s: %v", name, err)
}
return key
}

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())

View File

@@ -22,7 +22,6 @@ import (
// @Summary List servers (org scoped)
// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param status query string false "Filter by status (pending|provisioning|ready|failed)"
@@ -89,7 +88,6 @@ func ListServers(db *gorm.DB) http.HandlerFunc {
// @Summary Get server by ID (org scoped)
// @Description Returns one server in the given organization.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
@@ -329,11 +327,10 @@ func UpdateServer(db *gorm.DB) http.HandlerFunc {
// @Summary Delete server (org scoped)
// @Description Permanently deletes the server.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 204 {string} string "No Content"
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
@@ -370,6 +367,63 @@ func DeleteServer(db *gorm.DB) http.HandlerFunc {
}
}
// ResetServerHostKey godoc
//
// @ID ResetServerHostKey
// @Summary Reset SSH host key (org scoped)
// @Description Clears the stored SSH host key for this server. The next SSH connection will re-learn the host key (trust-on-first-use).
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 200 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "reset failed"
// @Router /servers/{id}/reset-hostkey [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ResetServerHostKey(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, "id_invalid", "invalid id")
return
}
var server models.Server
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&server).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
return
}
// Clear stored host key so next SSH handshake will TOFU and persist a new one.
server.SSHHostKey = ""
server.SSHHostKeyAlgo = ""
if err := db.Save(&server).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to reset host key")
return
}
utils.WriteJSON(w, http.StatusOK, server)
}
}
// --- Helpers ---
func validStatus(status string) bool {

View File

@@ -0,0 +1,78 @@
package handlers
import (
"testing"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/testutil/pgtest"
"github.com/google/uuid"
)
func TestValidStatus(t *testing.T) {
// known-good statuses from servers.go
valid := []string{"pending", "provisioning", "ready", "failed"}
for _, s := range valid {
if !validStatus(s) {
t.Errorf("expected validStatus(%q) = true, got false", s)
}
}
invalid := []string{"foobar", "unknown"}
for _, s := range invalid {
if validStatus(s) {
t.Errorf("expected validStatus(%q) = false, got true", s)
}
}
}
func TestEnsureKeyBelongsToOrg_Success(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "servers-org"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
key := createTestSshKey(t, db, org.ID, "org-key")
if err := ensureKeyBelongsToOrg(org.ID, key.ID, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureKeyBelongsToOrg_WrongOrg(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
keyA := createTestSshKey(t, db, orgA.ID, "org-a-key")
// ask for orgB with a key that belongs to orgA → should fail
if err := ensureKeyBelongsToOrg(orgB.ID, keyA.ID, db); err == nil {
t.Fatalf("expected error when ssh key belongs to a different org, got nil")
}
}
func TestEnsureKeyBelongsToOrg_NotFound(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-nokey"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
// random keyID that doesn't exist
randomKeyID := uuid.New()
if err := ensureKeyBelongsToOrg(org.ID, randomKeyID, db); err == nil {
t.Fatalf("expected error when ssh key does not exist, got nil")
}
}

View File

@@ -31,7 +31,6 @@ import (
// @Summary List ssh keys (org scoped)
// @Description Returns ssh keys for the organization in X-Org-ID.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Success 200 {array} dto.SshResponse
@@ -62,6 +61,9 @@ func ListPublicSshKeys(db *gorm.DB) http.HandlerFunc {
return
}
if out == nil {
out = []dto.SshResponse{}
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
@@ -186,7 +188,6 @@ func CreateSSHKey(db *gorm.DB) http.HandlerFunc {
// @Summary Get ssh key by ID (org scoped)
// @Description Returns public key fields. Append `?reveal=true` to include the private key PEM.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
@@ -280,11 +281,10 @@ func GetSSHKey(db *gorm.DB) http.HandlerFunc {
// @Summary Delete ssh keypair (org scoped)
// @Description Permanently deletes a keypair.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Success 204 {string} string "No Content"
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
@@ -379,7 +379,6 @@ func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
}
if mode == "json" {
prefix := keyFilenamePrefix(key.PublicKey)
resp := dto.SshMaterialJSON{
ID: key.ID.String(),
Name: key.Name,
@@ -389,7 +388,7 @@ func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
case "public":
pub := key.PublicKey
resp.PublicKey = &pub
resp.Filenames = []string{fmt.Sprintf("%s_%s.pub", prefix, key.ID.String())}
resp.Filenames = []string{fmt.Sprintf("%s.pub", key.ID.String())}
utils.WriteJSON(w, http.StatusOK, resp)
return
@@ -400,7 +399,7 @@ func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
return
}
resp.PrivatePEM = &plain
resp.Filenames = []string{fmt.Sprintf("%s_%s.pem", prefix, key.ID.String())}
resp.Filenames = []string{fmt.Sprintf("%s.pem", key.ID.String())}
utils.WriteJSON(w, http.StatusOK, resp)
return
@@ -413,16 +412,16 @@ func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("%s_%s.pem", prefix, key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("%s_%s.pub", prefix, key.ID.String()), []byte(key.PublicKey), zw)
_ = toZipFile(fmt.Sprintf("%s.pem", key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
b64 := utils.EncodeB64(buf.Bytes())
resp.ZipBase64 = &b64
resp.Filenames = []string{
fmt.Sprintf("%s_%s.zip", prefix, key.ID.String()),
fmt.Sprintf("%s_%s.pem", prefix, key.ID.String()),
fmt.Sprintf("%s_%s.pub", prefix, key.ID.String()),
fmt.Sprintf("%s.zip", key.ID.String()),
fmt.Sprintf("%s.pem", key.ID.String()),
fmt.Sprintf("%s.pub", key.ID.String()),
}
utils.WriteJSON(w, http.StatusOK, resp)
return
@@ -433,11 +432,9 @@ func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
}
}
prefix := keyFilenamePrefix(key.PublicKey)
switch part {
case "public":
filename := fmt.Sprintf("%s_%s.pub", prefix, key.ID.String())
filename := fmt.Sprintf("%s.pub", key.ID.String())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(key.PublicKey))
@@ -449,7 +446,7 @@ func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
filename := fmt.Sprintf("%s_%s.pem", prefix, key.ID.String())
filename := fmt.Sprintf("%s.pem", key.ID.String())
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(plain))
@@ -464,8 +461,8 @@ func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("%s_%s.pem", prefix, key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("%s_%s.pub", prefix, key.ID.String()), []byte(key.PublicKey), zw)
_ = toZipFile(fmt.Sprintf("%s.pem", key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
filename := fmt.Sprintf("ssh_key_%s.zip", key.ID.String())

View File

@@ -22,7 +22,6 @@ import (
// @Summary List node pool taints (org scoped)
// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key"
@@ -70,7 +69,6 @@ func ListTaints(db *gorm.DB) http.HandlerFunc {
// @ID GetTaint
// @Summary Get node taint by ID (org scoped)
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
@@ -279,11 +277,10 @@ func UpdateTaint(db *gorm.DB) http.HandlerFunc {
// @Summary Delete taint (org scoped)
// @Description Permanently deletes the taint.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Success 204 {string} string "No Content"
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"

View File

@@ -0,0 +1,64 @@
package handlers
import (
"net/http"
"runtime"
"runtime/debug"
"strconv"
"github.com/glueops/autoglue/internal/utils"
"github.com/glueops/autoglue/internal/version"
)
type VersionResponse struct {
Version string `json:"version" example:"1.4.2"`
Commit string `json:"commit" example:"a1b2c3d"`
Built string `json:"built" example:"2025-11-08T12:34:56Z"`
BuiltBy string `json:"builtBy" example:"ci"`
Go string `json:"go" example:"go1.23.3"`
GOOS string `json:"goOS" example:"linux"`
GOARCH string `json:"goArch" example:"amd64"`
VCS string `json:"vcs,omitempty" example:"git"`
Revision string `json:"revision,omitempty" example:"a1b2c3d4e5f6abcdef"`
CommitTime string `json:"commitTime,omitempty" example:"2025-11-08T12:31:00Z"`
Modified *bool `json:"modified,omitempty" example:"false"`
}
// Version godoc
//
// @Summary Service version information
// @Description Returns build/runtime metadata for the running service.
// @Tags Meta
// @ID Version // operationId
// @Produce json
// @Success 200 {object} VersionResponse
// @Router /version [get]
func Version(w http.ResponseWriter, r *http.Request) {
resp := VersionResponse{
Version: version.Version,
Commit: version.Commit,
Built: version.Date,
BuiltBy: version.BuiltBy,
Go: runtime.Version(),
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
}
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs":
resp.VCS = s.Value
case "vcs.revision":
resp.Revision = s.Value
case "vcs.time":
resp.CommitTime = s.Value
case "vcs.modified":
if b, err := strconv.ParseBool(s.Value); err == nil {
resp.Modified = &b
}
}
}
}
utils.WriteJSON(w, http.StatusOK, resp)
}

182
internal/mapper/cluster.go Normal file
View File

@@ -0,0 +1,182 @@
package mapper
import (
"fmt"
"time"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
)
func ClusterToDTO(c models.Cluster) dto.ClusterResponse {
var bastion *dto.ServerResponse
if c.BastionServer != nil {
b := ServerToDTO(*c.BastionServer)
bastion = &b
}
var captainDomain *dto.DomainResponse
if c.CaptainDomainID != nil && c.CaptainDomain.ID != uuid.Nil {
dr := DomainToDTO(c.CaptainDomain)
captainDomain = &dr
}
var controlPlane *dto.RecordSetResponse
if c.ControlPlaneRecordSet != nil {
rr := RecordSetToDTO(*c.ControlPlaneRecordSet)
controlPlane = &rr
}
var cfqdn *string
if captainDomain != nil && controlPlane != nil {
fq := fmt.Sprintf("%s.%s", controlPlane.Name, captainDomain.DomainName)
cfqdn = &fq
}
var appsLB *dto.LoadBalancerResponse
if c.AppsLoadBalancer != nil {
lr := LoadBalancerToDTO(*c.AppsLoadBalancer)
appsLB = &lr
}
var glueOpsLB *dto.LoadBalancerResponse
if c.GlueOpsLoadBalancer != nil {
lr := LoadBalancerToDTO(*c.GlueOpsLoadBalancer)
glueOpsLB = &lr
}
nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
for _, np := range c.NodePools {
nps = append(nps, NodePoolToDTO(np))
}
return dto.ClusterResponse{
ID: c.ID,
Name: c.Name,
CaptainDomain: captainDomain,
ControlPlaneRecordSet: controlPlane,
ControlPlaneFQDN: cfqdn,
AppsLoadBalancer: appsLB,
GlueOpsLoadBalancer: glueOpsLB,
BastionServer: bastion,
Provider: c.Provider,
Region: c.Region,
Status: c.Status,
LastError: c.LastError,
RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey,
NodePools: nps,
DockerImage: c.DockerImage,
DockerTag: c.DockerTag,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
func NodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
labels := make([]dto.LabelResponse, 0, len(np.Labels))
for _, l := range np.Labels {
labels = append(labels, dto.LabelResponse{
Key: l.Key,
Value: l.Value,
})
}
annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
for _, a := range np.Annotations {
annotations = append(annotations, dto.AnnotationResponse{
Key: a.Key,
Value: a.Value,
})
}
taints := make([]dto.TaintResponse, 0, len(np.Taints))
for _, t := range np.Taints {
taints = append(taints, dto.TaintResponse{
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
})
}
servers := make([]dto.ServerResponse, 0, len(np.Servers))
for _, s := range np.Servers {
servers = append(servers, ServerToDTO(s))
}
return dto.NodePoolResponse{
AuditFields: common.AuditFields{
ID: np.ID,
OrganizationID: np.OrganizationID,
CreatedAt: np.CreatedAt,
UpdatedAt: np.UpdatedAt,
},
Name: np.Name,
Role: dto.NodeRole(np.Role),
Labels: labels,
Annotations: annotations,
Taints: taints,
Servers: servers,
}
}
func ServerToDTO(s models.Server) dto.ServerResponse {
return dto.ServerResponse{
ID: s.ID,
Hostname: s.Hostname,
PrivateIPAddress: s.PrivateIPAddress,
PublicIPAddress: s.PublicIPAddress,
Role: s.Role,
Status: s.Status,
SSHUser: s.SSHUser,
SshKeyID: s.SshKeyID,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func DomainToDTO(d models.Domain) dto.DomainResponse {
return dto.DomainResponse{
ID: d.ID.String(),
OrganizationID: d.OrganizationID.String(),
DomainName: d.DomainName,
ZoneID: d.ZoneID,
Status: d.Status,
LastError: d.LastError,
CredentialID: d.CredentialID.String(),
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func RecordSetToDTO(rs models.RecordSet) dto.RecordSetResponse {
return dto.RecordSetResponse{
ID: rs.ID.String(),
DomainID: rs.DomainID.String(),
Name: rs.Name,
Type: rs.Type,
TTL: rs.TTL,
Values: []byte(rs.Values),
Fingerprint: rs.Fingerprint,
Status: rs.Status,
Owner: rs.Owner,
LastError: rs.LastError,
CreatedAt: rs.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: rs.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func LoadBalancerToDTO(lb models.LoadBalancer) dto.LoadBalancerResponse {
return dto.LoadBalancerResponse{
ID: lb.ID,
OrganizationID: lb.OrganizationID,
Name: lb.Name,
Kind: lb.Kind,
PublicIPAddress: lb.PublicIPAddress,
PrivateIPAddress: lb.PrivateIPAddress,
CreatedAt: lb.CreatedAt,
UpdatedAt: lb.UpdatedAt,
}
}

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

@@ -9,4 +9,5 @@ type Annotation struct {
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
NodePools []NodePool `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
}

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

18
internal/models/backup.go Normal file
View File

@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Backup struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_credential,priority:1"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Enabled bool `gorm:"not null;default:false" json:"enabled"`
CredentialID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uniq_org_credential,priority:2" json:"credential_id"`
Credential Credential `gorm:"foreignKey:CredentialID" json:"credential,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

View File

@@ -0,0 +1,48 @@
package models
import (
"time"
"github.com/google/uuid"
)
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
ClusterStatusBootstrapping = "bootstrapping"
)
type Cluster struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Provider string `json:"provider"`
Region string `json:"region"`
Status string `gorm:"type:varchar(20);not null;default:'pre_pending'" json:"status"`
LastError string `gorm:"type:text;not null;default:''" json:"last_error"`
CaptainDomainID *uuid.UUID `gorm:"type:uuid" json:"captain_domain_id"`
CaptainDomain Domain `gorm:"foreignKey:CaptainDomainID" json:"captain_domain"`
ControlPlaneRecordSetID *uuid.UUID `gorm:"type:uuid" json:"control_plane_record_set_id,omitempty"`
ControlPlaneRecordSet *RecordSet `gorm:"foreignKey:ControlPlaneRecordSetID" json:"control_plane_record_set,omitempty"`
AppsLoadBalancerID *uuid.UUID `gorm:"type:uuid" json:"apps_load_balancer_id,omitempty"`
AppsLoadBalancer *LoadBalancer `gorm:"foreignKey:AppsLoadBalancerID" json:"apps_load_balancer,omitempty"`
GlueOpsLoadBalancerID *uuid.UUID `gorm:"type:uuid" json:"glueops_load_balancer_id,omitempty"`
GlueOpsLoadBalancer *LoadBalancer `gorm:"foreignKey:GlueOpsLoadBalancerID" json:"glueops_load_balancer,omitempty"`
BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"`
BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"`
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
KubeIV string `json:"-"`
KubeTag string `json:"-"`
DockerImage string `json:"docker_image"`
DockerTag string `json:"docker_tag"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

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

@@ -0,0 +1,29 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
)
type Credential struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_provider_scopekind_scope,priority:1" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Provider string `gorm:"type:varchar(50);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:2;index:idx_provider_kind"`
Kind string `gorm:"type:varchar(50);not null;index:idx_provider_kind;index:idx_kind_scope"`
ScopeKind string `gorm:"type:varchar(20);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:3"`
Scope datatypes.JSON `gorm:"type:jsonb;not null;default:'{}';index:idx_kind_scope"`
ScopeFingerprint string `gorm:"type:char(64);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:4;index"`
SchemaVersion int `gorm:"not null;default:1"`
Name string `gorm:"type:varchar(100);not null;default:''"`
ScopeVersion int `gorm:"not null;default:1"`
AccountID string `gorm:"type:varchar(32)"`
Region string `gorm:"type:varchar(32)"`
EncryptedData string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"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"`
}

41
internal/models/domain.go Normal file
View File

@@ -0,0 +1,41 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
)
type Domain struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_domain,priority:1"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
DomainName string `gorm:"type:varchar(253);not null;uniqueIndex:uniq_org_domain,priority:2"`
ZoneID string `gorm:"type:varchar(128);not null;default:''"` // backfilled for R53 (e.g. "/hostedzone/Z123...")
Status string `gorm:"type:varchar(20);not null;default:'pending'"` // pending, provisioning, ready, failed
LastError string `gorm:"type:text;not null;default:''"`
CredentialID uuid.UUID `gorm:"type:uuid;not null" json:"credential_id"`
Credential Credential `gorm:"foreignKey:CredentialID" json:"credential,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}
type RecordSet struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
DomainID uuid.UUID `gorm:"type:uuid;not null;index"`
Domain Domain `gorm:"foreignKey:DomainID;constraint:OnDelete:CASCADE"`
Name string `gorm:"type:varchar(253);not null"` // e.g. "endpoint" (relative to DomainName)
Type string `gorm:"type:varchar(10);not null;index"` // A, AAAA, CNAME, TXT, MX, SRV, NS, CAA...
TTL *int `gorm:""` // nil for alias targets (Route 53 ignores TTL for alias)
Values datatypes.JSON `gorm:"type:jsonb;not null;default:'[]'"`
Fingerprint string `gorm:"type:char(64);not null;index"` // sha256 of canonical(name,type,ttl,values|alias)
Status string `gorm:"type:varchar(20);not null;default:'pending'"`
Owner string `gorm:"type:varchar(16);not null;default:'unknown'"` // 'autoglue' | 'external' | 'unknown'
LastError string `gorm:"type:text;not null;default:''"`
_ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:1"` // tag holder
_ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:2"`
_ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:3"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

View File

@@ -9,5 +9,5 @@ type Label struct {
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
NodePools []NodePool `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
NodePools []NodePool `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
}

View File

@@ -0,0 +1,19 @@
package models
import (
"time"
"github.com/google/uuid"
)
type LoadBalancer struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
Organization Organization `json:"organization" gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"not null"`
Kind string `json:"kind" gorm:"not null"`
PublicIPAddress string `json:"public_ip_address" gorm:"not null"`
PrivateIPAddress string `json:"private_ip_address" gorm:"not null"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

View File

@@ -1,23 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
"github.com/glueops/autoglue/internal/common"
)
type NodePool struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
//Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
common.AuditFields
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
//Topology string `gorm:"not null,default:'stacked'" json:"topology,omitempty"` // stacked or external
Role string `gorm:"not null,default:'worker'" json:"role,omitempty"` // master, worker, or etcd (etcd only if topology = external
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
Role string `gorm:"not null,default:'worker'" json:"role,omitempty"` // master, worker, or etcd (etcd only if topology = external
}

View File

@@ -19,8 +19,11 @@ type Server struct {
SSHUser string `gorm:"not null" json:"ssh_user"`
SshKeyID uuid.UUID `gorm:"type:uuid;not null" json:"ssh_key_id"`
SshKey SshKey `gorm:"foreignKey:SshKeyID" json:"ssh_key"`
Role string `gorm:"not null" json:"role"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'" json:"status"` // pending, provisioning, ready, failed
Role string `gorm:"not null" json:"role" enums:"master,worker,bastion"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'" json:"status" enums:"pending, provisioning, ready, failed"` // pending, provisioning, ready, failed
NodePools []NodePool `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
SSHHostKey string `gorm:"column:ssh_host_key"`
SSHHostKeyAlgo string `gorm:"column:ssh_host_key_algo"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}

View File

@@ -0,0 +1,119 @@
package pgtest
import (
"fmt"
"log"
"sync"
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var (
once sync.Once
epg *embeddedpostgres.EmbeddedPostgres
gdb *gorm.DB
initErr error
dsn string
)
// initDB is called once via sync.Once. It starts embedded Postgres,
// opens a GORM connection and runs the same migrations as NewRuntime.
func initDB() {
const port uint32 = 55432
cfg := embeddedpostgres.
DefaultConfig().
Database("autoglue_test").
Username("autoglue").
Password("autoglue").
Port(port).
StartTimeout(30 * time.Second)
epg = embeddedpostgres.NewDatabase(cfg)
if err := epg.Start(); err != nil {
initErr = fmt.Errorf("start embedded postgres: %w", err)
return
}
dsn = fmt.Sprintf(
"host=127.0.0.1 port=%d user=%s password=%s dbname=%s sslmode=disable",
port,
"autoglue",
"autoglue",
"autoglue_test",
)
dbConn, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
initErr = fmt.Errorf("open gorm: %w", err)
return
}
// Use the same model list as app.NewRuntime so schema matches prod
if err := db.Run(
dbConn,
&models.Job{},
&models.MasterKey{},
&models.SigningKey{},
&models.User{},
&models.Organization{},
&models.Account{},
&models.Membership{},
&models.APIKey{},
&models.UserEmail{},
&models.RefreshToken{},
&models.OrganizationKey{},
&models.SshKey{},
&models.Server{},
&models.Taint{},
&models.Label{},
&models.Annotation{},
&models.NodePool{},
&models.Cluster{},
&models.Credential{},
&models.Domain{},
&models.RecordSet{},
); err != nil {
initErr = fmt.Errorf("migrate: %w", err)
return
}
gdb = dbConn
}
// DB returns a lazily-initialized *gorm.DB backed by embedded Postgres.
//
// Call this from any test that needs a real DB. If init fails, the test
// will fail immediately with a clear message.
func DB(t *testing.T) *gorm.DB {
t.Helper()
once.Do(initDB)
if initErr != nil {
t.Fatalf("failed to init embedded postgres: %v", initErr)
}
return gdb
}
// URL returns the DSN for the embedded Postgres instance, useful for code
// that expects a DB URL (e.g. bg.NewJobs).
func URL(t *testing.T) string {
t.Helper()
DB(t) // ensure initialized
return dsn
}
// Stop stops the embedded Postgres process. Call from TestMain in at
// least one package, or let the OS clean it up on process exit.
func Stop() {
if epg != nil {
if err := epg.Stop(); err != nil {
log.Printf("stop embedded postgres: %v", err)
}
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

4167
internal/web/dist/assets/index-CyGsiYei.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More