mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 21:00:06 +01:00
Compare commits
333 Commits
v0.6.15
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d8941977 | ||
|
|
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 |
8
.github/workflows/docker-publish.yml
vendored
8
.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,7 +47,7 @@ jobs:
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
@@ -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.6-alpine@sha256:d9b2e14101f27ec8d09674cd01186798d227bb0daec90e032aeb1cd22ac0f029 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
|
||||
|
||||
20
Makefile
20
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
|
||||
@@ -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
4158
docs/swagger.yaml
4158
docs/swagger.yaml
File diff suppressed because it is too large
Load Diff
97
go.mod
97
go.mod
@@ -4,22 +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.95.1
|
||||
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.4
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
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.33.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
|
||||
@@ -28,9 +36,33 @@ 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.10 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
@@ -41,37 +73,62 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
265
go.sum
265
go.sum
@@ -1,31 +1,126 @@
|
||||
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/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.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
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/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=
|
||||
@@ -51,8 +146,9 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
@@ -60,6 +156,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
@@ -67,8 +165,9 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -81,16 +180,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=
|
||||
@@ -101,6 +206,7 @@ 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=
|
||||
@@ -111,37 +217,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=
|
||||
@@ -153,6 +285,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=
|
||||
@@ -164,46 +297,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.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.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=
|
||||
@@ -211,30 +370,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=
|
||||
@@ -249,8 +416,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,187 +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("/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))
|
||||
})
|
||||
|
||||
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")
|
||||
@@ -247,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,8 +39,14 @@ func NewRuntime() *Runtime {
|
||||
&models.Label{},
|
||||
&models.Annotation{},
|
||||
&models.NodePool{},
|
||||
&models.Cluster{},
|
||||
&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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,24 +23,24 @@ import (
|
||||
)
|
||||
|
||||
// ListCredentials godoc
|
||||
// @ID ListCredentials
|
||||
// @Summary List credentials (metadata only)
|
||||
// @Description Returns credential metadata for the current org. Secrets are never returned.
|
||||
// @Tags Credentials
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Organization ID (UUID)"
|
||||
// @Param 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 (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
|
||||
//
|
||||
// @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())
|
||||
@@ -49,7 +49,7 @@ func ListCredentials(db *gorm.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
q := db.Where("organization_id = ?", orgID)
|
||||
if v := r.URL.Query().Get("provider"); v != "" {
|
||||
if v := r.URL.Query().Get("credential_provider"); v != "" {
|
||||
q = q.Where("provider = ?", v)
|
||||
}
|
||||
if v := r.URL.Query().Get("kind"); v != "" {
|
||||
@@ -73,21 +73,21 @@ func ListCredentials(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// GetCredential godoc
|
||||
// @ID GetCredential
|
||||
// @Summary Get credential by ID (metadata only)
|
||||
// @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} 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
|
||||
//
|
||||
// @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())
|
||||
@@ -117,21 +117,22 @@ func GetCredential(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// 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
|
||||
//
|
||||
// @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())
|
||||
@@ -153,7 +154,7 @@ func CreateCredential(db *gorm.DB) http.HandlerFunc {
|
||||
|
||||
cred, err := SaveCredentialWithScope(
|
||||
r.Context(), db, orgID,
|
||||
in.Provider, in.Kind, in.SchemaVersion,
|
||||
in.CredentialProvider, in.Kind, in.SchemaVersion,
|
||||
in.ScopeKind, in.ScopeVersion, json.RawMessage(in.Scope), json.RawMessage(in.Secret),
|
||||
in.Name, in.AccountID, in.Region,
|
||||
)
|
||||
@@ -166,21 +167,22 @@ func CreateCredential(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// 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
|
||||
//
|
||||
// @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())
|
||||
@@ -296,19 +298,19 @@ func UpdateCredential(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// DeleteCredential godoc
|
||||
// @ID DeleteCredential
|
||||
// @Summary Delete credential
|
||||
// @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 204
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Router /credentials/{id} [delete]
|
||||
// @Security BearerAuth
|
||||
// @Security OrgKeyAuth
|
||||
// @Security OrgSecretAuth
|
||||
//
|
||||
// @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())
|
||||
@@ -335,20 +337,21 @@ func DeleteCredential(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// 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
|
||||
//
|
||||
// @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())
|
||||
@@ -545,17 +548,17 @@ func SaveCredentialWithScope(
|
||||
// credOut converts model → response DTO
|
||||
func credOut(c *models.Credential) dto.CredentialOut {
|
||||
return dto.CredentialOut{
|
||||
ID: c.ID.String(),
|
||||
Provider: 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),
|
||||
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"`
|
||||
}
|
||||
@@ -7,28 +7,66 @@ import (
|
||||
)
|
||||
|
||||
type ClusterResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Region string `json:"region"`
|
||||
Status string `json:"status"`
|
||||
CaptainDomain string `json:"captain_domain"`
|
||||
ClusterLoadBalancer string `json:"cluster_load_balancer"`
|
||||
RandomToken string `json:"random_token"`
|
||||
CertificateKey string `json:"certificate_key"`
|
||||
ControlLoadBalancer string `json:"control_load_balancer"`
|
||||
NodePools []NodePoolResponse `json:"node_pools,omitempty"`
|
||||
BastionServer *ServerResponse `json:"bastion_server,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
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"`
|
||||
Provider string `json:"provider"`
|
||||
Region string `json:"region"`
|
||||
Status string `json:"status"`
|
||||
CaptainDomain string `json:"captain_domain"`
|
||||
ClusterLoadBalancer *string `json:"cluster_load_balancer"`
|
||||
ControlLoadBalancer *string `json:"control_load_balancer"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// RawJSON is a swagger-friendly wrapper for json.RawMessage.
|
||||
type RawJSON json.RawMessage
|
||||
type RawJSON = json.RawMessage
|
||||
|
||||
var Validate = validator.New()
|
||||
|
||||
@@ -97,16 +97,16 @@ var ScopeRegistry = map[string]map[string]map[int]ScopeDef{
|
||||
|
||||
// CreateCredentialRequest represents the POST /credentials payload
|
||||
type CreateCredentialRequest struct {
|
||||
Provider string `json:"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=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
|
||||
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}
|
||||
@@ -123,16 +123,16 @@ type UpdateCredentialRequest struct {
|
||||
|
||||
// CredentialOut is what we return (no secrets)
|
||||
type CredentialOut struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"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"`
|
||||
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)
|
||||
}
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -6,15 +6,13 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Dns struct {
|
||||
type Backup struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider" json:"organization_id"`
|
||||
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"`
|
||||
ClusterID *uuid.UUID `gorm:"type:uuid" json:"cluster_id,omitempty"`
|
||||
Cluster *Cluster `gorm:"foreignKey:ClusterID" json:"cluster,omitempty"`
|
||||
Type string `gorm:"not null" json:"type,omitempty"`
|
||||
Name string `gorm:"not null" json:"name,omitempty"`
|
||||
Content string `gorm:"not null" json:"content,omitempty"`
|
||||
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,25 +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"`
|
||||
ControlLoadBalancer string `json:"control_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"`
|
||||
}
|
||||
@@ -9,18 +9,18 @@ import (
|
||||
|
||||
type Credential struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_credentials_org_provider" json:"organization_id"`
|
||||
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;index"`
|
||||
Kind string `gorm:"type:varchar(50);not null;index"` // "aws_access_key", "api_token", "basic_auth", ...
|
||||
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:''"` // human label, lets you have multiple for same service
|
||||
ScopeKind string `gorm:"type:varchar(20);not null"` // "provider" | "service" | "resource"
|
||||
Scope datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'"` // e.g. {"service":"route53"} or {"arn":"arn:aws:s3:::my-bucket"}
|
||||
Name string `gorm:"type:varchar(100);not null;default:''"`
|
||||
ScopeVersion int `gorm:"not null;default:1"`
|
||||
AccountID string `gorm:"type:varchar(32)"` // AWS account ID if applicable
|
||||
Region string `gorm:"type:varchar(32)"` // default region (non-secret)
|
||||
ScopeFingerprint string `gorm:"type:char(64);not null;index"`
|
||||
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"`
|
||||
|
||||
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()"`
|
||||
}
|
||||
@@ -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
4
internal/web/dist/assets/react-B75e6Si-.js
vendored
4
internal/web/dist/assets/react-B75e6Si-.js
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/react-B75e6Si-.js.br
vendored
BIN
internal/web/dist/assets/react-B75e6Si-.js.br
vendored
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user