Compare commits

...

23 Commits

Author SHA1 Message Date
allanice001
0f0edf1007 Merge remote-tracking branch 'origin/main' 2025-11-17 18:21:57 +00:00
allanice001
56f86a11b4 feat: cluster page ui
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 18:21:48 +00:00
public-glueops-renovatebot[bot]
c9fe259a3a chore(fallback): update actions/checkout (#297)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-17 18:15:21 +00:00
allanice001
d163a050d8 feat: load balancers ui
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 15:16:20 +00:00
allanice001
9853d32b04 chore: update schema.sql from DB using atlas
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 05:03:55 +00:00
allanice001
d0c43df71c fix: package updates
Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 05:02:11 +00:00
allanice001
219ce80e5b Merge remote-tracking branch 'origin/main'
# Conflicts:
#	go.mod
#	go.sum
#	ui/yarn.lock
2025-11-17 05:00:10 +00:00
allanice001
7985b310c5 feat: Complete AG Loadbalancer & Cluster API
Refactor routing logic (Chi can be a pain when you're managing large sets of routes, but its one of the better options when considering a potential gRPC future)
       Upgrade API Generation to fully support OAS3.1
      Update swagger interface to RapiDoc - the old swagger interface doesnt support OAS3.1 yet
      Docs are now embedded as part of the UI - once logged in they pick up the cookies and org id from what gets set by the UI, but you can override it
      Other updates include better portability of the db-studio

Signed-off-by: allanice001 <allanice001@gmail.com>
2025-11-17 04:59:39 +00:00
public-glueops-renovatebot[bot]
501785d471 chore(patch): update github.com/dyaksa/archer to v1.1.5 #patch (#293)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-17 01:31:12 +00:00
public-glueops-renovatebot[bot]
3ca32e9ed7 chore: lock file maintenance (#292)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-16 18:59:00 +00:00
public-glueops-renovatebot[bot]
b6e5d329a5 chore: lock file maintenance (#291)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-16 14:00:52 +00:00
public-glueops-renovatebot[bot]
c0821253ca chore: lock file maintenance (#290)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-16 05:18:07 +00:00
public-glueops-renovatebot[bot]
bb8b1f2773 chore: lock file maintenance (#289)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 23:12:05 +00:00
public-glueops-renovatebot[bot]
33b0dffba7 chore: lock file maintenance (#288)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 10:09:03 +00:00
public-glueops-renovatebot[bot]
04cc6facaa chore(patch): update @vitejs/plugin-react to 5.1.1 #patch (#265)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 08:02:08 +00:00
public-glueops-renovatebot[bot]
e414204ac9 chore(patch): update @types/react to 19.2.5 #patch (#285)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 05:16:15 +00:00
public-glueops-renovatebot[bot]
2975baafb9 chore: lock file maintenance (#287)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-15 04:24:58 +00:00
public-glueops-renovatebot[bot]
5819d69d3e chore(patch): update @types/node to 24.10.1 #patch (#263)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 23:40:14 +00:00
public-glueops-renovatebot[bot]
d0ab259047 chore: lock file maintenance (#286)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 20:20:18 +00:00
public-glueops-renovatebot[bot]
058b07993c chore(patch): update @types/react to 19.2.3 #patch (#261)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 17:30:05 +00:00
public-glueops-renovatebot[bot]
92fbf004c6 chore: lock file maintenance (#284)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 08:25:10 +00:00
public-glueops-renovatebot[bot]
1d89bc4312 chore: lock file maintenance (#283)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 07:39:11 +00:00
public-glueops-renovatebot[bot]
6626565a75 chore(patch): update github.com/aws/aws-sdk-go-v2/config to v1.31.20 #patch (#282)
Co-authored-by: public-glueops-renovatebot[bot] <186083205+public-glueops-renovatebot[bot]@users.noreply.github.com>
2025-11-14 07:38:06 +00:00
91 changed files with 15397 additions and 5568 deletions

View File

@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer

100
.semgrep.yml Normal file
View File

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

View File

@@ -30,6 +30,7 @@ UI_SSG_ROUTES ?= /,/login,/docs,/pricing
# Go versioning (go.mod uses major.minor; youre on 1.25.4)
GO_VERSION ?= 1.25.4
SWAG_FLAGS ?= --v3.1 --outputTypes json,yaml,go
# SDK / package settings (TypeScript)
SDK_TS_OUTDIR ?= sdk/ts
@@ -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

View File

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

20
atlas.hcl Normal file
View File

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

View File

@@ -134,7 +134,7 @@ var serveCmd = &cobra.Command{
dbURL = cfg.DbURL
}
studio, err := api.PgwebHandler(
studio, err := api.MountDbStudio(
dbURL,
"db-studio",
false,

File diff suppressed because one or more lines are too long

View File

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

12
docs/openapi.json Normal file

File diff suppressed because one or more lines are too long

6702
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

78
go.mod
View File

@@ -3,14 +3,16 @@ module github.com/glueops/autoglue
go 1.25.4
require (
ariga.io/atlas-provider-gorm v0.6.0
github.com/alexedwards/argon2id v1.0.0
github.com/aws/aws-sdk-go-v2 v1.39.6
github.com/aws/aws-sdk-go-v2/config v1.31.18
github.com/aws/aws-sdk-go-v2/credentials v1.18.22
github.com/aws/aws-sdk-go-v2/service/route53 v1.59.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0
github.com/aws/aws-sdk-go-v2/config v1.31.20
github.com/aws/aws-sdk-go-v2/credentials v1.18.24
github.com/aws/aws-sdk-go-v2/service/route53 v1.59.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2
github.com/coreos/go-oidc/v3 v3.16.0
github.com/dyaksa/archer v1.1.3
github.com/dyaksa/archer v1.1.5
github.com/fergusstrange/embedded-postgres v1.33.0
github.com/gin-gonic/gin v1.11.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
@@ -23,7 +25,6 @@ require (
github.com/sosedoff/pgweb v0.16.2
github.com/spf13/cobra v1.10.1
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.44.0
golang.org/x/oauth2 v0.33.0
@@ -34,8 +35,20 @@ require (
)
require (
ariga.io/atlas v0.36.2-0.20250806044935-5bb51a0a956e // indirect
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go v0.121.6 // indirect
cloud.google.com/go/auth v0.16.4 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.8.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/longrunning v0.6.7 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/spanner v1.84.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.1.0 // indirect
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.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.3 // indirect
@@ -48,20 +61,26 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.40.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect
github.com/aws/smithy-go v1.23.2 // 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.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
@@ -72,6 +91,15 @@ require (
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/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/googleapis/go-gorm-spanner v1.8.6 // indirect
github.com/googleapis/go-sql-spanner v1.17.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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
@@ -89,14 +117,17 @@ require (
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/microsoft/go-mssqldb v1.7.2 // 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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect
github.com/prometheus/client_model v0.5.0 // 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
@@ -107,14 +138,25 @@ require (
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/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/sv-tools/openapi v0.2.1 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
github.com/swaggo/swag v1.8.1 // indirect
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect
@@ -123,8 +165,16 @@ require (
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/api v0.247.0 // indirect
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect
google.golang.org/grpc v1.74.2 // 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
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/driver/sqlserver v1.6.0 // indirect
)

1689
go.sum

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,41 @@
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountClusterRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
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.DeleteNodePool(db))
})
}

View File

@@ -10,7 +10,7 @@ import (
pgcmd "github.com/sosedoff/pgweb/pkg/command"
)
func PgwebHandler(dbURL, prefix string, readonly bool) (http.Handler, error) {
func MountDbStudio(dbURL, prefix string, readonly bool) (http.Handler, error) {
// Normalize prefix for pgweb:
// - no leading slash
// - always trailing slash if not empty

View File

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

View File

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

View File

@@ -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 https://api.github.com",
"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' ws://localhost:8080 https://api.github.com",
"connect-src 'self' ws://localhost:8080 https://api.github.com https://unpkg.com",
"frame-ancestors 'none'",
}, "; "))
}

View File

@@ -38,6 +38,7 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
r.Use(SecurityHeaders)
r.Use(requestBodyLimit(10 << 20))
r.Use(httprate.LimitByIP(100, 1*time.Minute))
r.Use(middleware.StripSlashes)
allowed := getAllowedOrigins()
r.Use(cors.Handler(cors.Options{
@@ -103,18 +104,19 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
mux := http.NewServeMux()
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)
mux.Handle("/", proxy)
return mux
}
fmt.Println("Running in production mode")
if h, err := web.SPAHandler(); err == nil {
r.NotFound(h.ServeHTTP)
} else {
log.Error().Err(err).Msg("spa handler init failed")
fmt.Println("Running in production mode")
if h, err := web.SPAHandler(); err == nil {
r.NotFound(h.ServeHTTP)
} else {
log.Error().Err(err).Msg("spa handler init failed")
}
}
return r

View File

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

View File

@@ -39,10 +39,11 @@ func NewRuntime() *Runtime {
&models.Label{},
&models.Annotation{},
&models.NodePool{},
&models.Cluster{},
&models.Credential{},
&models.Domain{},
&models.RecordSet{},
&models.LoadBalancer{},
&models.Cluster{},
)
if err != nil {

View File

@@ -140,10 +140,7 @@ func BastionBootstrapWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
_ = setServerStatus(db, s.ID, "failed")
continue
}
ok++
// logHostInfo(jobID, s, "done", "host completed",
// "elapsed_ms", time.Since(hostStart).Milliseconds())
}
res := BastionBootstrapResult{

View File

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

View File

@@ -2,7 +2,9 @@ package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"html/template"
"net/http"
"net/url"
"strings"
@@ -252,10 +254,11 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
accessTTL := 1 * time.Hour
refreshTTL := 30 * 24 * time.Hour
cfgLoaded, _ := config.Load()
access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: user.ID.String(),
Issuer: cfg.JWTIssuer,
Audience: cfg.JWTAudience,
Issuer: cfgLoaded.JWTIssuer,
Audience: cfgLoaded.JWTAudience,
TTL: accessTTL,
Claims: map[string]any{
"email": email,
@@ -273,7 +276,10 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
return
}
secure := strings.HasPrefix(cfg.OAuthRedirectBase, "https://")
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")
}
@@ -291,14 +297,7 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
// 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
}
@@ -371,7 +370,10 @@ func Refresh(db *gorm.DB) http.HandlerFunc {
return
}
secure := strings.HasPrefix(cfg.OAuthRedirectBase, "https://")
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")
}
@@ -424,6 +426,11 @@ func Logout(db *gorm.DB) http.HandlerFunc {
}
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: "",
@@ -432,11 +439,10 @@ func Logout(db *gorm.DB) http.HandlerFunc {
MaxAge: -1,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(cfg.OAuthRedirectBase, "https"),
Secure: secure,
})
w.WriteHeader(204)
}
}
@@ -506,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")
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 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
func ListCredentials(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -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())
@@ -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())

View File

@@ -166,7 +166,6 @@ func mustSameOrgDomainWithCredential(db *gorm.DB, orgID uuid.UUID, credID uuid.U
// @Summary List domains (org scoped)
// @Description Returns domains for X-Org-ID. Filters: `domain_name`, `status`, `q` (contains).
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param domain_name query string false "Exact domain name (lowercase, no trailing dot)"
@@ -213,21 +212,20 @@ func ListDomains(db *gorm.DB) http.HandlerFunc {
// GetDomain godoc
//
// @ID GetDomain
// @Summary Get 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)"
// @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
// @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())
@@ -261,7 +259,7 @@ func GetDomain(db *gorm.DB) http.HandlerFunc {
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @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"
@@ -312,22 +310,22 @@ func CreateDomain(db *gorm.DB) http.HandlerFunc {
// 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
// @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())
@@ -390,20 +388,19 @@ func UpdateDomain(db *gorm.DB) http.HandlerFunc {
// DeleteDomain godoc
//
// @ID DeleteDomain
// @Summary Delete a domain
// @Tags DNS
// @Accept json
// @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
// @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())
@@ -437,13 +434,12 @@ func DeleteDomain(db *gorm.DB) http.HandlerFunc {
// @Summary List record sets for a domain
// @Description Filters: `name`, `type`, `status`.
// @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 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"
// @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"
@@ -509,22 +505,22 @@ func ListRecordSets(db *gorm.DB) http.HandlerFunc {
// 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
// @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())
@@ -610,22 +606,22 @@ func CreateRecordSet(db *gorm.DB) http.HandlerFunc {
// 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
// @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())
@@ -720,20 +716,19 @@ func UpdateRecordSet(db *gorm.DB) http.HandlerFunc {
// DeleteRecordSet godoc
//
// @ID DeleteRecordSet
// @Summary Delete a record set (API removes row; worker can optionally handle external deletion policy)
// @Tags DNS
// @Accept json
// @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
// @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())

View File

@@ -7,28 +7,56 @@ 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"`
AppsLoadBalancer *LoadBalancerResponse `json:"apps_load_balancer,omitempty"`
GlueOpsLoadBalancer *LoadBalancerResponse `json:"glueops_load_balancer,omitempty"`
BastionServer *ServerResponse `json:"bastion_server,omitempty"`
Provider string `json:"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"`
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"`
Provider string `json:"provider"`
Region string `json:"region"`
}
type UpdateClusterRequest struct {
Name *string `json:"name,omitempty"`
Provider *string `json:"provider,omitempty"`
Region *string `json:"region,omitempty"`
}
type AttachCaptainDomainRequest struct {
DomainID uuid.UUID `json:"domain_id"`
}
type AttachRecordSetRequest struct {
RecordSetID uuid.UUID `json:"record_set_id"`
}
type AttachLoadBalancerRequest struct {
LoadBalancerID uuid.UUID `json:"load_balancer_id"`
}
type AttachBastionRequest struct {
ServerID uuid.UUID `json:"server_id"`
}
type SetKubeconfigRequest struct {
Kubeconfig string `json:"kubeconfig"`
}
type AttachNodePoolRequest struct {
NodePoolID uuid.UUID `json:"node_pool_id"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -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

View File

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

View File

@@ -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"

View File

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

View File

@@ -31,7 +31,6 @@ import (
// @Summary List ssh keys (org scoped)
// @Description Returns ssh keys for the organization in X-Org-ID.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Success 200 {array} dto.SshResponse
@@ -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"

View File

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

View File

@@ -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]

View File

@@ -6,28 +6,40 @@ 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
)
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"` // nonprod.earth.onglueops.rocks
AppsLoadBalancer string `json:"cluster_load_balancer"` // {public_ip: 1.2.3.4, private_ip: 10.0.30.1, name: apps.CaqptainDomain}
GlueOpsLoadBalancer string `json:"control_load_balancer"` // {public_ip: 5.6.7.8, private_ip: 10.0.22.1, name: CaptainDomain}
ControlPlane string `json:"control_plane"` // <- dns cntlpn
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:"-"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

View File

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

View File

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

View File

@@ -67,7 +67,13 @@ func SPAHandler() (http.Handler, error) {
return
}
filePath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
raw := strings.TrimSpace(r.URL.Path)
if raw == "" || raw == "/" {
raw = "/index.html"
}
clean := path.Clean("/" + raw) // nosemgrep: autoglue.filesystem.no-path-clean
filePath := strings.TrimPrefix(clean, "/")
if filePath == "" {
filePath = "index.html"
}

56
main.go
View File

@@ -1,44 +1,44 @@
package main
import (
"os"
"github.com/glueops/autoglue/cmd"
"github.com/glueops/autoglue/docs"
"github.com/joho/godotenv"
"github.com/glueops/autoglue/internal/version"
)
// @title AutoGlue API
// @version 1.0
// @description API for managing K3s clusters across cloud providers
// @title AutoGlue API
// @version dev
// @description API for managing K3s clusters across cloud providers
// @contact.name GlueOps
// @contact.name GlueOps
// @servers.url https://autoglue.onglueops.rocks/api/v1
// @servers.description Production API
// @servers.url https://autoglue.apps.nonprod.earth.onglueops.rocks/api/v1
// @servers.description Staging API
// @servers.url http://localhost:8080/api/v1
// @servers.description Local dev
// @BasePath /api/v1
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Bearer token authentication
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Bearer token authentication
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name X-API-KEY
// @description User API key
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name X-API-KEY
// @description User API key
// @securityDefinitions.apikey OrgKeyAuth
// @in header
// @name X-ORG-KEY
// @description Org-level key/secret authentication
// @securityDefinitions.apikey OrgKeyAuth
// @in header
// @name X-ORG-KEY
// @description Org-level key/secret authentication
// @securityDefinitions.apikey OrgSecretAuth
// @in header
// @name X-ORG-SECRET
// @description Org-level secret
// @securityDefinitions.apikey OrgSecretAuth
// @in header
// @name X-ORG-SECRET
// @description Org-level secret
func main() {
_ = godotenv.Load()
docs.SwaggerInfo.Host = os.Getenv("SWAGGER_HOST")
docs.SwaggerInfo.Version = version.Version
cmd.Execute()
}

View File

@@ -6,5 +6,5 @@ RUN cd /var/lib/postgresql/ && \
openssl req -x509 -in server.req -text -key server.key -out server.crt && \
chmod 600 server.key && \
chown postgres:postgres server.key
USER non-root
CMD ["postgres", "-c", "ssl=on", "-c", "ssl_cert_file=/var/lib/postgresql/server.crt", "-c", "ssl_key_file=/var/lib/postgresql/server.key" ]

435
schema.sql Normal file
View File

@@ -0,0 +1,435 @@
-- Add new schema named "public"
CREATE SCHEMA IF NOT EXISTS "public";
-- Set comment to schema: "public"
COMMENT ON SCHEMA "public" IS 'standard public schema';
-- Create "jobs" table
CREATE TABLE "public"."jobs" (
"id" character varying NOT NULL,
"queue_name" character varying NOT NULL,
"status" character varying NOT NULL,
"arguments" jsonb NOT NULL DEFAULT '{}',
"result" jsonb NOT NULL DEFAULT '{}',
"last_error" character varying NULL,
"retry_count" bigint NOT NULL DEFAULT 0,
"max_retry" bigint NOT NULL DEFAULT 0,
"retry_interval" bigint NOT NULL DEFAULT 0,
"scheduled_at" timestamptz NULL DEFAULT now(),
"started_at" timestamptz NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- Create index "idx_jobs_scheduled_at" to table: "jobs"
CREATE INDEX "idx_jobs_scheduled_at" ON "public"."jobs" ("scheduled_at");
-- Create index "idx_jobs_started_at" to table: "jobs"
CREATE INDEX "idx_jobs_started_at" ON "public"."jobs" ("started_at");
-- Create "api_keys" table
CREATE TABLE "public"."api_keys" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"name" text NOT NULL DEFAULT '',
"key_hash" text NOT NULL,
"scope" text NOT NULL DEFAULT '',
"user_id" text NULL,
"org_id" text NULL,
"secret_hash" text NULL,
"expires_at" timestamptz NULL,
"revoked" boolean NOT NULL DEFAULT false,
"prefix" text NULL,
"last_used_at" timestamptz NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- Create index "idx_api_keys_key_hash" to table: "api_keys"
CREATE UNIQUE INDEX "idx_api_keys_key_hash" ON "public"."api_keys" ("key_hash");
-- Create "refresh_tokens" table
CREATE TABLE "public"."refresh_tokens" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" text NOT NULL,
"family_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamptz NOT NULL,
"revoked_at" timestamptz NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- Create index "idx_refresh_tokens_family_id" to table: "refresh_tokens"
CREATE INDEX "idx_refresh_tokens_family_id" ON "public"."refresh_tokens" ("family_id");
-- Create index "idx_refresh_tokens_token_hash" to table: "refresh_tokens"
CREATE UNIQUE INDEX "idx_refresh_tokens_token_hash" ON "public"."refresh_tokens" ("token_hash");
-- Create index "idx_refresh_tokens_user_id" to table: "refresh_tokens"
CREATE INDEX "idx_refresh_tokens_user_id" ON "public"."refresh_tokens" ("user_id");
-- Create "users" table
CREATE TABLE "public"."users" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"display_name" text NULL,
"primary_email" text NULL,
"avatar_url" text NULL,
"is_disabled" boolean NULL,
"is_admin" boolean NULL DEFAULT false,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- Create "signing_keys" table
CREATE TABLE "public"."signing_keys" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"kid" text NOT NULL,
"alg" text NOT NULL,
"use" text NOT NULL DEFAULT 'sig',
"is_active" boolean NOT NULL DEFAULT true,
"public_pem" text NOT NULL,
"private_pem" text NOT NULL,
"not_before" timestamptz NULL,
"expires_at" timestamptz NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"rotated_from" text NULL,
PRIMARY KEY ("id")
);
-- Create index "idx_signing_keys_kid" to table: "signing_keys"
CREATE UNIQUE INDEX "idx_signing_keys_kid" ON "public"."signing_keys" ("kid");
-- Create "accounts" table
CREATE TABLE "public"."accounts" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"provider" text NOT NULL,
"subject" text NOT NULL,
"email" text NULL,
"email_verified" boolean NOT NULL DEFAULT false,
"profile" jsonb NOT NULL DEFAULT '{}',
"secret_hash" text NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_accounts_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION
);
-- Create index "idx_accounts_user_id" to table: "accounts"
CREATE INDEX "idx_accounts_user_id" ON "public"."accounts" ("user_id");
-- Create "organizations" table
CREATE TABLE "public"."organizations" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"name" text NOT NULL,
"domain" text NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- Create index "idx_organizations_domain" to table: "organizations"
CREATE INDEX "idx_organizations_domain" ON "public"."organizations" ("domain");
-- Create "annotations" table
CREATE TABLE "public"."annotations" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"key" text NOT NULL,
"value" text NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "fk_annotations_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_annotations_organization_id" to table: "annotations"
CREATE INDEX "idx_annotations_organization_id" ON "public"."annotations" ("organization_id");
-- Create "credentials" table
CREATE TABLE "public"."credentials" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"provider" character varying(50) NOT NULL,
"kind" character varying(50) NOT NULL,
"scope_kind" character varying(20) NOT NULL,
"scope" jsonb NOT NULL DEFAULT '{}',
"scope_fingerprint" character(64) NOT NULL,
"schema_version" bigint NOT NULL DEFAULT 1,
"name" character varying(100) NOT NULL DEFAULT '',
"scope_version" bigint NOT NULL DEFAULT 1,
"account_id" character varying(32) NULL,
"region" character varying(32) NULL,
"encrypted_data" text NOT NULL,
"iv" text NOT NULL,
"tag" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_credentials_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_credentials_organization_id" to table: "credentials"
CREATE INDEX "idx_credentials_organization_id" ON "public"."credentials" ("organization_id");
-- Create index "idx_credentials_scope_fingerprint" to table: "credentials"
CREATE INDEX "idx_credentials_scope_fingerprint" ON "public"."credentials" ("scope_fingerprint");
-- Create index "idx_kind_scope" to table: "credentials"
CREATE INDEX "idx_kind_scope" ON "public"."credentials" ("kind", "scope");
-- Create index "idx_provider_kind" to table: "credentials"
CREATE INDEX "idx_provider_kind" ON "public"."credentials" ("provider", "kind");
-- Create index "uniq_org_provider_scopekind_scope" to table: "credentials"
CREATE UNIQUE INDEX "uniq_org_provider_scopekind_scope" ON "public"."credentials" ("organization_id", "provider", "scope_kind", "scope_fingerprint");
-- Create "backups" table
CREATE TABLE "public"."backups" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"enabled" boolean NOT NULL DEFAULT false,
"credential_id" uuid NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_backups_credential" FOREIGN KEY ("credential_id") REFERENCES "public"."credentials" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION,
CONSTRAINT "fk_backups_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_backups_organization_id" to table: "backups"
CREATE INDEX "idx_backups_organization_id" ON "public"."backups" ("organization_id");
-- Create index "uniq_org_credential" to table: "backups"
CREATE UNIQUE INDEX "uniq_org_credential" ON "public"."backups" ("organization_id", "credential_id");
-- Create "load_balancers" table
CREATE TABLE "public"."load_balancers" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NULL,
"name" text NOT NULL,
"kind" text NOT NULL,
"public_ip_address" text NOT NULL,
"private_ip_address" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_load_balancers_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_load_balancers_organization_id" to table: "load_balancers"
CREATE INDEX "idx_load_balancers_organization_id" ON "public"."load_balancers" ("organization_id");
-- Create "ssh_keys" table
CREATE TABLE "public"."ssh_keys" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"name" text NOT NULL,
"public_key" text NOT NULL,
"encrypted_private_key" text NOT NULL,
"private_iv" text NOT NULL,
"private_tag" text NOT NULL,
"fingerprint" text NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "fk_ssh_keys_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_ssh_keys_fingerprint" to table: "ssh_keys"
CREATE INDEX "idx_ssh_keys_fingerprint" ON "public"."ssh_keys" ("fingerprint");
-- Create index "idx_ssh_keys_organization_id" to table: "ssh_keys"
CREATE INDEX "idx_ssh_keys_organization_id" ON "public"."ssh_keys" ("organization_id");
-- Create "servers" table
CREATE TABLE "public"."servers" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"hostname" text NULL,
"public_ip_address" text NULL,
"private_ip_address" text NOT NULL,
"ssh_user" text NOT NULL,
"ssh_key_id" uuid NOT NULL,
"role" text NOT NULL,
"status" text NULL DEFAULT 'pending',
"ssh_host_key" text NULL,
"ssh_host_key_algo" text NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_servers_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "fk_servers_ssh_key" FOREIGN KEY ("ssh_key_id") REFERENCES "public"."ssh_keys" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION
);
-- Create "domains" table
CREATE TABLE "public"."domains" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"domain_name" character varying(253) NOT NULL,
"zone_id" character varying(128) NOT NULL DEFAULT '',
"status" character varying(20) NOT NULL DEFAULT 'pending',
"last_error" text NOT NULL DEFAULT '',
"credential_id" uuid NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_domains_credential" FOREIGN KEY ("credential_id") REFERENCES "public"."credentials" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION,
CONSTRAINT "fk_domains_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_domains_organization_id" to table: "domains"
CREATE INDEX "idx_domains_organization_id" ON "public"."domains" ("organization_id");
-- Create index "uniq_org_domain" to table: "domains"
CREATE UNIQUE INDEX "uniq_org_domain" ON "public"."domains" ("organization_id", "domain_name");
-- Create "record_sets" table
CREATE TABLE "public"."record_sets" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"domain_id" uuid NOT NULL,
"name" character varying(253) NOT NULL,
"type" character varying(10) NOT NULL,
"ttl" bigint NULL,
"values" jsonb NOT NULL DEFAULT '[]',
"fingerprint" character(64) NOT NULL,
"status" character varying(20) NOT NULL DEFAULT 'pending',
"owner" character varying(16) NOT NULL DEFAULT 'unknown',
"last_error" text NOT NULL DEFAULT '',
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_record_sets_domain" FOREIGN KEY ("domain_id") REFERENCES "public"."domains" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_record_sets_domain_id" to table: "record_sets"
CREATE INDEX "idx_record_sets_domain_id" ON "public"."record_sets" ("domain_id");
-- Create index "idx_record_sets_fingerprint" to table: "record_sets"
CREATE INDEX "idx_record_sets_fingerprint" ON "public"."record_sets" ("fingerprint");
-- Create index "idx_record_sets_type" to table: "record_sets"
CREATE INDEX "idx_record_sets_type" ON "public"."record_sets" ("type");
-- Create "clusters" table
CREATE TABLE "public"."clusters" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"provider" text NULL,
"region" text NULL,
"status" character varying(20) NOT NULL DEFAULT 'pre_pending',
"last_error" text NOT NULL DEFAULT '',
"captain_domain_id" uuid NULL,
"control_plane_record_set_id" uuid NULL,
"apps_load_balancer_id" uuid NULL,
"glue_ops_load_balancer_id" uuid NULL,
"bastion_server_id" uuid NULL,
"random_token" text NULL,
"certificate_key" text NULL,
"encrypted_kubeconfig" text NULL,
"kube_iv" text NULL,
"kube_tag" text NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_clusters_apps_load_balancer" FOREIGN KEY ("apps_load_balancer_id") REFERENCES "public"."load_balancers" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION,
CONSTRAINT "fk_clusters_bastion_server" FOREIGN KEY ("bastion_server_id") REFERENCES "public"."servers" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION,
CONSTRAINT "fk_clusters_captain_domain" FOREIGN KEY ("captain_domain_id") REFERENCES "public"."domains" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION,
CONSTRAINT "fk_clusters_control_plane_record_set" FOREIGN KEY ("control_plane_record_set_id") REFERENCES "public"."record_sets" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION,
CONSTRAINT "fk_clusters_glue_ops_load_balancer" FOREIGN KEY ("glue_ops_load_balancer_id") REFERENCES "public"."load_balancers" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION,
CONSTRAINT "fk_clusters_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create "node_pools" table
CREATE TABLE "public"."node_pools" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"name" text NOT NULL,
"role" text NULL,
PRIMARY KEY ("id"),
CONSTRAINT "fk_node_pools_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_node_pools_organization_id" to table: "node_pools"
CREATE INDEX "idx_node_pools_organization_id" ON "public"."node_pools" ("organization_id");
-- Create "cluster_node_pools" table
CREATE TABLE "public"."cluster_node_pools" (
"node_pool_id" uuid NOT NULL DEFAULT gen_random_uuid(),
"cluster_id" uuid NOT NULL DEFAULT gen_random_uuid(),
PRIMARY KEY ("node_pool_id", "cluster_id"),
CONSTRAINT "fk_cluster_node_pools_cluster" FOREIGN KEY ("cluster_id") REFERENCES "public"."clusters" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "fk_cluster_node_pools_node_pool" FOREIGN KEY ("node_pool_id") REFERENCES "public"."node_pools" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create "labels" table
CREATE TABLE "public"."labels" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"key" text NOT NULL,
"value" text NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "fk_labels_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create index "idx_labels_organization_id" to table: "labels"
CREATE INDEX "idx_labels_organization_id" ON "public"."labels" ("organization_id");
-- Create "memberships" table
CREATE TABLE "public"."memberships" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"organization_id" uuid NOT NULL,
"role" text NOT NULL DEFAULT 'member',
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_memberships_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "fk_memberships_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION
);
-- Create index "idx_memberships_organization_id" to table: "memberships"
CREATE INDEX "idx_memberships_organization_id" ON "public"."memberships" ("organization_id");
-- Create index "idx_memberships_user_id" to table: "memberships"
CREATE INDEX "idx_memberships_user_id" ON "public"."memberships" ("user_id");
-- Create "node_annotations" table
CREATE TABLE "public"."node_annotations" (
"node_pool_id" uuid NOT NULL DEFAULT gen_random_uuid(),
"annotation_id" uuid NOT NULL DEFAULT gen_random_uuid(),
PRIMARY KEY ("node_pool_id", "annotation_id"),
CONSTRAINT "fk_node_annotations_annotation" FOREIGN KEY ("annotation_id") REFERENCES "public"."annotations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "fk_node_annotations_node_pool" FOREIGN KEY ("node_pool_id") REFERENCES "public"."node_pools" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create "node_labels" table
CREATE TABLE "public"."node_labels" (
"node_pool_id" uuid NOT NULL DEFAULT gen_random_uuid(),
"label_id" uuid NOT NULL DEFAULT gen_random_uuid(),
PRIMARY KEY ("node_pool_id", "label_id"),
CONSTRAINT "fk_node_labels_label" FOREIGN KEY ("label_id") REFERENCES "public"."labels" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "fk_node_labels_node_pool" FOREIGN KEY ("node_pool_id") REFERENCES "public"."node_pools" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create "node_servers" table
CREATE TABLE "public"."node_servers" (
"server_id" uuid NOT NULL DEFAULT gen_random_uuid(),
"node_pool_id" uuid NOT NULL DEFAULT gen_random_uuid(),
PRIMARY KEY ("server_id", "node_pool_id"),
CONSTRAINT "fk_node_servers_node_pool" FOREIGN KEY ("node_pool_id") REFERENCES "public"."node_pools" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "fk_node_servers_server" FOREIGN KEY ("server_id") REFERENCES "public"."servers" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create "taints" table
CREATE TABLE "public"."taints" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
"effect" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_taints_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create "node_taints" table
CREATE TABLE "public"."node_taints" (
"taint_id" uuid NOT NULL DEFAULT gen_random_uuid(),
"node_pool_id" uuid NOT NULL DEFAULT gen_random_uuid(),
PRIMARY KEY ("taint_id", "node_pool_id"),
CONSTRAINT "fk_node_taints_node_pool" FOREIGN KEY ("node_pool_id") REFERENCES "public"."node_pools" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "fk_node_taints_taint" FOREIGN KEY ("taint_id") REFERENCES "public"."taints" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create "master_keys" table
CREATE TABLE "public"."master_keys" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" text NOT NULL,
"is_active" boolean NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- Create "organization_keys" table
CREATE TABLE "public"."organization_keys" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"master_key_id" uuid NOT NULL,
"encrypted_key" text NOT NULL,
"iv" text NOT NULL,
"tag" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_organization_keys_master_key" FOREIGN KEY ("master_key_id") REFERENCES "public"."master_keys" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "fk_organization_keys_organization" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Create "user_emails" table
CREATE TABLE "public"."user_emails" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL,
"email" text NOT NULL,
"is_verified" boolean NOT NULL DEFAULT false,
"is_primary" boolean NOT NULL DEFAULT false,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
CONSTRAINT "fk_user_emails_user" FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION
);
-- Create index "idx_user_emails_user_id" to table: "user_emails"
CREATE INDEX "idx_user_emails_user_id" ON "public"."user_emails" ("user_id");

View File

@@ -1,137 +0,0 @@
//go:build ignore
// +build ignore
package main
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
type Target struct {
Name string
URL string
SHA256 string
}
const version = "0.16.2"
func main() {
targets := []Target{
{
Name: "pgweb-linux-amd64",
URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_linux_amd64.zip", version),
SHA256: "3d6c2063e1040b8a625eb7c43c9b84f8ed12cfc9a798eacbce85179963ee2554",
},
{
Name: "pgweb-linux-arm64",
URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_linux_arm64.zip", version),
SHA256: "079c698a323ed6431ce7e6343ee5847c7da62afbf45dfb2e78f8289d7b381783",
},
{
Name: "pgweb-darwin-amd64",
URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_darwin_amd64.zip", version),
SHA256: "c0a098e2eb9cf9f7c20161a2947522eb67eacbf2b6c3389c2f8e8c5ed7238957",
},
{
Name: "pgweb-darwin-arm64",
URL: fmt.Sprintf("https://github.com/sosedoff/pgweb/releases/download/v%s/pgweb_darwin_arm64.zip", version),
SHA256: "c8f5fca847f461ba22a619e2d96cb1656cefdffd8f2aef2340e14fc5b518d3a2",
},
}
outDir := filepath.Join("internal", "web", "pgwebbin")
_ = os.MkdirAll(outDir, 0o755)
for _, t := range targets {
destZip := filepath.Join(outDir, t.Name+".zip")
fmt.Printf("Downloading %s...\n", t.URL)
if err := downloadFile(destZip, t.URL); err != nil {
panic(err)
}
binPath := filepath.Join(outDir, t.Name)
if err := unzipSingle(destZip, binPath); err != nil {
panic(err)
}
_ = os.Remove(destZip)
// Make executable
if err := os.Chmod(binPath, 0o755); err != nil {
panic(err)
}
fmt.Printf("Saved %s\n", binPath)
// Compute checksum
sum, _ := fileSHA256(binPath)
fmt.Printf(" SHA256: %s\n", sum)
}
}
func downloadFile(dest, url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func unzipSingle(zipPath, outPath string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
if len(zr.File) == 0 {
return fmt.Errorf("zip file %s is empty", zipPath)
}
f := zr.File[0]
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, rc); err != nil {
return err
}
return nil
}

View File

@@ -1,20 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import { defineConfig, globalIgnores } from "eslint/config"
import globals from "globals"
import tseslint from "typescript-eslint"
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist", "src/sdk", "src/components"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
...tseslint.configs.recommended,
//reactHooks.configs['recommended-latest'],
//reactRefresh.configs.vite,
],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...(reactHooks.configs["recommended-latest"]?.rules ?? {}),
...(reactRefresh.configs.vite?.rules ?? {}),
"@typescript-eslint/no-explicit-any": "off",
},
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,

View File

@@ -37,8 +37,9 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-query": "^5.90.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -46,14 +47,16 @@
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"rapidoc": "^9.3.8",
"react": "^19.2.0",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.9.5",
"react-router-dom": "^7.9.6",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -64,10 +67,10 @@
"devDependencies": {
"@eslint/js": "9.39.1",
"@ianvs/prettier-plugin-sort-imports": "4.7.0",
"@types/node": "24.10.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@vitejs/plugin-react": "5.1.0",
"@types/node": "24.10.1",
"@types/react": "19.2.5",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.1",
"eslint": "9.39.1",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.4.24",

View File

@@ -2,25 +2,30 @@ import { AppShell } from "@/layouts/app-shell.tsx"
import { Route, Routes } from "react-router-dom"
import { ProtectedRoute } from "@/components/protected-route.tsx"
import { AnnotationPage } from "@/pages/annotations/annotation-page.tsx"
import { Login } from "@/pages/auth/login.tsx"
import { CredentialPage } from "@/pages/credentials/credential-page.tsx"
import { DnsPage } from "@/pages/dns/dns-page.tsx"
import { JobsPage } from "@/pages/jobs/jobs-page.tsx"
import { LabelsPage } from "@/pages/labels/labels-page.tsx"
import { MePage } from "@/pages/me/me-page.tsx"
import { NodePoolsPage } from "@/pages/nodepools/node-pools-page.tsx"
import { AnnotationPage } from "@/pages/annotation-page.tsx"
import { ClustersPage } from "@/pages/cluster-page"
import { CredentialPage } from "@/pages/credential-page.tsx"
import { DnsPage } from "@/pages/dns-page.tsx"
import { DocsPage } from "@/pages/docs-page.tsx"
import { JobsPage } from "@/pages/jobs-page.tsx"
import { LabelsPage } from "@/pages/labels-page.tsx"
import { LoadBalancersPage } from "@/pages/load-balancers-page"
import { Login } from "@/pages/login.tsx"
import { MePage } from "@/pages/me-page.tsx"
import { NodePoolsPage } from "@/pages/node-pools-page.tsx"
import { OrgApiKeys } from "@/pages/org/api-keys.tsx"
import { OrgMembers } from "@/pages/org/members.tsx"
import { OrgSettings } from "@/pages/org/settings.tsx"
import { ServerPage } from "@/pages/servers/server-page.tsx"
import { SshPage } from "@/pages/ssh/ssh-page.tsx"
import { TaintsPage } from "@/pages/taints/taints-page.tsx"
import { ServerPage } from "@/pages/server-page.tsx"
import { SshPage } from "@/pages/ssh-page.tsx"
import { TaintsPage } from "@/pages/taints-page.tsx"
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/docs" element={<DocsPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/me" element={<MePage />} />
@@ -37,6 +42,8 @@ export default function App() {
<Route path="/node-pools" element={<NodePoolsPage />} />
<Route path="/credentials" element={<CredentialPage />} />
<Route path="/dns" element={<DnsPage />} />
<Route path="/load-balancers" element={<LoadBalancersPage />} />
<Route path="/clusters" element={<ClustersPage />} />
<Route path="/admin/jobs" element={<JobsPage />} />
</Route>

View File

@@ -10,7 +10,9 @@ export const annotationsApi = {
}),
createAnnotation: (body: DtoCreateAnnotationRequest) =>
withRefresh(async () => {
return await annotations.createAnnotation({ body })
return await annotations.createAnnotation({
dtoCreateAnnotationRequest: body,
})
}),
getAnnotation: (id: string) =>
withRefresh(async () => {
@@ -22,6 +24,9 @@ export const annotationsApi = {
}),
updateAnnotation: (id: string, body: DtoUpdateAnnotationRequest) =>
withRefresh(async () => {
return await annotations.updateAnnotation({ id, body })
return await annotations.updateAnnotation({
id,
dtoUpdateAnnotationRequest: body,
})
}),
}

View File

@@ -4,14 +4,6 @@ import { makeArcherAdminApi } from "@/sdkClient.ts"
const archerAdmin = makeArcherAdminApi()
type ListParams = {
status?: "queued" | "running" | "succeeded" | "failed" | "canceled" | "retrying" | "scheduled"
queue?: string
q?: string
page?: number
pageSize?: number
}
export const archerAdminApi = {
listJobs: (params: AdminListArcherJobsRequest = {}) => {
return withRefresh(async () => {
@@ -25,7 +17,7 @@ export const archerAdminApi = {
run_at?: string
}) => {
return withRefresh(async () => {
return await archerAdmin.adminEnqueueArcherJob({ body })
return await archerAdmin.adminEnqueueArcherJob({ dtoEnqueueRequest: body })
})
},
retryJob: (id: string) => {

150
ui/src/api/clusters.ts Normal file
View File

@@ -0,0 +1,150 @@
import { withRefresh } from "@/api/with-refresh"
import type {
DtoAttachBastionRequest,
DtoAttachCaptainDomainRequest,
DtoAttachLoadBalancerRequest,
DtoAttachRecordSetRequest,
DtoCreateClusterRequest,
DtoSetKubeconfigRequest,
DtoUpdateClusterRequest,
} from "@/sdk"
import { makeClusterApi } from "@/sdkClient"
const clusters = makeClusterApi()
export const clustersApi = {
// --- basic CRUD ---
listClusters: (q?: string) =>
withRefresh(async () => {
return await clusters.listClusters(q ? { q } : {})
}),
getCluster: (id: string) =>
withRefresh(async () => {
return await clusters.getCluster({ clusterID: id })
}),
createCluster: (body: DtoCreateClusterRequest) =>
withRefresh(async () => {
return await clusters.createCluster({
dtoCreateClusterRequest: body,
})
}),
updateCluster: (id: string, body: DtoUpdateClusterRequest) =>
withRefresh(async () => {
return await clusters.updateCluster({
clusterID: id,
dtoUpdateClusterRequest: body,
})
}),
deleteCluster: (id: string) =>
withRefresh(async () => {
return await clusters.deleteCluster({ clusterID: id })
}),
// --- kubeconfig ---
setKubeconfig: (clusterID: string, body: DtoSetKubeconfigRequest) =>
withRefresh(async () => {
return await clusters.setClusterKubeconfig({
clusterID,
dtoSetKubeconfigRequest: body,
})
}),
clearKubeconfig: (clusterID: string) =>
withRefresh(async () => {
return await clusters.clearClusterKubeconfig({ clusterID })
}),
// --- captain domain ---
attachCaptainDomain: (clusterID: string, body: DtoAttachCaptainDomainRequest) =>
withRefresh(async () => {
return await clusters.attachCaptainDomain({
clusterID,
dtoAttachCaptainDomainRequest: body,
})
}),
detachCaptainDomain: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachCaptainDomain({ clusterID })
}),
// --- control plane record set ---
attachControlPlaneRecordSet: (clusterID: string, body: DtoAttachRecordSetRequest) =>
withRefresh(async () => {
return await clusters.attachControlPlaneRecordSet({
clusterID,
dtoAttachRecordSetRequest: body,
})
}),
detachControlPlaneRecordSet: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachControlPlaneRecordSet({ clusterID })
}),
// --- load balancers ---
attachAppsLoadBalancer: (clusterID: string, body: DtoAttachLoadBalancerRequest) =>
withRefresh(async () => {
return await clusters.attachAppsLoadBalancer({
clusterID,
dtoAttachLoadBalancerRequest: body,
})
}),
detachAppsLoadBalancer: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachAppsLoadBalancer({ clusterID })
}),
attachGlueOpsLoadBalancer: (clusterID: string, body: DtoAttachLoadBalancerRequest) =>
withRefresh(async () => {
return await clusters.attachGlueOpsLoadBalancer({
clusterID,
dtoAttachLoadBalancerRequest: body,
})
}),
detachGlueOpsLoadBalancer: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachGlueOpsLoadBalancer({ clusterID })
}),
// --- bastion ---
attachBastion: (clusterID: string, body: DtoAttachBastionRequest) =>
withRefresh(async () => {
return await clusters.attachBastionServer({
clusterID,
dtoAttachBastionRequest: body,
})
}),
detachBastion: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachBastionServer({ clusterID })
}),
// -- node-pools
attachNodePool: (clusterID: string, nodePoolID: string) =>
withRefresh(async () => {
return await clusters.attachNodePool({
clusterID,
dtoAttachNodePoolRequest: { node_pool_id: nodePoolID },
})
}),
detachNodePool: (clusterID: string, nodePoolID: string) =>
withRefresh(async () => {
return await clusters.detachNodePool({ clusterID, nodePoolID })
}),
}

View File

@@ -11,7 +11,7 @@ export const credentialsApi = {
}),
createCredential: async (body: DtoCreateCredentialRequest) =>
withRefresh(async () => {
return await credentials.createCredential({ body })
return await credentials.createCredential({ dtoCreateCredentialRequest: body })
}),
getCredential: async (id: string) =>
withRefresh(async () => {
@@ -23,7 +23,7 @@ export const credentialsApi = {
}),
updateCredential: async (id: string, body: DtoUpdateCredentialRequest) =>
withRefresh(async () => {
return await credentials.updateCredential({ id, body })
return await credentials.updateCredential({ id, dtoUpdateCredentialRequest: body })
}),
revealCredential: async (id: string) =>
withRefresh(async () => {

View File

@@ -20,11 +20,11 @@ export const dnsApi = {
}),
createDomain: async (body: DtoCreateDomainRequest) =>
withRefresh(async () => {
return await dns.createDomain({ body })
return await dns.createDomain({ dtoCreateDomainRequest: body })
}),
updateDomain: async (id: string, body: DtoUpdateDomainRequest) =>
withRefresh(async () => {
return await dns.updateDomain({ id, body })
return await dns.updateDomain({ id, dtoUpdateDomainRequest: body })
}),
deleteDomain: async (id: string) =>
withRefresh(async () => {
@@ -36,11 +36,11 @@ export const dnsApi = {
}),
createRecordSetsByDomain: async (domainId: string, body: DtoCreateRecordSetRequest) =>
withRefresh(async () => {
return await dns.createRecordSet({ domainId, body })
return await dns.createRecordSet({ domainId, dtoCreateRecordSetRequest: body })
}),
updateRecordSetsByDomain: async (id: string, body: DtoUpdateRecordSetRequest) =>
withRefresh(async () => {
return await dns.updateRecordSet({ id, body })
return await dns.updateRecordSet({ id, dtoUpdateRecordSetRequest: body })
}),
deleteRecordSetsByDomain: async (id: string) =>
withRefresh(async () => {

View File

@@ -11,7 +11,7 @@ export const labelsApi = {
}),
createLabel: (body: DtoCreateLabelRequest) =>
withRefresh(async () => {
return await labels.createLabel({ body })
return await labels.createLabel({ dtoCreateLabelRequest: body })
}),
getLabel: (id: string) =>
withRefresh(async () => {
@@ -23,6 +23,6 @@ export const labelsApi = {
}),
updateLabel: (id: string, body: DtoUpdateLabelRequest) =>
withRefresh(async () => {
return await labels.updateLabel({ id, body })
return await labels.updateLabel({ id, dtoUpdateLabelRequest: body })
}),
}

View File

@@ -0,0 +1,32 @@
import { withRefresh } from "@/api/with-refresh"
import type { DtoCreateLoadBalancerRequest, DtoUpdateLoadBalancerRequest } from "@/sdk"
import { makeLoadBalancerApi } from "@/sdkClient"
const loadBalancers = makeLoadBalancerApi()
export const loadBalancersApi = {
listLoadBalancers: () =>
withRefresh(async () => {
return await loadBalancers.listLoadBalancers()
}),
getLoadBalancer: (id: string) =>
withRefresh(async () => {
return await loadBalancers.getLoadBalancers({ id })
}),
createLoadBalancer: (body: DtoCreateLoadBalancerRequest) =>
withRefresh(async () => {
return await loadBalancers.createLoadBalancer({
dtoCreateLoadBalancerRequest: body,
})
}),
updateLoadBalancer: (id: string, body: DtoUpdateLoadBalancerRequest) =>
withRefresh(async () => {
return await loadBalancers.updateLoadBalancer({
id,
dtoUpdateLoadBalancerRequest: body,
})
}),
deleteLoadBalancer: (id: string) =>
withRefresh(async () => {
return await loadBalancers.deleteLoadBalancer({ id })
}),
}

View File

@@ -19,7 +19,7 @@ export const meApi = {
updateMe: (body: HandlersUpdateMeRequest) =>
withRefresh(async (): Promise<ModelsUser> => {
return await me.updateMe({ body })
return await me.updateMe({ handlersUpdateMeRequest: body })
}),
listKeys: () =>
@@ -29,7 +29,7 @@ export const meApi = {
createKey: (body: HandlersCreateUserKeyRequest) =>
withRefresh(async (): Promise<HandlersUserAPIKeyOut> => {
return await keys.createUserAPIKey({ body })
return await keys.createUserAPIKey({ handlersCreateUserKeyRequest: body })
}),
deleteKey: (id: string) =>

View File

@@ -22,7 +22,7 @@ export const nodePoolsApi = {
}),
createNodePool: (body: DtoCreateNodePoolRequest) =>
withRefresh(async () => {
return await nodePools.createNodePool({ body })
return await nodePools.createNodePool({ dtoCreateNodePoolRequest: body })
}),
getNodePool: (id: string) =>
withRefresh(async () => {
@@ -34,7 +34,7 @@ export const nodePoolsApi = {
}),
updateNodePool: (id: string, body: DtoUpdateNodePoolRequest) =>
withRefresh(async () => {
return await nodePools.updateNodePool({ id, body })
return await nodePools.updateNodePool({ id, dtoUpdateNodePoolRequest: body })
}),
// Servers
listNodePoolServers: (id: string) =>
@@ -43,7 +43,7 @@ export const nodePoolsApi = {
}),
attachNodePoolServer: (id: string, body: DtoAttachServersRequest) =>
withRefresh(async () => {
return await nodePools.attachNodePoolServers({ id, body })
return await nodePools.attachNodePoolServers({ id, dtoAttachServersRequest: body })
}),
detachNodePoolServers: (id: string, serverId: string) =>
withRefresh(async () => {
@@ -56,7 +56,7 @@ export const nodePoolsApi = {
}),
attachNodePoolTaints: (id: string, body: DtoAttachTaintsRequest) =>
withRefresh(async () => {
return await nodePools.attachNodePoolTaints({ id, body })
return await nodePools.attachNodePoolTaints({ id, dtoAttachTaintsRequest: body })
}),
detachNodePoolTaints: (id: string, taintId: string) =>
withRefresh(async () => {
@@ -69,7 +69,7 @@ export const nodePoolsApi = {
}),
attachNodePoolLabels: (id: string, body: DtoAttachLabelsRequest) =>
withRefresh(async () => {
return await nodePools.attachNodePoolLabels({ id, body })
return await nodePools.attachNodePoolLabels({ id, dtoAttachLabelsRequest: body })
}),
detachNodePoolLabels: (id: string, labelId: string) =>
withRefresh(async () => {
@@ -82,7 +82,7 @@ export const nodePoolsApi = {
}),
attachNodePoolAnnotations: (id: string, body: DtoAttachAnnotationsRequest) =>
withRefresh(async () => {
return await nodePools.attachNodePoolAnnotations({ id, body })
return await nodePools.attachNodePoolAnnotations({ id, dtoAttachAnnotationsRequest: body })
}),
detachNodePoolAnnotations: (id: string, annotationId: string) =>
withRefresh(async () => {

View File

@@ -11,7 +11,7 @@ export const serversApi = {
}),
createServer: (body: DtoCreateServerRequest) =>
withRefresh(async () => {
return await servers.createServer({ body })
return await servers.createServer({ dtoCreateServerRequest: body })
}),
getServer: (id: string) =>
withRefresh(async () => {
@@ -19,7 +19,7 @@ export const serversApi = {
}),
updateServer: (id: string, body: DtoUpdateServerRequest) =>
withRefresh(async () => {
return await servers.updateServer({ id, body })
return await servers.updateServer({ id, dtoUpdateServerRequest: body })
}),
deleteServer: (id: string) =>
withRefresh(async () => {

View File

@@ -33,7 +33,7 @@ export const sshApi = {
createSshKey: (body: DtoCreateSSHRequest) =>
withRefresh(async (): Promise<DtoSshResponse> => {
// SDK expects { body }
return await ssh.createSSHKey({ body })
return await ssh.createSSHKey({ dtoCreateSSHRequest: body })
}),
getSshKeyById: (id: string) =>

View File

@@ -10,7 +10,7 @@ export const taintsApi = {
}),
createTaint: (body: DtoCreateTaintRequest) =>
withRefresh(async () => {
return await taints.createTaint({ body })
return await taints.createTaint({ dtoCreateTaintRequest: body })
}),
getTaint: (id: string) =>
withRefresh(async () => {
@@ -22,6 +22,6 @@ export const taintsApi = {
}),
updateTaint: (id: string, body: DtoUpdateTaintRequest) =>
withRefresh(async () => {
return await taints.updateTaint({ id, body })
return await taints.updateTaint({ id, dtoUpdateTaintRequest: body })
}),
}

View File

@@ -4,6 +4,7 @@ import { orgStore } from "@/auth/org.ts"
import { Footer } from "@/layouts/footer.tsx"
import { adminNav, mainNav, orgNav, userNav } from "@/layouts/nav-config.ts"
import { OrgSwitcher } from "@/layouts/org-switcher.tsx"
import { ThemePillSwitcher } from "@/layouts/theme-switcher"
import { Topbar } from "@/layouts/topbar.tsx"
import { NavLink, Outlet } from "react-router-dom"
@@ -147,6 +148,7 @@ export const AppShell = () => {
<SidebarMenuButton asChild tooltip={n.label}>
<NavLink
to={n.to}
target={n.target ? n.target : "_self"}
className={({ isActive }) =>
cn("flex items-center gap-2", isActive && "text-primary")
}
@@ -160,6 +162,9 @@ export const AppShell = () => {
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<div className="mt-auto flex items-center justify-center p-3">
<ThemePillSwitcher />
</div>
</SidebarContent>
<SidebarFooter>

View File

@@ -33,7 +33,7 @@ function asClipboardText(v?: VersionInfo) {
return `v${v.version} (${shortCommit(v.commit)}) • built ${v.built}${v.go} ${v.goOS}/${v.goArch}`
}
export const Footer = memo(function Footer({ className }: { className?: string }) {
export const Footer = memo(function Footer() {
const footerQ = useQuery({
queryKey: ["footer"],
queryFn: () => metaApi.footer() as Promise<VersionInfo>,

View File

@@ -15,15 +15,19 @@ import {
import { AiOutlineCluster } from "react-icons/ai"
import { GrUserWorker } from "react-icons/gr"
import { MdOutlineDns } from "react-icons/md"
import { SiSwagger } from "react-icons/si"
import { TbLoadBalancer } from "react-icons/tb"
export type NavItem = {
to: string
label: string
icon: ComponentType<{ className?: string }>
target?: string
}
export const mainNav: NavItem[] = [
{ to: "/clusters", label: "Clusters", icon: AiOutlineCluster },
{ to: "/load-balancers", label: "Load Balancers", icon: TbLoadBalancer},
{ to: "/dns", label: "DNS", icon: MdOutlineDns },
{ to: "/node-pools", label: "Node Pools", icon: BoxesIcon },
{ to: "/annotations", label: "Annotations", icon: ComponentIcon },
@@ -45,4 +49,5 @@ export const userNav: NavItem[] = [{ to: "/me", label: "Profile", icon: User2 }]
export const adminNav: NavItem[] = [
{ to: "/admin/users", label: "Users Admin", icon: Users },
{ to: "/admin/jobs", label: "Jobs Admin", icon: GrUserWorker },
{ to: "/docs", label: "API Docs ", icon: SiSwagger, target: "_blank" },
]

View File

@@ -0,0 +1,78 @@
import { type ComponentType } from "react"
import { motion } from "framer-motion"
import { Monitor, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { cn } from "@/lib/utils"
type ThemeValue = "light" | "dark" | "system"
const options: { id: ThemeValue; icon: ComponentType<{ className?: string }>; label: string }[] = [
{ id: "light", icon: Sun, label: "Light" },
{ id: "dark", icon: Moon, label: "Dark" },
{ id: "system", icon: Monitor, label: "System" },
]
interface ThemePillSwitcherProps {
className?: string
variant?: "pill" | "wide"
ariaLabel?: string
}
export const ThemePillSwitcher = ({
className = "",
variant = "pill",
ariaLabel = "Toggle theme",
}: ThemePillSwitcherProps) => {
const { theme, setTheme } = useTheme()
const currentTheme = (theme ?? "system") as ThemeValue
const isPill = variant === "pill"
return (
<div
className={cn(
"inline-flex items-center",
isPill && "bg-muted/70 rounded-full p-1 text-xs shadow-sm",
!isPill && "gap-2",
className
)}
aria-label={ariaLabel}
role="radiogroup"
>
{options.map(({ id, icon: Icon, label }) => {
const isActive = currentTheme === id
return (
<button
key={id}
type="button"
role="radio"
aria-checked={isActive}
onClick={() => setTheme(id)}
aria-label={isPill ? label : undefined}
className={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background relative flex items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
isActive ? "text-foreground border" : "text-muted-foreground hover:text-foreground",
// --- Conditional Classes ---
// "pill" variant is a fixed 8x8 square
isPill && "h-8 w-8",
// "wide" variant has padding, a gap, and auto width
!isPill && "h-8 gap-2 px-3 text-sm font-medium"
)}
>
{isActive && (
<motion.span
layoutId="theme-switcher-pill"
className="bg-background absolute inset-0 rounded-full shadow-sm"
transition={{ type: "spring", stiffness: 350, damping: 26 }}
/>
)}
<Icon className="relative z-10 h-4 w-4" />
{!isPill && <span className="relative z-10">{label}</span>}
</button>
)
})}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react"
import { ThemePillSwitcher } from "@/layouts/theme-switcher"
import { Link, useLocation } from "react-router-dom"
import { useMe } from "@/hooks/use-me.ts"
@@ -69,6 +70,7 @@ export const Topbar = () => {
</Breadcrumb>
</div>
<ThemePillSwitcher variant="wide" />
<Button variant="ghost" size="sm" asChild>
<Link to="/me">{isLoading ? "…" : me?.display_name || "Profile"}</Link>
</Button>

View File

@@ -1,6 +1,5 @@
import { useMemo, useState } from "react"
import { annotationsApi } from "@/api/annotations.ts"
import { labelsApi } from "@/api/labels.ts"
import type { DtoLabelResponse } from "@/sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"

File diff suppressed because it is too large Load Diff

View File

@@ -192,7 +192,9 @@ function extractErr(e: any): string {
try {
const msg = (e as any)?.response?.data?.message || (e as any)?.message
if (msg) return String(msg)
} catch {}
} catch {
return "Unknown error"
}
return "Unknown error"
}

View File

@@ -224,9 +224,12 @@ export const DnsPage = () => {
const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data])
useEffect(() => {
if (!selected && domainsQ.data && domainsQ.data.length) {
setSelected(domainsQ.data[0]!)
const setSelectedDns = () => {
if (!selected && domainsQ.data && domainsQ.data.length) {
setSelected(domainsQ.data[0]!)
}
}
setSelectedDns()
}, [domainsQ.data, selected])
const filteredDomains = useMemo(() => {

171
ui/src/pages/docs-page.tsx Normal file
View File

@@ -0,0 +1,171 @@
import { useEffect, useRef, useState, type FC } from "react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import "rapidoc"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
type RdThemeMode = "auto" | "light" | "dark"
export const DocsPage: FC = () => {
const rdRef = useRef<any>(null)
const { theme, systemTheme, setTheme } = useTheme()
const [orgId, setOrgId] = useState("")
const [rdThemeMode, setRdThemeMode] = useState<RdThemeMode>("auto")
useEffect(() => {
const stateSetter = () => {
const stored = localStorage.getItem("autoglue.org")
if (stored) setOrgId(stored)
}
stateSetter()
}, [])
useEffect(() => {
const rd = rdRef.current
if (!rd) return
let effectiveTheme: "light" | "dark" = "light"
if (rdThemeMode === "light") {
effectiveTheme = "light"
} else if (rdThemeMode === "dark") {
effectiveTheme = "dark"
} else {
const appTheme = theme === "system" ? systemTheme : theme
effectiveTheme = appTheme === "dark" ? "dark" : "light"
}
rd.setAttribute("theme", effectiveTheme)
if (typeof window !== "undefined") {
const defaultServer = `${window.location.origin}/api/v1`
rd.setAttribute("default-api-server", defaultServer)
}
if (orgId) {
rd.setAttribute("api-key-name", "X-ORG-ID")
rd.setAttribute("api-key-location", "header")
rd.setAttribute("api-key-value", orgId)
} else {
rd.removeAttribute("api-key-value")
}
}, [theme, systemTheme, rdThemeMode, orgId])
const handleSaveOrg = () => {
const trimmed = orgId.trim()
localStorage.setItem("autoglue.org", trimmed)
const rd = rdRef.current
if (!rd) return
if (trimmed) {
rd.setAttribute("api-key-value", trimmed)
} else {
rd.removeAttribute("api-key-value")
}
}
const handleResetOrg = () => {
localStorage.removeItem("autoglue.org")
setOrgId("")
const rd = rdRef.current
if (!rd) return
rd.removeAttribute("api-key-value")
}
return (
<div className="flex h-[100svh] flex-col">
{/* Control bar */}
<Card className="rounded-none border-b">
<CardHeader className="py-3">
<CardTitle className="flex flex-wrap items-center justify-between gap-4 text-base">
<span>AutoGlue API Docs</span>
<div className="flex items-center gap-2 text-xs">
<div className="flex flex-wrap items-center gap-3 text-xs">
{/* Theme selector */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Docs theme</span>
<Select
value={rdThemeMode}
onValueChange={(v) => {
const mode = v as RdThemeMode
setRdThemeMode(mode)
if (mode === "auto") {
setTheme("system")
} else {
setTheme(v)
}
}}
>
<SelectTrigger className="h-8 w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto (match app)</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Org ID controls */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Org ID (X-ORG-ID)</span>
<Input
className="h-8 w-80"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
placeholder="org_..."
/>
<Button size="sm" onClick={handleSaveOrg}>
Save
</Button>
<Button size="sm" variant="outline" onClick={handleResetOrg}>
Reset
</Button>
</div>
</div>
</div>
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground py-0 pb-2 text-xs">
Requests from <code>&lt;rapi-doc&gt;</code> will include:
<code className="ml-1">Cookie: ag_jwt=</code> and{" "}
<code className="ml-1">X-ORG-ID={orgId}</code>
{!orgId && <> (set an Org ID above to send an X-ORG-ID header)</>}
</CardContent>
</Card>
{/* @ts-expect-error ts-2339 */}
<rapi-doc
ref={rdRef}
id="autoglue-docs"
spec-url="/swagger/openapi.json"
render-style="read"
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"
/>
</div>
)
}

View File

@@ -0,0 +1,479 @@
import { useMemo, useState } from "react"
import { loadBalancersApi } from "@/api/loadbalancers"
import type { DtoLoadBalancerResponse } from "@/sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CircleSlash2, Network, Pencil, Plus, Search } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { truncateMiddle } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
// --- schemas ---
const createLoadBalancerSchema = z.object({
name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"),
kind: z.enum(["glueops", "public"]).default("public"),
public_ip_address: z
.string()
.trim()
.min(1, "Public IP/hostname is required")
.max(255, "Max 255 chars"),
private_ip_address: z
.string()
.trim()
.min(1, "Private IP/hostname is required")
.max(255, "Max 255 chars"),
})
type CreateLoadBalancerInput = z.input<typeof createLoadBalancerSchema>
const updateLoadBalancerSchema = createLoadBalancerSchema.partial()
type UpdateLoadBalancerValues = z.infer<typeof updateLoadBalancerSchema>
// --- badge ---
function LoadBalancerBadge({
lb,
}: {
lb: Pick<DtoLoadBalancerResponse, "name" | "kind" | "public_ip_address" | "private_ip_address">
}) {
return (
<Badge variant="secondary" className="font-mono text-xs">
<Network className="mr-1 h-3 w-3" />
{lb.name} · {lb.kind} · {lb.public_ip_address} {lb.private_ip_address}
</Badge>
)
}
export const LoadBalancersPage = () => {
const [filter, setFilter] = useState<string>("")
const [createOpen, setCreateOpen] = useState<boolean>(false)
const [updateOpen, setUpdateOpen] = useState<boolean>(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const qc = useQueryClient()
const lbsQ = useQuery({
queryKey: ["loadBalancers"],
queryFn: () => loadBalancersApi.listLoadBalancers(),
})
// --- Create ---
const createForm = useForm<CreateLoadBalancerInput>({
resolver: zodResolver(createLoadBalancerSchema),
defaultValues: {
name: "",
kind: "public",
public_ip_address: "",
private_ip_address: "",
},
})
const createMut = useMutation({
mutationFn: (values: CreateLoadBalancerInput) => loadBalancersApi.createLoadBalancer(values),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["loadBalancers"] })
createForm.reset()
setCreateOpen(false)
toast.success("Load balancer created successfully.")
},
onError: (err: any) => {
toast.error(err?.message ?? "There was an error while creating the load balancer")
},
})
const onCreateSubmit = (values: CreateLoadBalancerInput) => {
createMut.mutate(values)
}
// --- Update ---
const updateForm = useForm<UpdateLoadBalancerValues>({
resolver: zodResolver(updateLoadBalancerSchema),
defaultValues: {},
})
const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: UpdateLoadBalancerValues }) =>
loadBalancersApi.updateLoadBalancer(id, values),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["loadBalancers"] })
updateForm.reset()
setUpdateOpen(false)
toast.success("Load balancer updated successfully.")
},
onError: (err: any) => {
toast.error(err?.message ?? "There was an error while updating the load balancer")
},
})
const openEdit = (lb: DtoLoadBalancerResponse) => {
setEditingId(lb.id!)
updateForm.reset({
name: lb.name ?? "",
kind: (lb.kind as "public" | "glueops") ?? "public",
public_ip_address: lb.public_ip_address ?? "",
private_ip_address: lb.private_ip_address ?? "",
})
setUpdateOpen(true)
}
// --- Delete ---
const deleteMut = useMutation({
mutationFn: (id: string) => loadBalancersApi.deleteLoadBalancer(id),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["loadBalancers"] })
setDeleteId(null)
toast.success("Load balancer deleted successfully.")
},
onError: (err: any) => {
toast.error(err?.message ?? "There was an error while deleting the load balancer")
},
})
// --- Filter ---
const filtered = useMemo(() => {
const data = lbsQ.data ?? []
const q = filter.trim().toLowerCase()
return q
? data.filter((lb: any) => {
return (
lb.name?.toLowerCase().includes(q) ||
lb.kind?.toLowerCase().includes(q) ||
lb.public_ip_address?.toLowerCase().includes(q) ||
lb.private_ip_address?.toLowerCase().includes(q)
)
})
: data
}, [filter, lbsQ.data])
if (lbsQ.isLoading) return <div className="p-6">Loading load balancers</div>
if (lbsQ.error) return <div className="p-6 text-red-500">Error loading load balancers.</div>
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Load Balancers</h1>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search load balancers"
className="w-64 pl-8"
/>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Load Balancer
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Load Balancer</DialogTitle>
</DialogHeader>
<Form {...createForm}>
<form className="space-y-4" onSubmit={createForm.handleSubmit(onCreateSubmit)}>
<FormField
control={createForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="apps-lb-01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="kind"
render={({ field }) => (
<FormItem>
<FormLabel>Kind</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value ?? "public"}>
<SelectTrigger>
<SelectValue placeholder="Select kind" />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">Public</SelectItem>
<SelectItem value="glueops">GlueOps</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="public_ip_address"
render={({ field }) => (
<FormItem>
<FormLabel>Public IP</FormLabel>
<FormControl>
<Input placeholder="1.2.3.4" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="private_ip_address"
render={({ field }) => (
<FormItem>
<FormLabel>Private IP</FormLabel>
<FormControl>
<Input placeholder="10.0.30.10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createMut.isPending}>
{createMut.isPending ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Public IP / Hostname</TableHead>
<TableHead>Private IP / Hostname</TableHead>
<TableHead>Summary</TableHead>
<TableHead className="w-[220px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((lb: DtoLoadBalancerResponse) => (
<TableRow key={lb.id}>
<TableCell>{lb.name}</TableCell>
<TableCell>{lb.kind}</TableCell>
<TableCell>{lb.public_ip_address}</TableCell>
<TableCell>{lb.private_ip_address}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<LoadBalancerBadge lb={lb} />
{lb.id && (
<code className="text-muted-foreground text-xs">
{truncateMiddle(lb.id, 6)}
</code>
)}
</div>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(lb)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteId(lb.id!)}
disabled={deleteMut.isPending && deleteId === lb.id}
>
{deleteMut.isPending && deleteId === lb.id ? "Deleting…" : "Delete"}
</Button>
</div>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-10 text-center">
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
No load balancers match your search.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Update dialog */}
<Dialog open={updateOpen} onOpenChange={setUpdateOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit Load Balancer</DialogTitle>
</DialogHeader>
<Form {...updateForm}>
<form
className="space-y-4"
onSubmit={updateForm.handleSubmit((values) => {
if (!editingId) return
updateMut.mutate({ id: editingId, values })
})}
>
<FormField
control={updateForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="apps-lb-01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="kind"
render={({ field }) => (
<FormItem>
<FormLabel>Kind</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value ?? "public"}>
<SelectTrigger>
<SelectValue placeholder="Select kind" />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">Public</SelectItem>
<SelectItem value="glueops">GlueOps</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="public_ip_address"
render={({ field }) => (
<FormItem>
<FormLabel>Public IP / Hostname</FormLabel>
<FormControl>
<Input placeholder="1.2.3.4 or apps.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="private_ip_address"
render={({ field }) => (
<FormItem>
<FormLabel>Private IP / Hostname</FormLabel>
<FormControl>
<Input placeholder="10.0.30.10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setUpdateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateMut.isPending}>
{updateMut.isPending ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Delete confirm dialog */}
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete load balancer</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm">
This action cannot be undone. Are you sure you want to delete this load balancer?
</p>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && deleteMut.mutate(deleteId)}
disabled={deleteMut.isPending}
>
{deleteMut.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,8 +1,6 @@
import { useEffect, useMemo } from "react"
import { credentialsApi } from "@/api/credentials.ts"
import { useEffect } from "react"
import { withRefresh } from "@/api/with-refresh.ts"
import { orgStore } from "@/auth/org.ts"
import type { DtoCredentialOut } from "@/sdk"
import { makeOrgsApi } from "@/sdkClient.ts"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
@@ -22,6 +20,7 @@ import {
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
/*
const isS3 = (c: DtoCredentialOut) =>
c.provider === "aws" &&
c.scope_kind === "service" &&
@@ -35,6 +34,7 @@ const isS3 = (c: DtoCredentialOut) =>
return false
}
})()
*/
const schema = z.object({
name: z.string().min(1, "Required"),
@@ -54,13 +54,14 @@ export const OrgSettings = () => {
queryFn: () => withRefresh(() => api.getOrg({ id: orgId! })),
})
/*
const credentialQ = useQuery({
queryKey: ["credentials", "s3"],
queryFn: () => credentialsApi.listCredentials(), // client-side filter
})
const s3Credentials = useMemo(() => (credentialQ.data ?? []).filter(isS3), [credentialQ.data])
*/
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
@@ -76,7 +77,7 @@ export const OrgSettings = () => {
domain: q.data.domain ?? "",
})
}
}, [q.data])
}, [q.data, form])
const updateMut = useMutation({
mutationFn: (v: Partial<Values>) => api.updateOrg({ id: orgId!, body: v }),

View File

@@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { formatDistanceToNow } from "date-fns"
import { Plus, Search } from "lucide-react"
import { useForm } from "react-hook-form"
import { useForm, useWatch } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
@@ -136,8 +136,17 @@ export const ServerPage = () => {
mode: "onChange",
})
const roleIsBastion = createForm.watch("role") === "bastion"
const pubCreate = createForm.watch("public_ip_address")?.trim() ?? ""
const watchedRoleCreate = useWatch({
control: createForm.control,
name: "role",
})
const roleIsBastion = watchedRoleCreate === "bastion"
const watchedPublicIpCreate = useWatch({
control: createForm.control,
name: "public_ip_address",
})
const pubCreate = watchedPublicIpCreate?.trim() ?? ""
const needPubCreate = roleIsBastion && pubCreate === ""
const createMut = useMutation({
@@ -160,8 +169,19 @@ export const ServerPage = () => {
mode: "onChange",
})
const roleIsBastionU = updateForm.watch("role") === "bastion"
const pubUpdate = updateForm.watch("public_ip_address")?.trim() ?? ""
const watchedRoleUpdate = useWatch({
control: updateForm.control,
name: "role",
})
const watchedPublicIpAddressUpdate = useWatch({
control: updateForm.control,
name: "public_ip_address",
})
const roleIsBastionU = watchedRoleUpdate === "bastion"
const pubUpdate = watchedPublicIpAddressUpdate?.trim() ?? ""
const needPubUpdate = roleIsBastionU && pubUpdate === ""
const updateMut = useMutation({

View File

@@ -4,11 +4,10 @@ import type { DtoCreateSSHRequest, DtoSshRevealResponse } from "@/sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Download, Eye, Loader2, Plus, Trash2 } from "lucide-react"
import { useForm } from "react-hook-form"
import { useForm, useWatch } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { truncateMiddle } from "@/lib/utils.ts"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
@@ -105,6 +104,11 @@ export const SshPage = () => {
},
})
const watchedType = useWatch({
control: form.control,
name: "type",
})
const createMutation = useMutation({
mutationFn: async (values: CreateKeyInput) => {
const payload: DtoCreateSSHRequest = {
@@ -257,7 +261,7 @@ export const SshPage = () => {
<FormControl>
<Select
value={field.value}
disabled={form.watch("type") === "ed25519"}
disabled={watchedType === "ed25519"}
onValueChange={field.onChange}
>
<SelectTrigger className="w-[180px]">
@@ -316,7 +320,6 @@ export const SshPage = () => {
<TableBody>
{filtered.map((k) => {
const keyType = getKeyType(k.public_key!)
const truncated = truncateMiddle(k.public_key!, 18)
return (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name || "—"}</TableCell>

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react"
import { type ReactNode } from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export type Theme = "light" | "dark" | "system"

View File

@@ -4,10 +4,12 @@ import {
AnnotationsApi,
ArcherAdminApi,
AuthApi,
ClustersApi,
Configuration,
CredentialsApi,
DNSApi,
LabelsApi,
LoadBalancersApi,
MeApi,
MeAPIKeysApi,
MetaApi,
@@ -122,4 +124,12 @@ export function makeCredentialsApi() {
export function makeDnsApi() {
return makeApiClient(DNSApi)
}
export function makeLoadBalancerApi() {
return makeApiClient(LoadBalancersApi)
}
export function makeClusterApi() {
return makeApiClient(ClustersApi)
}

30
ui/src/types/rapidoc.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
import type React from "react"
declare global {
namespace JSX {
interface IntrinsicElements {
"rapi-doc": React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
"spec-url"?: string
"render-style"?: string
theme?: string
"show-header"?: string | boolean
"persist-auth"?: string | boolean
"allow-advanced-search"?: string | boolean
"schema-description-expanded"?: string | boolean
"allow-schema-description-expand-toggle"?: string | boolean
"allow-spec-file-download"?: string | boolean
"allow-spec-file-load"?: string | boolean
"allow-spec-url-load"?: string | boolean
"allow-try"?: string | boolean
"schema-style"?: string
"fetch-credentials"?: string
"default-api-server"?: string
"api-key-name"?: string
"api-key-location"?: string
"api-key-value"?: string
}
}
}
}
export {}

View File

@@ -28,5 +28,5 @@
"@/*": ["./src/*"]
}
},
"include": ["src"]
"include": ["src", "src/types"]
}

View File

@@ -8,6 +8,8 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
},
"jsx": "react-jsx"
},
"include": ["src", "src/types"]
}

File diff suppressed because it is too large Load Diff