mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 21:00:06 +01:00
Compare commits
352 Commits
v0.6.12
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5b1cc277 | ||
|
|
a40da0c23d | ||
|
|
1c740da56d | ||
|
|
0136c8ef78 | ||
|
|
d4fdd85671 | ||
|
|
5d3f0bfaf6 | ||
|
|
3926bafa5c | ||
|
|
210a056fe6 | ||
|
|
b864187af5 | ||
|
|
10725d9263 | ||
|
|
84d5575ea8 | ||
|
|
b7b2c3c65d | ||
|
|
94cd1ca2df | ||
|
|
9379e8dd05 | ||
|
|
e0b15f6b9e | ||
|
|
accf590d24 | ||
|
|
b910ff52ab | ||
|
|
1e498b309b | ||
|
|
85601bcb84 | ||
|
|
ce348a65ea | ||
|
|
37153a6eed | ||
|
|
936891d110 | ||
|
|
965a5ed88b | ||
|
|
c2b76f1dea | ||
|
|
5c9768b0b2 | ||
|
|
a02c29f333 | ||
|
|
223d89bdd1 | ||
|
|
411c98174a | ||
|
|
a7657368ed | ||
|
|
134153c197 | ||
|
|
b4905b8c59 | ||
|
|
f9dc0d4db5 | ||
|
|
3d6e53d24d | ||
|
|
2f6a382733 | ||
|
|
8261b5d702 | ||
|
|
23415ead6d | ||
|
|
ba8098acac | ||
|
|
da5801cc6b | ||
|
|
809c0930ae | ||
|
|
17af4e8da4 | ||
|
|
039fb04c97 | ||
|
|
335fd10fab | ||
|
|
a3ec862e12 | ||
|
|
66595daa4b | ||
|
|
d6d1806907 | ||
|
|
3c61f66c9a | ||
|
|
0fb6fa3389 | ||
|
|
4cd9f77694 | ||
|
|
f6af0d970f | ||
|
|
1138b346e3 | ||
|
|
4e612652a2 | ||
|
|
4127c0dd06 | ||
|
|
8e97df134a | ||
|
|
636b500108 | ||
|
|
2e3fd44b34 | ||
|
|
2bfd8b0a78 | ||
|
|
c7fb0bcf18 | ||
|
|
c4a0fbd7b8 | ||
|
|
8616336279 | ||
|
|
d1ea3667e6 | ||
|
|
0f562ac5f4 | ||
|
|
810218124e | ||
|
|
5aff256377 | ||
|
|
bdd6b61859 | ||
|
|
42a86b22dd | ||
|
|
b8cb1e1a2a | ||
|
|
5a4ae19900 | ||
|
|
d9db293894 | ||
|
|
19d5ee7251 | ||
|
|
6b91a5760b | ||
|
|
bbd4c86013 | ||
|
|
99ebcb11a3 | ||
|
|
be1b35da3c | ||
|
|
a6a296f283 | ||
|
|
341ecf8b0a | ||
|
|
92998015ec | ||
|
|
9345c2761c | ||
|
|
6944e5d027 | ||
|
|
48b3bf5d3c | ||
|
|
4c595db85e | ||
|
|
79f3259bd6 | ||
|
|
e0d163181a | ||
|
|
e8d568eba7 | ||
|
|
c21a766dab | ||
|
|
495c1551b4 | ||
|
|
4a5c0df481 | ||
|
|
c92bba5518 | ||
|
|
823088e294 | ||
|
|
938689fda3 | ||
|
|
77332f208f | ||
|
|
711488492c | ||
|
|
27b89722a4 | ||
|
|
c8a537e30f | ||
|
|
98de70b96b | ||
|
|
63c4574f9c | ||
|
|
5e85cad5b7 | ||
|
|
53725bb834 | ||
|
|
6611dc4950 | ||
|
|
49665ffc9c | ||
|
|
ac14ef8fff | ||
|
|
f8e543b595 | ||
|
|
8cc81e52b7 | ||
|
|
d6e28c7fa2 | ||
|
|
9832229194 | ||
|
|
da82998754 | ||
|
|
bca32fe784 | ||
|
|
848e8d5179 | ||
|
|
d3ee38881c | ||
|
|
d39db44aa7 | ||
|
|
01b29a4706 | ||
|
|
bc3bd92d54 | ||
|
|
2057f92b82 | ||
|
|
169283b6c7 | ||
|
|
865270312c | ||
|
|
7cc447c0f5 | ||
|
|
8a0345f7f5 | ||
|
|
bb7114efe9 | ||
|
|
9dd0148764 | ||
|
|
bcc69e1c86 | ||
|
|
a7bf6b43b4 | ||
|
|
ced0a0663f | ||
|
|
dac28d3ea5 | ||
|
|
dd0cefc08a | ||
|
|
842f7c9be6 | ||
|
|
c15311a5a1 | ||
|
|
25ced343c4 | ||
|
|
b72a8d384d | ||
|
|
c786a79b60 | ||
|
|
01b1434842 | ||
|
|
e8c9cde474 | ||
|
|
ae92d05cd4 | ||
|
|
67d50d2b15 | ||
|
|
e5a664b812 | ||
|
|
f722ba8dca | ||
|
|
20e6d8d186 | ||
|
|
85f37cd113 | ||
|
|
fd1a81ecd8 | ||
|
|
793daf3ac3 | ||
|
|
7bef4ef6f1 | ||
|
|
9fa9cd169b | ||
|
|
8812b43346 | ||
|
|
21a6d7d5a1 | ||
|
|
da332c89dd | ||
|
|
fd25825f34 | ||
|
|
de3740e974 | ||
|
|
21dd26503f | ||
|
|
e1da229c30 | ||
|
|
5377e521e9 | ||
|
|
a929561bc8 | ||
|
|
c63f9f1cf3 | ||
|
|
4c02179b70 | ||
|
|
c8289c6936 | ||
|
|
c17caf22a2 | ||
|
|
986eeb9bf9 | ||
|
|
b0bbc13946 | ||
|
|
f50dcae823 | ||
|
|
ab9a77e1f5 | ||
|
|
416b2ff4e2 | ||
|
|
20bef7545c | ||
|
|
15e101439b | ||
|
|
fb0901a812 | ||
|
|
fee4c64551 | ||
|
|
4d37a6363f | ||
|
|
1dbdd04808 | ||
|
|
45b55015ac | ||
|
|
6b191089a5 | ||
|
|
d2e6ff9812 | ||
|
|
98a6cf7e51 | ||
|
|
fb4af74e3c | ||
|
|
1021e06655 | ||
|
|
c6be7bf8eb | ||
|
|
1429c40b2b | ||
|
|
73c4904a42 | ||
|
|
40df22c166 | ||
|
|
500a8d1095 | ||
|
|
eff69ff4ce | ||
|
|
2cd6ee91eb | ||
|
|
2d3800b576 | ||
|
|
eb86f2ad3c | ||
|
|
0b342f2c65 | ||
|
|
e8725b1a7c | ||
|
|
5ec1d3bb0c | ||
|
|
35efd1c0b9 | ||
|
|
8febd35998 | ||
|
|
94f668583e | ||
|
|
9e1bca3dc3 | ||
|
|
a9eb8cebd6 | ||
|
|
fa5bbbe7b9 | ||
|
|
8468317cf9 | ||
|
|
d315536956 | ||
|
|
165fcb51ff | ||
|
|
6f195aa0e6 | ||
|
|
b07d56c93c | ||
|
|
01fb6bfe61 | ||
|
|
06bd37f8d4 | ||
|
|
4f005b541d | ||
|
|
8426f43c5e | ||
|
|
963477a348 | ||
|
|
cdec3896a7 | ||
|
|
ba39f70e21 | ||
|
|
571aa67f39 | ||
|
|
8b01dfb8f0 | ||
|
|
e177a07c94 | ||
|
|
3ea6de756f | ||
|
|
be197a4ec5 | ||
|
|
fb92033555 | ||
|
|
4420ea8ffe | ||
|
|
e1c59e2dbe | ||
|
|
d269f8fa52 | ||
|
|
3ee9c0cddb | ||
|
|
fa97e9411a | ||
|
|
e2f91ffc8c | ||
|
|
9bc47b7fdc | ||
|
|
3d2f98bf7c | ||
|
|
1c875f2634 | ||
|
|
b752cf2aa9 | ||
|
|
a52166af4a | ||
|
|
83abb08534 | ||
|
|
fefad47ab4 | ||
|
|
125c896ef5 | ||
|
|
bc04f4ed8e | ||
|
|
25f2da417d | ||
|
|
79e16c8bf7 | ||
|
|
56a4899d2b | ||
|
|
6e86da8939 | ||
|
|
75dc2689c8 | ||
|
|
2fcfa04a3f | ||
|
|
78ec2744f1 | ||
|
|
6948fcfaec | ||
|
|
4ac43b38d5 | ||
|
|
590d3980be | ||
|
|
e569b90fcf | ||
|
|
49eb568990 | ||
|
|
698c8263ca | ||
|
|
b73f78dd74 | ||
|
|
b250f1d8c2 | ||
|
|
1764cfa03d | ||
|
|
7e33ae3e38 | ||
|
|
590a39ef14 | ||
|
|
154bd738ff | ||
|
|
831c29a774 | ||
|
|
8a5afc358c | ||
|
|
93f4189885 | ||
|
|
e1a53b122b | ||
|
|
2fafe2d752 | ||
|
|
1d82e562f4 | ||
|
|
a6a4315b7c | ||
|
|
f7aa67f522 | ||
|
|
ddcba7e4e8 | ||
|
|
f44ac86010 | ||
|
|
c02eb401a9 | ||
|
|
bc24ae2553 | ||
|
|
f423a53854 | ||
|
|
f9ca5a85ae | ||
|
|
a815cb8131 | ||
|
|
e488974ca8 | ||
|
|
55b5459854 | ||
|
|
76e02ac8e0 | ||
|
|
af19648fe9 | ||
|
|
28c8151b2c | ||
|
|
fa831a74ce | ||
|
|
3c2b7dade5 | ||
|
|
78b9c97de1 | ||
|
|
ae1aa591a2 | ||
|
|
7cae40b00b | ||
|
|
59b158ca0a | ||
|
|
915a9a4342 | ||
|
|
ffa59e66e1 | ||
|
|
1e8d2e8410 | ||
|
|
686511e21b | ||
|
|
8cb1901227 | ||
|
|
388eec726e | ||
|
|
6f696b4e94 | ||
|
|
5c07732b42 | ||
|
|
7949a544a4 | ||
|
|
3f0f6579ef | ||
|
|
efac33fba6 | ||
|
|
22a411fed9 | ||
|
|
83c3116ed9 | ||
|
|
07974c1359 | ||
|
|
d08528586c | ||
|
|
bb745d6a4e | ||
|
|
0f0edf1007 | ||
|
|
56f86a11b4 | ||
|
|
c9fe259a3a | ||
|
|
d163a050d8 | ||
|
|
9853d32b04 | ||
|
|
d0c43df71c | ||
|
|
219ce80e5b | ||
|
|
7985b310c5 | ||
|
|
501785d471 | ||
|
|
3ca32e9ed7 | ||
|
|
b6e5d329a5 | ||
|
|
c0821253ca | ||
|
|
bb8b1f2773 | ||
|
|
33b0dffba7 | ||
|
|
04cc6facaa | ||
|
|
e414204ac9 | ||
|
|
2975baafb9 | ||
|
|
5819d69d3e | ||
|
|
d0ab259047 | ||
|
|
058b07993c | ||
|
|
92fbf004c6 | ||
|
|
1d89bc4312 | ||
|
|
6626565a75 | ||
|
|
165d2a2af1 | ||
|
|
fc1c83ba18 | ||
|
|
2be0eb8180 | ||
|
|
81043419e1 | ||
|
|
f2ff08993a | ||
|
|
fabf456786 | ||
|
|
23c60d4ce8 | ||
|
|
c9bc09ae92 | ||
|
|
6ec8305962 | ||
|
|
8d34198477 | ||
|
|
f79224b831 | ||
|
|
cd8e3f2d86 | ||
|
|
ad0ec48027 | ||
|
|
7a3e56b3ff | ||
|
|
015044abb1 | ||
|
|
afd8c8ceb2 | ||
|
|
b358911b1b | ||
|
|
ad8141a497 | ||
|
|
9485b2ae4f | ||
|
|
cc8e8b38c7 | ||
|
|
586e51b8cc | ||
|
|
ea4c625269 | ||
|
|
b4c108a5be | ||
|
|
3a1ce33bca | ||
|
|
dbb7ec398e | ||
|
|
82847e5027 | ||
|
|
4314599427 | ||
|
|
9108ee8f8f | ||
|
|
1feb3e29e1 | ||
|
|
0e9ce98624 | ||
|
|
96aef81959 | ||
|
|
62232e18f3 | ||
|
|
515327153c | ||
|
|
5f2e885a8e | ||
|
|
01b48efba0 | ||
|
|
1c87566c5b | ||
|
|
ad00a3c45d | ||
|
|
158fdce780 | ||
|
|
c4fd344364 | ||
|
|
953e724ba0 | ||
|
|
256acfd686 | ||
|
|
1dd0a39aad | ||
|
|
7ef0605c2b | ||
|
|
8a92727b88 | ||
|
|
1f9920a04c | ||
|
|
5fd96ec40f | ||
|
|
bc72df3c9a |
10
.github/workflows/docker-publish.yml
vendored
10
.github/workflows/docker-publish.yml
vendored
@@ -33,13 +33,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
# 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'
|
||||
|
||||
@@ -47,13 +47,13 @@ jobs:
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -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
4
.gitignore
vendored
@@ -137,4 +137,6 @@ notes.txt
|
||||
|
||||
.terraform
|
||||
.terraform.lock*
|
||||
terraform.tfstate*
|
||||
terraform.tfstate*
|
||||
|
||||
ui/src/sdk
|
||||
100
.semgrep.yml
Normal file
100
.semgrep.yml
Normal 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
|
||||
@@ -1,7 +1,7 @@
|
||||
#################################
|
||||
# Builder: Go + Node in one
|
||||
#################################
|
||||
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
|
||||
FROM golang:1.25.7-alpine@sha256:81d49e1de26fa223b9ae0b4d5a4065ff8176a7d80aa5ef0bd9f2eee430afe4d7 AS builder
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash git ca-certificates tzdata \
|
||||
@@ -24,7 +24,7 @@ RUN make build
|
||||
#################################
|
||||
# Runtime
|
||||
#################################
|
||||
FROM alpine:3.22@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412
|
||||
FROM alpine:3.23@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata postgresql17-client \
|
||||
&& addgroup -S app && adduser -S app -G app
|
||||
|
||||
22
Makefile
22
Makefile
@@ -30,6 +30,7 @@ UI_SSG_ROUTES ?= /,/login,/docs,/pricing
|
||||
|
||||
# Go versioning (go.mod uses major.minor; you’re 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,8 +99,8 @@ 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 ( \
|
||||
@@ -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
|
||||
@@ -310,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}'
|
||||
|
||||
@@ -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
20
atlas.hcl
Normal 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
51
cmd/db.go
Normal 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)
|
||||
}
|
||||
168
cmd/serve.go
168
cmd/serve.go
@@ -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)
|
||||
|
||||
|
||||
@@ -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:c076638db1e15662150be4fb62b8a6e96ef6ba5bde90c838a0239225854830f7
|
||||
restart: always
|
||||
ports:
|
||||
- "1025:1025"
|
||||
|
||||
19
docs/docs.go
19
docs/docs.go
File diff suppressed because one or more lines are too long
@@ -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
13
docs/openapi.json
Normal file
File diff suppressed because one or more lines are too long
7234
docs/openapi.yaml
Normal file
7234
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
3666
docs/swagger.yaml
3666
docs/swagger.yaml
File diff suppressed because it is too large
Load Diff
100
go.mod
100
go.mod
@@ -4,21 +4,30 @@ 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/go-chi/chi/v5 v5.2.3
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||
github.com/aws/smithy-go v1.24.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/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.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
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
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5
|
||||
golang.org/x/crypto v0.47.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 +36,99 @@ 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.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // 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.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // 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/sv-tools/openapi v0.4.0 // 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.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.40.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
|
||||
)
|
||||
|
||||
275
go.sum
275
go.sum
@@ -1,29 +1,130 @@
|
||||
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 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
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/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||
github.com/aws/aws-sdk-go-v2/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/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||
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/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/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/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/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/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/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/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||
github.com/aws/aws-sdk-go-v2/service/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/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||
github.com/aws/aws-sdk-go-v2/service/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/route53 v1.62.1 h1:1jIdwWOulae7bBLIgB36OZ0DINACb1wxM6wdGlx4eHE=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1/go.mod h1:tE2zGlMIlxWv+7Otap7ctRp3qeKqtnja7DZguj3Vu/Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/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.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/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
@@ -43,6 +144,15 @@ 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.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,15 +160,20 @@ 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=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/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 +186,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 +210,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 +223,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 +291,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 +303,72 @@ 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/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4=
|
||||
github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE=
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc4 h1:SZ8cK68gcV6cslwrJMIOqPkJELRwq4gmjvk77MrvHvY=
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc4/go.mod h1:Ow7Y8gF16BTCDn8YxZbyKn8FkMLRUHekv1kROJZpbvE=
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk=
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI=
|
||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u242MzDP6D0B5vGC+wxm5WRY+alQZy+dJk3bFI=
|
||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/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/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
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/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
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 +376,38 @@ 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/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.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/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
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/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
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 +422,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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
37
internal/api/mount_admin_routes.go
Normal file
37
internal/api/mount_admin_routes.go
Normal 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))
|
||||
})
|
||||
})
|
||||
}
|
||||
20
internal/api/mount_annotation_routes.go
Normal file
20
internal/api/mount_annotation_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
39
internal/api/mount_api_routes.go
Normal file
39
internal/api/mount_api_routes.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
16
internal/api/mount_auth_routes.go
Normal file
16
internal/api/mount_auth_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
46
internal/api/mount_cluster_routes.go
Normal file
46
internal/api/mount_cluster_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
21
internal/api/mount_credential_routes.go
Normal file
21
internal/api/mount_credential_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
53
internal/api/mount_db_studio.go
Normal file
53
internal/api/mount_db_studio.go
Normal 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
|
||||
}
|
||||
27
internal/api/mount_dns_routes.go
Normal file
27
internal/api/mount_dns_routes.go
Normal file
@@ -0,0 +1,27 @@
|
||||
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.Get("/records/{id}", handlers.GetRecordSet(db))
|
||||
d.Patch("/records/{id}", handlers.UpdateRecordSet(db))
|
||||
d.Delete("/records/{id}", handlers.DeleteRecordSet(db))
|
||||
})
|
||||
}
|
||||
20
internal/api/mount_label_routes.go
Normal file
20
internal/api/mount_label_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
20
internal/api/mount_load_balancer_routes.go
Normal file
20
internal/api/mount_load_balancer_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
22
internal/api/mount_me_routes.go
Normal file
22
internal/api/mount_me_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
13
internal/api/mount_meta_routes.go
Normal file
13
internal/api/mount_meta_routes.go
Normal 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)
|
||||
}
|
||||
40
internal/api/mount_node_pool_routes.go
Normal file
40
internal/api/mount_node_pool_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
35
internal/api/mount_org_routes.go
Normal file
35
internal/api/mount_org_routes.go
Normal 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))
|
||||
})
|
||||
})
|
||||
}
|
||||
24
internal/api/mount_pprof_routes.go
Normal file
24
internal/api/mount_pprof_routes.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
21
internal/api/mount_server_routes.go
Normal file
21
internal/api/mount_server_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
20
internal/api/mount_ssh_routes.go
Normal file
20
internal/api/mount_ssh_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
87
internal/api/mount_swagger_routes.go
Normal file
87
internal/api/mount_swagger_routes.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
internal/api/mount_taint_routes.go
Normal file
20
internal/api/mount_taint_routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
@@ -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' 'unsafe-inline'",
|
||||
"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'",
|
||||
}, "; "))
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
@@ -40,7 +37,8 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs) http.Handler {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(SecurityHeaders)
|
||||
r.Use(requestBodyLimit(10 << 20))
|
||||
r.Use(httprate.LimitByIP(100, 1*time.Minute))
|
||||
r.Use(httprate.LimitByIP(1000, 1*time.Minute))
|
||||
r.Use(middleware.StripSlashes)
|
||||
|
||||
allowed := getAllowedOrigins()
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
@@ -59,177 +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.Get("/version", handlers.Version)
|
||||
|
||||
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", 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))
|
||||
})
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
v1.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))
|
||||
})
|
||||
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")
|
||||
@@ -237,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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,14 @@ func NewRuntime() *Runtime {
|
||||
&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 {
|
||||
|
||||
264
internal/bg/backup_s3.go
Normal file
264
internal/bg/backup_s3.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -494,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,55 @@ 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),
|
||||
archer.WithInstances(1),
|
||||
)
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
|
||||
195
internal/bg/cluster_action.go
Normal file
195
internal/bg/cluster_action.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dyaksa/archer"
|
||||
"github.com/dyaksa/archer/job"
|
||||
"github.com/glueops/autoglue/internal/mapper"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ClusterActionArgs struct {
|
||||
OrgID uuid.UUID `json:"org_id"`
|
||||
ClusterID uuid.UUID `json:"cluster_id"`
|
||||
Action string `json:"action"`
|
||||
MakeTarget string `json:"make_target"`
|
||||
}
|
||||
|
||||
type ClusterActionResult struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
ElapsedMs int `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
func ClusterActionWorker(db *gorm.DB) archer.WorkerFn {
|
||||
return func(ctx context.Context, j job.Job) (any, error) {
|
||||
start := time.Now()
|
||||
var args ClusterActionArgs
|
||||
_ = j.ParseArguments(&args)
|
||||
|
||||
runID, _ := uuid.Parse(j.ID)
|
||||
|
||||
updateRun := func(status string, errMsg string) {
|
||||
updates := map[string]any{
|
||||
"status": status,
|
||||
"error": errMsg,
|
||||
}
|
||||
if status == "succeeded" || status == "failed" {
|
||||
updates["finished_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
db.Model(&models.ClusterRun{}).Where("id = ?", runID).Updates(updates)
|
||||
}
|
||||
|
||||
updateRun("running", "")
|
||||
|
||||
logger := log.With().
|
||||
Str("job", j.ID).
|
||||
Str("cluster_id", args.ClusterID.String()).
|
||||
Str("action", args.Action).
|
||||
Logger()
|
||||
|
||||
var c models.Cluster
|
||||
if err := db.
|
||||
Preload("BastionServer.SshKey").
|
||||
Preload("CaptainDomain").
|
||||
Preload("ControlPlaneRecordSet").
|
||||
Preload("AppsLoadBalancer").
|
||||
Preload("GlueOpsLoadBalancer").
|
||||
Preload("NodePools").
|
||||
Preload("NodePools.Labels").
|
||||
Preload("NodePools.Annotations").
|
||||
Preload("NodePools.Taints").
|
||||
Preload("NodePools.Servers.SshKey").
|
||||
Where("id = ? AND organization_id = ?", args.ClusterID, args.OrgID).
|
||||
First(&c).Error; err != nil {
|
||||
updateRun("failed", fmt.Errorf("load cluster: %w", err).Error())
|
||||
return nil, fmt.Errorf("load cluster: %w", err)
|
||||
}
|
||||
|
||||
// ---- Step 1: Prepare (mostly lifted from ClusterPrepareWorker)
|
||||
if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("mark bootstrapping: %w", err)
|
||||
}
|
||||
c.Status = clusterStatusBootstrapping
|
||||
|
||||
if err := validateClusterForPrepare(&c); err != nil {
|
||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("validate: %w", err)
|
||||
}
|
||||
|
||||
allServers := flattenClusterServers(&c)
|
||||
keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, &c, allServers)
|
||||
if err != nil {
|
||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("build ssh assets: %w", err)
|
||||
}
|
||||
|
||||
dtoCluster := mapper.ClusterToDTO(c)
|
||||
|
||||
if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
|
||||
kubeconfig, err := utils.DecryptForOrg(
|
||||
c.OrganizationID,
|
||||
c.EncryptedKubeconfig,
|
||||
c.KubeIV,
|
||||
c.KubeTag,
|
||||
db,
|
||||
)
|
||||
if err != nil {
|
||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||
return nil, fmt.Errorf("decrypt kubeconfig: %w", err)
|
||||
}
|
||||
dtoCluster.Kubeconfig = &kubeconfig
|
||||
}
|
||||
|
||||
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(db, c.OrganizationID, c.ID, 24*time.Hour)
|
||||
if err != nil {
|
||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("org key: %w", err)
|
||||
}
|
||||
dtoCluster.OrgKey = &orgKey
|
||||
dtoCluster.OrgSecret = &orgSecret
|
||||
|
||||
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
|
||||
if err != nil {
|
||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
{
|
||||
runCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
|
||||
err := pushAssetsToBastion(runCtx, db, &c, sshConfig, keyPayloads, payloadJSON)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("push assets: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("mark pending: %w", err)
|
||||
}
|
||||
c.Status = clusterStatusPending
|
||||
|
||||
// ---- Step 2: Setup (ping-servers)
|
||||
{
|
||||
runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
out, err := runMakeOnBastion(runCtx, db, &c, "ping-servers")
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("output", out).Msg("ping-servers failed")
|
||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("ping-servers: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("mark provisioning: %w", err)
|
||||
}
|
||||
c.Status = clusterStatusProvisioning
|
||||
|
||||
// ---- Step 3: Bootstrap (parameterized target)
|
||||
{
|
||||
runCtx, cancel := context.WithTimeout(ctx, 60*time.Minute)
|
||||
out, err := runMakeOnBastion(runCtx, db, &c, args.MakeTarget)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("output", out).Msg("bootstrap target failed")
|
||||
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make %s: %v", args.MakeTarget, err))
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("make %s: %w", args.MakeTarget, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
|
||||
updateRun("failed", err.Error())
|
||||
return nil, fmt.Errorf("mark ready: %w", err)
|
||||
}
|
||||
|
||||
updateRun("succeeded", "")
|
||||
|
||||
return ClusterActionResult{
|
||||
Status: "ok",
|
||||
Action: args.Action,
|
||||
ClusterID: c.ID.String(),
|
||||
ElapsedMs: int(time.Since(start).Milliseconds()),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
121
internal/bg/cluster_bootstrap.go
Normal file
121
internal/bg/cluster_bootstrap.go
Normal 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
|
||||
}
|
||||
}
|
||||
120
internal/bg/cluster_setup.go
Normal file
120
internal/bg/cluster_setup.go
Normal 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
|
||||
}
|
||||
}
|
||||
782
internal/bg/dns.go
Normal file
782
internal/bg/dns.go
Normal file
@@ -0,0 +1,782 @@
|
||||
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"
|
||||
"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"
|
||||
"github.com/aws/smithy-go"
|
||||
smithyhttp "github.com/aws/smithy-go/transport/http"
|
||||
)
|
||||
|
||||
/************* 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"
|
||||
|
||||
// Default TTL for non-alias records (alias not supported in this reconciler)
|
||||
const defaultRecordTTLSeconds int64 = 300
|
||||
|
||||
/************* entrypoint worker *************/
|
||||
|
||||
func DNSReconsileWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
||||
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("zone_id", d.ZoneID).
|
||||
Str("domain", d.DomainName).
|
||||
Str("record_id", records[i].ID.String()).
|
||||
Str("name", records[i].Name).
|
||||
Str("type", strings.ToUpper(records[i].Type)).
|
||||
Msg("[dns] apply record failed")
|
||||
_ = setRecordFailed(db, &records[i], err)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
|
||||
logCtx := log.With().
|
||||
Str("zone_id", zoneID).
|
||||
Str("domain", d.DomainName).
|
||||
Str("fqdn", fq).
|
||||
Str("rr_type", rt).
|
||||
Str("record_id", r.ID.String()).
|
||||
Str("org_id", d.OrganizationID.String()).
|
||||
Logger()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// ---- ExternalDNS preflight ----
|
||||
extOwned, err := hasExternalDNSOwnership(ctx, r53c, zoneID, fq, rt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("external_dns_lookup: %w", err)
|
||||
}
|
||||
if extOwned {
|
||||
logCtx.Warn().Msg("[dns] ownership conflict: external-dns claims this record")
|
||||
r.Owner = "external"
|
||||
_ = db.Save(r).Error
|
||||
return fmt.Errorf("ownership_conflict: external-dns claims %s; refusing to modify", strings.TrimSuffix(fq, "."))
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
}
|
||||
|
||||
logCtx.Debug().
|
||||
Bool("externaldns_owned", extOwned).
|
||||
Int("marker_txt_count", len(markerVals)).
|
||||
Bool("marker_has_our_exact", hasOurExact).
|
||||
Bool("marker_has_foreign", hasForeignOwner).
|
||||
Msg("[dns] ownership preflight")
|
||||
|
||||
if hasForeignOwner {
|
||||
logCtx.Warn().Msg("[dns] ownership conflict: foreign _autoglue marker")
|
||||
r.Owner = "external"
|
||||
_ = db.Save(r).Error
|
||||
return fmt.Errorf("ownership_conflict: marker for %s is owned by another controller; refusing to modify", strings.TrimSuffix(fq, "."))
|
||||
}
|
||||
|
||||
// Decode user values
|
||||
var userVals []string
|
||||
rawVals := strings.TrimSpace(string(r.Values))
|
||||
if rawVals != "" && rawVals != "null" {
|
||||
if err := jsonUnmarshalStrict([]byte(rawVals), &userVals); err != nil {
|
||||
return fmt.Errorf("values decode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Quote TXT values as required by Route53
|
||||
recs := make([]r53types.ResourceRecord, 0, len(userVals))
|
||||
for _, v := range userVals {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if rt == "TXT" && !(strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`)) {
|
||||
v = strconv.Quote(v)
|
||||
}
|
||||
recs = append(recs, r53types.ResourceRecord{Value: aws.String(v)})
|
||||
}
|
||||
|
||||
// Alias is NOT supported - enforce at least one value for all record types we manage
|
||||
if len(recs) == 0 {
|
||||
logCtx.Warn().
|
||||
Str("raw_values", truncateForLog(string(r.Values), 240)).
|
||||
Int("decoded_value_count", len(userVals)).
|
||||
Msg("[dns] invalid record: no values (alias not supported)")
|
||||
return fmt.Errorf("invalid_record: %s %s requires at least one value (alias not supported)", strings.TrimSuffix(fq, "."), rt)
|
||||
}
|
||||
|
||||
ttl := defaultRecordTTLSeconds
|
||||
if r.TTL != nil && *r.TTL > 0 {
|
||||
ttl = int64(*r.TTL)
|
||||
}
|
||||
|
||||
// Build RR change (UPSERT)
|
||||
rrChange := r53types.Change{
|
||||
Action: r53types.ChangeActionUpsert,
|
||||
ResourceRecordSet: &r53types.ResourceRecordSet{
|
||||
Name: aws.String(fq),
|
||||
Type: r53types.RRType(rt),
|
||||
TTL: aws.Int64(ttl),
|
||||
ResourceRecords: recs,
|
||||
},
|
||||
}
|
||||
|
||||
// Build marker TXT change (UPSERT)
|
||||
markerChange := r53types.Change{
|
||||
Action: r53types.ChangeActionUpsert,
|
||||
ResourceRecordSet: &r53types.ResourceRecordSet{
|
||||
Name: aws.String(mname),
|
||||
Type: r53types.RRTypeTxt,
|
||||
TTL: aws.Int64(defaultRecordTTLSeconds),
|
||||
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...)
|
||||
|
||||
// Log what we are about to send
|
||||
logCtx.Debug().
|
||||
Interface("route53_change_batch", toLogChangeBatch(zoneID, changes)).
|
||||
Msg("[dns] route53 request preview")
|
||||
|
||||
_, err = r53c.ChangeResourceRecordSets(ctx, &r53.ChangeResourceRecordSetsInput{
|
||||
HostedZoneId: aws.String(zoneID),
|
||||
ChangeBatch: &r53types.ChangeBatch{Changes: changes},
|
||||
})
|
||||
if err != nil {
|
||||
logAWSError(logCtx, err)
|
||||
logCtx.Info().Dur("elapsed", time.Since(start)).Msg("[dns] apply failed")
|
||||
return err
|
||||
}
|
||||
|
||||
logCtx.Info().
|
||||
Dur("elapsed", time.Since(start)).
|
||||
Int("change_count", len(changes)).
|
||||
Msg("[dns] apply ok")
|
||||
|
||||
// Success → mark ready & ownership
|
||||
r.Status = "ready"
|
||||
r.LastError = ""
|
||||
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(defaultRecordTTLSeconds),
|
||||
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)
|
||||
}
|
||||
|
||||
/************* logging DTOs & helpers *************/
|
||||
|
||||
type logRR struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type logRRSet struct {
|
||||
Action string `json:"action"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL *int64 `json:"ttl,omitempty"`
|
||||
Records []logRR `json:"records,omitempty"`
|
||||
RecordCount int `json:"record_count"`
|
||||
HasAliasTarget bool `json:"has_alias_target"`
|
||||
SetIdentifier *string `json:"set_identifier,omitempty"`
|
||||
}
|
||||
|
||||
type logChangeBatch struct {
|
||||
HostedZoneID string `json:"hosted_zone_id"`
|
||||
ChangeCount int `json:"change_count"`
|
||||
Changes []logRRSet `json:"changes"`
|
||||
}
|
||||
|
||||
func truncateForLog(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
|
||||
func toLogChangeBatch(zoneID string, changes []r53types.Change) logChangeBatch {
|
||||
out := logChangeBatch{
|
||||
HostedZoneID: zoneID,
|
||||
ChangeCount: len(changes),
|
||||
Changes: make([]logRRSet, 0, len(changes)),
|
||||
}
|
||||
|
||||
for _, ch := range changes {
|
||||
if ch.ResourceRecordSet == nil {
|
||||
continue
|
||||
}
|
||||
rrs := ch.ResourceRecordSet
|
||||
lc := logRRSet{
|
||||
Action: string(ch.Action),
|
||||
Name: aws.ToString(rrs.Name),
|
||||
Type: string(rrs.Type),
|
||||
TTL: rrs.TTL,
|
||||
HasAliasTarget: rrs.AliasTarget != nil,
|
||||
SetIdentifier: rrs.SetIdentifier,
|
||||
RecordCount: len(rrs.ResourceRecords),
|
||||
Records: make([]logRR, 0, min(len(rrs.ResourceRecords), 5)),
|
||||
}
|
||||
|
||||
// Log up to first 5 values (truncate each) to avoid log bloat / secrets
|
||||
for i, rr := range rrs.ResourceRecords {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
lc.Records = append(lc.Records, logRR{Value: truncateForLog(aws.ToString(rr.Value), 160)})
|
||||
}
|
||||
|
||||
out.Changes = append(out.Changes, lc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// logAWSError extracts useful smithy/HTTP metadata (status code + request id + api code) into logs.
|
||||
// logAWSError extracts useful smithy/HTTP metadata (status code + request id + api code) into logs.
|
||||
func logAWSError(l zerolog.Logger, err error) {
|
||||
// Add operation context if present
|
||||
var opErr *smithy.OperationError
|
||||
if errors.As(err, &opErr) {
|
||||
l = l.With().
|
||||
Str("aws_service", opErr.ServiceID).
|
||||
Str("aws_operation", opErr.OperationName).
|
||||
Logger()
|
||||
err = opErr.Unwrap()
|
||||
}
|
||||
|
||||
// HTTP status + request id (smithy-go transport/http)
|
||||
var re *smithyhttp.ResponseError
|
||||
if errors.As(err, &re) {
|
||||
status := re.HTTPStatusCode()
|
||||
|
||||
reqID := ""
|
||||
if resp := re.HTTPResponse(); resp != nil && resp.Header != nil {
|
||||
reqID = resp.Header.Get("x-amzn-RequestId")
|
||||
if reqID == "" {
|
||||
reqID = resp.Header.Get("x-amz-request-id")
|
||||
}
|
||||
}
|
||||
|
||||
ev := l.Error().Int("http_status", status).Err(err)
|
||||
if reqID != "" {
|
||||
ev = ev.Str("aws_request_id", reqID)
|
||||
}
|
||||
ev.Msg("[dns] aws route53 call failed")
|
||||
return
|
||||
}
|
||||
|
||||
// API error code/message (best-effort)
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
l.Error().
|
||||
Str("aws_error_code", apiErr.ErrorCode()).
|
||||
Str("aws_error_message", apiErr.ErrorMessage()).
|
||||
Err(err).
|
||||
Msg("[dns] aws route53 api error")
|
||||
return
|
||||
}
|
||||
|
||||
l.Error().Err(err).Msg("[dns] aws route53 error")
|
||||
}
|
||||
95
internal/bg/org_key_sweeper.go
Normal file
95
internal/bg/org_key_sweeper.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/dyaksa/archer"
|
||||
"github.com/dyaksa/archer/job"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OrgKeySweeperArgs struct {
|
||||
IntervalS int `json:"interval_seconds,omitempty"`
|
||||
RetentionDays int `json:"retention_days,omitempty"`
|
||||
}
|
||||
|
||||
type OrgKeySweeperResult struct {
|
||||
Status string `json:"status"`
|
||||
MarkedRevoked int `json:"marked_revoked"`
|
||||
DeletedEphemeral int `json:"deleted_ephemeral"`
|
||||
ElapsedMs int `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
func OrgKeySweeperWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
|
||||
return func(ctx context.Context, j job.Job) (any, error) {
|
||||
args := OrgKeySweeperArgs{
|
||||
IntervalS: 3600,
|
||||
RetentionDays: 10,
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
_ = j.ParseArguments(&args)
|
||||
if args.IntervalS <= 0 {
|
||||
args.IntervalS = 3600
|
||||
}
|
||||
if args.RetentionDays <= 0 {
|
||||
args.RetentionDays = 10
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 1) Mark expired keys as revoked
|
||||
res1 := db.Model(&models.APIKey{}).
|
||||
Where("expires_at IS NOT NULL AND expires_at <= ? AND revoked = false", now).
|
||||
Updates(map[string]any{
|
||||
"revoked": true,
|
||||
"updated_at": now,
|
||||
})
|
||||
|
||||
if res1.Error != nil {
|
||||
log.Error().Err(res1.Error).Msg("[org_key_sweeper] mark expired revoked failed")
|
||||
return nil, res1.Error
|
||||
}
|
||||
markedRevoked := int(res1.RowsAffected)
|
||||
|
||||
// 2) Hard-delete ephemeral keys that are revoked and older than retention
|
||||
cutoff := now.Add(-time.Duration(args.RetentionDays) * 24 * time.Hour)
|
||||
res2 := db.
|
||||
Where("is_ephemeral = ? AND revoked = ? AND updated_at <= ?", true, true, cutoff).
|
||||
Delete(&models.APIKey{})
|
||||
|
||||
if res2.Error != nil {
|
||||
log.Error().Err(res2.Error).Msg("[org_key_sweeper] delete revoked ephemeral keys failed")
|
||||
return nil, res2.Error
|
||||
}
|
||||
deletedEphemeral := int(res2.RowsAffected)
|
||||
|
||||
out := OrgKeySweeperResult{
|
||||
Status: "ok",
|
||||
MarkedRevoked: markedRevoked,
|
||||
DeletedEphemeral: deletedEphemeral,
|
||||
ElapsedMs: int(time.Since(start).Milliseconds()),
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("marked_revoked", markedRevoked).
|
||||
Int("deleted_ephemeral", deletedEphemeral).
|
||||
Msg("[org_key_sweeper] cleanup tick ok")
|
||||
|
||||
// Re-enqueue the sweeper
|
||||
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
|
||||
_, _ = jobs.Enqueue(
|
||||
ctx,
|
||||
uuid.NewString(),
|
||||
"org_key_sweeper",
|
||||
args,
|
||||
archer.WithScheduleTime(next),
|
||||
archer.WithMaxRetries(1),
|
||||
)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
658
internal/bg/prepare_cluster.go
Normal file
658
internal/bg/prepare_cluster.go
Normal 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; we’ll 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
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
DbURL string
|
||||
DbURLRO string
|
||||
Port string
|
||||
Host string
|
||||
JWTIssuer string
|
||||
@@ -29,6 +30,12 @@ type Config struct {
|
||||
Debug bool
|
||||
Swagger bool
|
||||
SwaggerHost string
|
||||
|
||||
DBStudioEnabled bool
|
||||
DBStudioBind string
|
||||
DBStudioPort string
|
||||
DBStudioUser string
|
||||
DBStudioPass string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -48,6 +55,12 @@ 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")
|
||||
@@ -63,6 +76,7 @@ func Load() (Config, error) {
|
||||
"bind.address",
|
||||
"bind.port",
|
||||
"database.url",
|
||||
"database.url_ro",
|
||||
"jwt.issuer",
|
||||
"jwt.audience",
|
||||
"jwt.private.enc.key",
|
||||
@@ -76,6 +90,11 @@ func Load() (Config, error) {
|
||||
"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)
|
||||
@@ -84,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"),
|
||||
@@ -100,6 +120,12 @@ func Load() (Config, error) {
|
||||
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
|
||||
|
||||
256
internal/handlers/actions.go
Normal file
256
internal/handlers/actions.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListActions godoc
|
||||
//
|
||||
// @ID ListActions
|
||||
// @Summary List available actions
|
||||
// @Description Returns all admin-configured actions.
|
||||
// @Tags Actions
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.ActionResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 500 {string} string "db error"
|
||||
// @Router /admin/actions [get]
|
||||
// @Security BearerAuth
|
||||
func ListActions(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var rows []models.Action
|
||||
if err := db.Order("label ASC").Find(&rows).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.ActionResponse, 0, len(rows))
|
||||
for _, a := range rows {
|
||||
out = append(out, actionToDTO(a))
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAction godoc
|
||||
//
|
||||
// @ID GetAction
|
||||
// @Summary Get a single action by ID
|
||||
// @Description Returns a single action.
|
||||
// @Tags Actions
|
||||
// @Produce json
|
||||
// @Param actionID path string true "Action ID"
|
||||
// @Success 200 {object} dto.ActionResponse
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "db error"
|
||||
// @Router /admin/actions/{actionID} [get]
|
||||
// @Security BearerAuth
|
||||
func GetAction(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
|
||||
return
|
||||
}
|
||||
|
||||
var row models.Action
|
||||
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAction godoc
|
||||
//
|
||||
// @ID CreateAction
|
||||
// @Summary Create an action
|
||||
// @Description Creates a new admin-configured action.
|
||||
// @Tags Actions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.CreateActionRequest true "payload"
|
||||
// @Success 201 {object} dto.ActionResponse
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 500 {string} string "db error"
|
||||
// @Router /admin/actions [post]
|
||||
// @Security BearerAuth
|
||||
func CreateAction(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var in dto.CreateActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
label := strings.TrimSpace(in.Label)
|
||||
desc := strings.TrimSpace(in.Description)
|
||||
target := strings.TrimSpace(in.MakeTarget)
|
||||
|
||||
if label == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label is required")
|
||||
return
|
||||
}
|
||||
if desc == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description is required")
|
||||
return
|
||||
}
|
||||
if target == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target is required")
|
||||
return
|
||||
}
|
||||
|
||||
row := models.Action{
|
||||
Label: label,
|
||||
Description: desc,
|
||||
MakeTarget: target,
|
||||
}
|
||||
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusCreated, actionToDTO(row))
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateAction godoc
|
||||
//
|
||||
// @ID UpdateAction
|
||||
// @Summary Update an action
|
||||
// @Description Updates an action. Only provided fields are modified.
|
||||
// @Tags Actions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param actionID path string true "Action ID"
|
||||
// @Param body body dto.UpdateActionRequest true "payload"
|
||||
// @Success 200 {object} dto.ActionResponse
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "db error"
|
||||
// @Router /admin/actions/{actionID} [patch]
|
||||
// @Security BearerAuth
|
||||
func UpdateAction(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
|
||||
return
|
||||
}
|
||||
|
||||
var in dto.UpdateActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var row models.Action
|
||||
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
if in.Label != nil {
|
||||
v := strings.TrimSpace(*in.Label)
|
||||
if v == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label cannot be empty")
|
||||
return
|
||||
}
|
||||
row.Label = v
|
||||
}
|
||||
if in.Description != nil {
|
||||
v := strings.TrimSpace(*in.Description)
|
||||
if v == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description cannot be empty")
|
||||
return
|
||||
}
|
||||
row.Description = v
|
||||
}
|
||||
if in.MakeTarget != nil {
|
||||
v := strings.TrimSpace(*in.MakeTarget)
|
||||
if v == "" {
|
||||
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target cannot be empty")
|
||||
return
|
||||
}
|
||||
row.MakeTarget = v
|
||||
}
|
||||
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAction godoc
|
||||
//
|
||||
// @ID DeleteAction
|
||||
// @Summary Delete an action
|
||||
// @Description Deletes an action.
|
||||
// @Tags Actions
|
||||
// @Produce json
|
||||
// @Param actionID path string true "Action ID"
|
||||
// @Success 204 {string} string "deleted"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "db error"
|
||||
// @Router /admin/actions/{actionID} [delete]
|
||||
// @Security BearerAuth
|
||||
func DeleteAction(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
|
||||
return
|
||||
}
|
||||
|
||||
tx := db.Where("id = ?", actionID).Delete(&models.Action{})
|
||||
if tx.Error != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
if tx.RowsAffected == 0 {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func actionToDTO(a models.Action) dto.ActionResponse {
|
||||
return dto.ActionResponse{
|
||||
ID: a.ID,
|
||||
Label: a.Label,
|
||||
Description: a.Description,
|
||||
MakeTarget: a.MakeTarget,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
263
internal/handlers/cluster_runs.go
Normal file
263
internal/handlers/cluster_runs.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dyaksa/archer"
|
||||
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
||||
"github.com/glueops/autoglue/internal/bg"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/glueops/autoglue/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListClusterRuns godoc
|
||||
//
|
||||
// @ID ListClusterRuns
|
||||
// @Summary List cluster runs (org scoped)
|
||||
// @Description Returns runs for a cluster within the organization in X-Org-ID.
|
||||
// @Tags ClusterRuns
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param clusterID path string true "Cluster ID"
|
||||
// @Success 200 {array} dto.ClusterRunResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "cluster not found"
|
||||
// @Failure 500 {string} string "db error"
|
||||
// @Router /clusters/{clusterID}/runs [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func ListClusterRuns(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure cluster exists + org scoped
|
||||
if err := db.Select("id").
|
||||
Where("id = ? AND organization_id = ?", clusterID, orgID).
|
||||
First(&models.Cluster{}).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
var rows []models.ClusterRun
|
||||
if err := db.
|
||||
Where("organization_id = ? AND cluster_id = ?", orgID, clusterID).
|
||||
Order("created_at DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.ClusterRunResponse, 0, len(rows))
|
||||
for _, cr := range rows {
|
||||
out = append(out, clusterRunToDTO(cr))
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// GetClusterRun godoc
|
||||
//
|
||||
// @ID GetClusterRun
|
||||
// @Summary Get a cluster run (org scoped)
|
||||
// @Description Returns a single run for a cluster within the organization in X-Org-ID.
|
||||
// @Tags ClusterRuns
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param clusterID path string true "Cluster ID"
|
||||
// @Param runID path string true "Run ID"
|
||||
// @Success 200 {object} dto.ClusterRunResponse
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "db error"
|
||||
// @Router /clusters/{clusterID}/runs/{runID} [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func GetClusterRun(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
|
||||
return
|
||||
}
|
||||
|
||||
runID, err := uuid.Parse(chi.URLParam(r, "runID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_run_id", "invalid run id")
|
||||
return
|
||||
}
|
||||
|
||||
var row models.ClusterRun
|
||||
if err := db.
|
||||
Where("id = ? AND organization_id = ? AND cluster_id = ?", runID, orgID, clusterID).
|
||||
First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "run not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, clusterRunToDTO(row))
|
||||
}
|
||||
}
|
||||
|
||||
// RunClusterAction godoc
|
||||
//
|
||||
// @ID RunClusterAction
|
||||
// @Summary Run an admin-configured action on a cluster (org scoped)
|
||||
// @Description Creates a ClusterRun record for the cluster/action. Execution is handled asynchronously by workers.
|
||||
// @Tags ClusterRuns
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param clusterID path string true "Cluster ID"
|
||||
// @Param actionID path string true "Action ID"
|
||||
// @Success 201 {object} dto.ClusterRunResponse
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "cluster or action not found"
|
||||
// @Failure 500 {string} string "db error"
|
||||
// @Router /clusters/{clusterID}/actions/{actionID}/runs [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
func RunClusterAction(db *gorm.DB, jobs *bg.Jobs) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
|
||||
return
|
||||
}
|
||||
|
||||
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
|
||||
return
|
||||
}
|
||||
|
||||
// cluster must exist + org scoped
|
||||
var cluster models.Cluster
|
||||
if err := db.Select("id", "organization_id").
|
||||
Where("id = ? AND organization_id = ?", clusterID, orgID).
|
||||
First(&cluster).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
// action is global/admin-configured (not org scoped)
|
||||
var action models.Action
|
||||
if err := db.Where("id = ?", actionID).First(&action).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "action_not_found", "action not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
run := models.ClusterRun{
|
||||
OrganizationID: orgID,
|
||||
ClusterID: clusterID,
|
||||
Action: action.MakeTarget, // this is what you actually execute
|
||||
Status: models.ClusterRunStatusQueued,
|
||||
Error: "",
|
||||
FinishedAt: time.Time{},
|
||||
}
|
||||
|
||||
if err := db.Create(&run).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
||||
return
|
||||
}
|
||||
|
||||
args := bg.ClusterActionArgs{
|
||||
OrgID: orgID,
|
||||
ClusterID: clusterID,
|
||||
Action: action.MakeTarget,
|
||||
MakeTarget: action.MakeTarget,
|
||||
}
|
||||
// Enqueue with run.ID as the job ID so the worker can look it up.
|
||||
_, enqueueErr := jobs.Enqueue(
|
||||
r.Context(),
|
||||
run.ID.String(),
|
||||
"cluster_action",
|
||||
args,
|
||||
archer.WithMaxRetries(0),
|
||||
)
|
||||
|
||||
if enqueueErr != nil {
|
||||
_ = db.Model(&models.ClusterRun{}).
|
||||
Where("id = ?", run.ID).
|
||||
Updates(map[string]any{
|
||||
"status": models.ClusterRunStatusFailed,
|
||||
"error": "failed to enqueue job: " + enqueueErr.Error(),
|
||||
"finished_at": time.Now().UTC(),
|
||||
}).Error
|
||||
|
||||
utils.WriteError(w, http.StatusInternalServerError, "job_error", "failed to enqueue cluster action")
|
||||
return
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusCreated, clusterRunToDTO(run))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func clusterRunToDTO(cr models.ClusterRun) dto.ClusterRunResponse {
|
||||
var finished *time.Time
|
||||
if !cr.FinishedAt.IsZero() {
|
||||
t := cr.FinishedAt
|
||||
finished = &t
|
||||
}
|
||||
return dto.ClusterRunResponse{
|
||||
ID: cr.ID,
|
||||
OrganizationID: cr.OrganizationID,
|
||||
ClusterID: cr.ClusterID,
|
||||
Action: cr.Action,
|
||||
Status: cr.Status,
|
||||
Error: cr.Error,
|
||||
CreatedAt: cr.CreatedAt,
|
||||
UpdatedAt: cr.UpdatedAt,
|
||||
FinishedAt: finished,
|
||||
}
|
||||
}
|
||||
1719
internal/handlers/clusters.go
Normal file
1719
internal/handlers/clusters.go
Normal file
File diff suppressed because it is too large
Load Diff
564
internal/handlers/credentials.go
Normal file
564
internal/handlers/credentials.go
Normal 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),
|
||||
}
|
||||
}
|
||||
841
internal/handlers/dns.go
Normal file
841
internal/handlers/dns.go
Normal file
@@ -0,0 +1,841 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecordSet godoc
|
||||
//
|
||||
// @ID GetRecordSet
|
||||
// @Summary Get a record set (org scoped)
|
||||
// @Tags DNS
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Record Set ID (UUID)"
|
||||
// @Success 200 {object} dto.RecordSetResponse
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Router /dns/records/{id} [get]
|
||||
func GetRecordSet(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
||||
if !ok {
|
||||
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
|
||||
return
|
||||
}
|
||||
|
||||
var row models.RecordSet
|
||||
if err := db.
|
||||
Joins("Domain").
|
||||
Where(`record_sets.id = ? AND "Domain"."organization_id" = ?`, id, orgID).
|
||||
First(&row).Error; err != nil {
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
|
||||
return
|
||||
}
|
||||
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, recordOut(&row))
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRecordSet godoc
|
||||
//
|
||||
// @ID CreateRecordSet
|
||||
// @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),
|
||||
}
|
||||
}
|
||||
28
internal/handlers/dto/actions.go
Normal file
28
internal/handlers/dto/actions.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ActionResponse struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
MakeTarget string `json:"make_target"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
}
|
||||
|
||||
type CreateActionRequest struct {
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
MakeTarget string `json:"make_target"`
|
||||
}
|
||||
|
||||
type UpdateActionRequest struct {
|
||||
Label *string `json:"label,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
MakeTarget *string `json:"make_target,omitempty"`
|
||||
}
|
||||
19
internal/handlers/dto/cluster_runs.go
Normal file
19
internal/handlers/dto/cluster_runs.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ClusterRunResponse struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
|
||||
ClusterID uuid.UUID `json:"cluster_id" format:"uuid"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
|
||||
}
|
||||
72
internal/handlers/dto/clusters.go
Normal file
72
internal/handlers/dto/clusters.go
Normal 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"`
|
||||
}
|
||||
138
internal/handlers/dto/credentials.go
Normal file
138
internal/handlers/dto/credentials.go
Normal 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"`
|
||||
}
|
||||
103
internal/handlers/dto/dns.go
Normal file
103
internal/handlers/dto/dns.go
Normal 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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
32
internal/handlers/dto/load_balancers.go
Normal file
32
internal/handlers/dto/load_balancers.go
Normal 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"`
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
286
internal/handlers/load_balancers.go
Normal file
286
internal/handlers/load_balancers.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,13 @@ import (
|
||||
// @Summary List node pools (org scoped)
|
||||
// @Description Returns node pools for the organization in X-Org-ID.
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param q query string false "Name contains (case-insensitive)"
|
||||
// @Success 200 {array} dto.NodePoolResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "failed to list node pools"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param q query string false "Name contains (case-insensitive)"
|
||||
// @Success 200 {array} dto.NodePoolResponse
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "failed to list node pools"
|
||||
// @Router /node-pools [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -145,16 +144,15 @@ func ListNodePools(db *gorm.DB) http.HandlerFunc {
|
||||
// @Summary Get node pool by ID (org scoped)
|
||||
// @Description Returns one node pool. Add `include=servers` to include servers.
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 200 {object} dto.NodePoolResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 200 {object} dto.NodePoolResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Router /node-pools/{id} [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -194,13 +192,13 @@ func GetNodePool(db *gorm.DB) http.HandlerFunc {
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param body body dto.CreateNodePoolRequest true "NodePool payload"
|
||||
// @Success 201 {object} dto.NodePoolResponse
|
||||
// @Failure 400 {string} string "invalid json / missing fields / invalid server_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "create failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param body body dto.CreateNodePoolRequest true "NodePool payload"
|
||||
// @Success 201 {object} dto.NodePoolResponse
|
||||
// @Failure 400 {string} string "invalid json / missing fields / invalid server_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "create failed"
|
||||
// @Router /node-pools [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -257,15 +255,15 @@ func CreateNodePool(db *gorm.DB) http.HandlerFunc {
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param body body dto.UpdateNodePoolRequest true "Fields to update"
|
||||
// @Success 200 {object} dto.NodePoolResponse
|
||||
// @Failure 400 {string} string "invalid id / invalid json"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "update failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param body body dto.UpdateNodePoolRequest true "Fields to update"
|
||||
// @Success 200 {object} dto.NodePoolResponse
|
||||
// @Failure 400 {string} string "invalid id / invalid json"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "update failed"
|
||||
// @Router /node-pools/{id} [patch]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -327,15 +325,14 @@ func UpdateNodePool(db *gorm.DB) http.HandlerFunc {
|
||||
// @Summary Delete node pool (org scoped)
|
||||
// @Description Permanently deletes the node pool.
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "delete failed"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 500 {string} string "delete failed"
|
||||
// @Router /node-pools/{id} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -369,16 +366,15 @@ func DeleteNodePool(db *gorm.DB) http.HandlerFunc {
|
||||
// @ID ListNodePoolServers
|
||||
// @Summary List servers attached to a node pool (org scoped)
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 200 {array} 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 "fetch failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 200 {array} 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 "fetch failed"
|
||||
// @Router /node-pools/{id}/servers [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -434,15 +430,15 @@ func ListNodePoolServers(db *gorm.DB) http.HandlerFunc {
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param body body dto.AttachServersRequest true "Server IDs to attach"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid server_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "attach failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param body body dto.AttachServersRequest true "Server IDs to attach"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid server_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "attach failed"
|
||||
// @Router /node-pools/{id}/servers [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -521,17 +517,16 @@ func AttachNodePoolServers(db *gorm.DB) http.HandlerFunc {
|
||||
// @ID DetachNodePoolServer
|
||||
// @Summary Detach one server from a node pool (org scoped)
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param serverId path string true "Server ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "detach failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param serverId path string true "Server ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "detach failed"
|
||||
// @Router /node-pools/{id}/servers/{serverId} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -588,16 +583,15 @@ func DetachNodePoolServer(db *gorm.DB) http.HandlerFunc {
|
||||
// @ID ListNodePoolTaints
|
||||
// @Summary List taints attached to a node pool (org scoped)
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 200 {array} dto.TaintResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 200 {array} dto.TaintResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Router /node-pools/{id}/taints [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -646,15 +640,15 @@ func ListNodePoolTaints(db *gorm.DB) http.HandlerFunc {
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param body body dto.AttachTaintsRequest true "Taint IDs to attach"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid taint_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "attach failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param body body dto.AttachTaintsRequest true "Taint IDs to attach"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid taint_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "attach failed"
|
||||
// @Router /node-pools/{id}/taints [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -730,17 +724,16 @@ func AttachNodePoolTaints(db *gorm.DB) http.HandlerFunc {
|
||||
// @ID DetachNodePoolTaint
|
||||
// @Summary Detach one taint from a node pool (org scoped)
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param taintId path string true "Taint ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "detach failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param taintId path string true "Taint ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "detach failed"
|
||||
// @Router /node-pools/{id}/taints/{taintId} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -798,16 +791,15 @@ func DetachNodePoolTaint(db *gorm.DB) http.HandlerFunc {
|
||||
// @ID ListNodePoolLabels
|
||||
// @Summary List labels attached to a node pool (org scoped)
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Label Pool ID (UUID)"
|
||||
// @Success 200 {array} dto.LabelResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Label Pool ID (UUID)"
|
||||
// @Success 200 {array} dto.LabelResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Router /node-pools/{id}/labels [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -836,16 +828,16 @@ func ListNodePoolLabels(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
out := make([]dto.LabelResponse, 0, len(np.Taints))
|
||||
for _, taint := range np.Taints {
|
||||
for _, label := range np.Labels {
|
||||
out = append(out, dto.LabelResponse{
|
||||
AuditFields: common.AuditFields{
|
||||
ID: taint.ID,
|
||||
OrganizationID: taint.OrganizationID,
|
||||
CreatedAt: taint.CreatedAt,
|
||||
UpdatedAt: taint.UpdatedAt,
|
||||
ID: label.ID,
|
||||
OrganizationID: label.OrganizationID,
|
||||
CreatedAt: label.CreatedAt,
|
||||
UpdatedAt: label.UpdatedAt,
|
||||
},
|
||||
Key: taint.Key,
|
||||
Value: taint.Value,
|
||||
Key: label.Key,
|
||||
Value: label.Value,
|
||||
})
|
||||
}
|
||||
utils.WriteJSON(w, http.StatusOK, out)
|
||||
@@ -859,15 +851,15 @@ func ListNodePoolLabels(db *gorm.DB) http.HandlerFunc {
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param body body dto.AttachLabelsRequest true "Label IDs to attach"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid server_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "attach failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param body body dto.AttachLabelsRequest true "Label IDs to attach"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid server_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "attach failed"
|
||||
// @Router /node-pools/{id}/labels [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -940,17 +932,16 @@ func AttachNodePoolLabels(db *gorm.DB) http.HandlerFunc {
|
||||
// @ID DetachNodePoolLabel
|
||||
// @Summary Detach one label from a node pool (org scoped)
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param labelId path string true "Label ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "detach failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param labelId path string true "Label ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "detach failed"
|
||||
// @Router /node-pools/{id}/labels/{labelId} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -1008,16 +999,15 @@ func DetachNodePoolLabel(db *gorm.DB) http.HandlerFunc {
|
||||
// @ID ListNodePoolAnnotations
|
||||
// @Summary List annotations attached to a node pool (org scoped)
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 200 {array} dto.AnnotationResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Success 200 {array} dto.AnnotationResponse
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "fetch failed"
|
||||
// @Router /node-pools/{id}/annotations [get]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -1069,15 +1059,15 @@ func ListNodePoolAnnotations(db *gorm.DB) http.HandlerFunc {
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Group ID (UUID)"
|
||||
// @Param body body dto.AttachAnnotationsRequest true "Annotation IDs to attach"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid server_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "attach failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Group ID (UUID)"
|
||||
// @Param body body dto.AttachAnnotationsRequest true "Annotation IDs to attach"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id / invalid server_ids"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "attach failed"
|
||||
// @Router /node-pools/{id}/annotations [post]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
@@ -1151,17 +1141,16 @@ func AttachNodePoolAnnotations(db *gorm.DB) http.HandlerFunc {
|
||||
// @ID DetachNodePoolAnnotation
|
||||
// @Summary Detach one annotation from a node pool (org scoped)
|
||||
// @Tags NodePools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param annotationId path string true "Annotation ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "detach failed"
|
||||
// @Param X-Org-ID header string false "Organization UUID"
|
||||
// @Param id path string true "Node Pool ID (UUID)"
|
||||
// @Param annotationId path string true "Annotation ID (UUID)"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 400 {string} string "invalid id"
|
||||
// @Failure 401 {string} string "Unauthorized"
|
||||
// @Failure 403 {string} string "organization required"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 500 {string} string "detach failed"
|
||||
// @Router /node-pools/{id}/annotations/{annotationId} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
|
||||
381
internal/handlers/node_pools_test.go
Normal file
381
internal/handlers/node_pools_test.go
Normal 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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
78
internal/handlers/servers_test.go
Normal file
78
internal/handlers/servers_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -189,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)"
|
||||
@@ -283,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -30,7 +30,6 @@ type VersionResponse struct {
|
||||
// @Description Returns build/runtime metadata for the running service.
|
||||
// @Tags Meta
|
||||
// @ID Version // operationId
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} VersionResponse
|
||||
// @Router /version [get]
|
||||
|
||||
182
internal/mapper/cluster.go
Normal file
182
internal/mapper/cluster.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package mapper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/common"
|
||||
"github.com/glueops/autoglue/internal/handlers/dto"
|
||||
"github.com/glueops/autoglue/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func ClusterToDTO(c models.Cluster) dto.ClusterResponse {
|
||||
var bastion *dto.ServerResponse
|
||||
if c.BastionServer != nil {
|
||||
b := ServerToDTO(*c.BastionServer)
|
||||
bastion = &b
|
||||
}
|
||||
|
||||
var captainDomain *dto.DomainResponse
|
||||
if c.CaptainDomainID != nil && c.CaptainDomain.ID != uuid.Nil {
|
||||
dr := DomainToDTO(c.CaptainDomain)
|
||||
captainDomain = &dr
|
||||
}
|
||||
|
||||
var controlPlane *dto.RecordSetResponse
|
||||
if c.ControlPlaneRecordSet != nil {
|
||||
rr := RecordSetToDTO(*c.ControlPlaneRecordSet)
|
||||
controlPlane = &rr
|
||||
}
|
||||
|
||||
var cfqdn *string
|
||||
if captainDomain != nil && controlPlane != nil {
|
||||
fq := fmt.Sprintf("%s.%s", controlPlane.Name, captainDomain.DomainName)
|
||||
cfqdn = &fq
|
||||
}
|
||||
|
||||
var appsLB *dto.LoadBalancerResponse
|
||||
if c.AppsLoadBalancer != nil {
|
||||
lr := LoadBalancerToDTO(*c.AppsLoadBalancer)
|
||||
appsLB = &lr
|
||||
}
|
||||
|
||||
var glueOpsLB *dto.LoadBalancerResponse
|
||||
if c.GlueOpsLoadBalancer != nil {
|
||||
lr := LoadBalancerToDTO(*c.GlueOpsLoadBalancer)
|
||||
glueOpsLB = &lr
|
||||
}
|
||||
|
||||
nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
|
||||
for _, np := range c.NodePools {
|
||||
nps = append(nps, NodePoolToDTO(np))
|
||||
}
|
||||
|
||||
return dto.ClusterResponse{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
CaptainDomain: captainDomain,
|
||||
ControlPlaneRecordSet: controlPlane,
|
||||
ControlPlaneFQDN: cfqdn,
|
||||
AppsLoadBalancer: appsLB,
|
||||
GlueOpsLoadBalancer: glueOpsLB,
|
||||
BastionServer: bastion,
|
||||
Provider: c.Provider,
|
||||
Region: c.Region,
|
||||
Status: c.Status,
|
||||
LastError: c.LastError,
|
||||
RandomToken: c.RandomToken,
|
||||
CertificateKey: c.CertificateKey,
|
||||
NodePools: nps,
|
||||
DockerImage: c.DockerImage,
|
||||
DockerTag: c.DockerTag,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
|
||||
labels := make([]dto.LabelResponse, 0, len(np.Labels))
|
||||
for _, l := range np.Labels {
|
||||
labels = append(labels, dto.LabelResponse{
|
||||
Key: l.Key,
|
||||
Value: l.Value,
|
||||
})
|
||||
}
|
||||
|
||||
annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
|
||||
for _, a := range np.Annotations {
|
||||
annotations = append(annotations, dto.AnnotationResponse{
|
||||
Key: a.Key,
|
||||
Value: a.Value,
|
||||
})
|
||||
}
|
||||
|
||||
taints := make([]dto.TaintResponse, 0, len(np.Taints))
|
||||
for _, t := range np.Taints {
|
||||
taints = append(taints, dto.TaintResponse{
|
||||
Key: t.Key,
|
||||
Value: t.Value,
|
||||
Effect: t.Effect,
|
||||
})
|
||||
}
|
||||
|
||||
servers := make([]dto.ServerResponse, 0, len(np.Servers))
|
||||
for _, s := range np.Servers {
|
||||
servers = append(servers, ServerToDTO(s))
|
||||
}
|
||||
|
||||
return dto.NodePoolResponse{
|
||||
AuditFields: common.AuditFields{
|
||||
ID: np.ID,
|
||||
OrganizationID: np.OrganizationID,
|
||||
CreatedAt: np.CreatedAt,
|
||||
UpdatedAt: np.UpdatedAt,
|
||||
},
|
||||
Name: np.Name,
|
||||
Role: dto.NodeRole(np.Role),
|
||||
Labels: labels,
|
||||
Annotations: annotations,
|
||||
Taints: taints,
|
||||
Servers: servers,
|
||||
}
|
||||
}
|
||||
|
||||
func ServerToDTO(s models.Server) dto.ServerResponse {
|
||||
return dto.ServerResponse{
|
||||
ID: s.ID,
|
||||
Hostname: s.Hostname,
|
||||
PrivateIPAddress: s.PrivateIPAddress,
|
||||
PublicIPAddress: s.PublicIPAddress,
|
||||
Role: s.Role,
|
||||
Status: s.Status,
|
||||
SSHUser: s.SSHUser,
|
||||
SshKeyID: s.SshKeyID,
|
||||
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func DomainToDTO(d models.Domain) dto.DomainResponse {
|
||||
return dto.DomainResponse{
|
||||
ID: d.ID.String(),
|
||||
OrganizationID: d.OrganizationID.String(),
|
||||
DomainName: d.DomainName,
|
||||
ZoneID: d.ZoneID,
|
||||
Status: d.Status,
|
||||
LastError: d.LastError,
|
||||
CredentialID: d.CredentialID.String(),
|
||||
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func RecordSetToDTO(rs models.RecordSet) dto.RecordSetResponse {
|
||||
return dto.RecordSetResponse{
|
||||
ID: rs.ID.String(),
|
||||
DomainID: rs.DomainID.String(),
|
||||
Name: rs.Name,
|
||||
Type: rs.Type,
|
||||
TTL: rs.TTL,
|
||||
Values: []byte(rs.Values),
|
||||
Fingerprint: rs.Fingerprint,
|
||||
Status: rs.Status,
|
||||
Owner: rs.Owner,
|
||||
LastError: rs.LastError,
|
||||
CreatedAt: rs.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: rs.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadBalancerToDTO(lb models.LoadBalancer) dto.LoadBalancerResponse {
|
||||
return dto.LoadBalancerResponse{
|
||||
ID: lb.ID,
|
||||
OrganizationID: lb.OrganizationID,
|
||||
Name: lb.Name,
|
||||
Kind: lb.Kind,
|
||||
PublicIPAddress: lb.PublicIPAddress,
|
||||
PrivateIPAddress: lb.PrivateIPAddress,
|
||||
CreatedAt: lb.CreatedAt,
|
||||
UpdatedAt: lb.UpdatedAt,
|
||||
}
|
||||
}
|
||||
16
internal/models/action.go
Normal file
16
internal/models/action.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Action struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||
Label string `gorm:"type:varchar(255);not null;uniqueIndex" json:"label"`
|
||||
Description string `gorm:"type:text;not null" json:"description"`
|
||||
MakeTarget string `gorm:"type:varchar(255);not null;uniqueIndex" json:"make_target"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
|
||||
}
|
||||
@@ -7,17 +7,20 @@ import (
|
||||
)
|
||||
|
||||
type APIKey struct {
|
||||
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
18
internal/models/backup.go
Normal 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()"`
|
||||
}
|
||||
@@ -6,24 +6,43 @@ import (
|
||||
"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 `json:"status"`
|
||||
CaptainDomain string `gorm:"not null" json:"captain_domain"`
|
||||
ClusterLoadBalancer string `json:"cluster_load_balancer"`
|
||||
RandomToken string `json:"random_token"`
|
||||
CertificateKey string `json:"certificate_key"`
|
||||
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
|
||||
KubeIV string `json:"-"`
|
||||
KubeTag string `json:"-"`
|
||||
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
|
||||
BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"`
|
||||
BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"`
|
||||
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()"`
|
||||
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()"`
|
||||
}
|
||||
|
||||
27
internal/models/cluster_runs.go
Normal file
27
internal/models/cluster_runs.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
ClusterRunStatusQueued = "queued"
|
||||
ClusterRunStatusRunning = "running"
|
||||
ClusterRunStatusSuccess = "success"
|
||||
ClusterRunStatusFailed = "failed"
|
||||
ClusterRunStatusCanceled = "canceled"
|
||||
)
|
||||
|
||||
type ClusterRun struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
|
||||
ClusterID uuid.UUID `json:"cluster_id" gorm:"type:uuid;index"`
|
||||
Action string `json:"action" gorm:"type:text;not null"`
|
||||
Status string `json:"status" gorm:"type:text;not null"`
|
||||
Error string `json:"error" gorm:"type:text;not null"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
|
||||
FinishedAt time.Time `json:"finished_at,omitempty" gorm:"type:timestamptz" format:"date-time"`
|
||||
}
|
||||
29
internal/models/credential.go
Normal file
29
internal/models/credential.go
Normal 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
41
internal/models/domain.go
Normal 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()"`
|
||||
}
|
||||
19
internal/models/load_balancer.go
Normal file
19
internal/models/load_balancer.go
Normal 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()"`
|
||||
}
|
||||
@@ -12,7 +12,7 @@ type NodePool struct {
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ type Server struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
119
internal/testutil/pgtest/pgtest.go
Normal file
119
internal/testutil/pgtest/pgtest.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
2
internal/web/dist/assets/index-CImaF4Hs.css
vendored
2
internal/web/dist/assets/index-CImaF4Hs.css
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/index-CImaF4Hs.css.br
vendored
BIN
internal/web/dist/assets/index-CImaF4Hs.css.br
vendored
Binary file not shown.
BIN
internal/web/dist/assets/index-CImaF4Hs.css.gz
vendored
BIN
internal/web/dist/assets/index-CImaF4Hs.css.gz
vendored
Binary file not shown.
2
internal/web/dist/assets/index-Cdjh6IZW.css
vendored
Normal file
2
internal/web/dist/assets/index-Cdjh6IZW.css
vendored
Normal file
File diff suppressed because one or more lines are too long
79
internal/web/dist/assets/index-DCrfJ1uu.js
vendored
79
internal/web/dist/assets/index-DCrfJ1uu.js
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/index-DCrfJ1uu.js.br
vendored
BIN
internal/web/dist/assets/index-DCrfJ1uu.js.br
vendored
Binary file not shown.
BIN
internal/web/dist/assets/index-DCrfJ1uu.js.gz
vendored
BIN
internal/web/dist/assets/index-DCrfJ1uu.js.gz
vendored
Binary file not shown.
File diff suppressed because one or more lines are too long
4186
internal/web/dist/assets/index-GteqH5KT.js
vendored
Normal file
4186
internal/web/dist/assets/index-GteqH5KT.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/web/dist/assets/index-GteqH5KT.js.map
vendored
Normal file
1
internal/web/dist/assets/index-GteqH5KT.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user