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>
This commit is contained in:
allanice001
2025-11-17 04:59:39 +00:00
parent 165d2a2af1
commit 7985b310c5
67 changed files with 10745 additions and 3283 deletions

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 versioning (go.mod uses major.minor; youre on 1.25.4)
GO_VERSION ?= 1.25.4 GO_VERSION ?= 1.25.4
SWAG_FLAGS ?= --v3.1 --outputTypes json,yaml,go
# SDK / package settings (TypeScript) # SDK / package settings (TypeScript)
SDK_TS_OUTDIR ?= sdk/ts SDK_TS_OUTDIR ?= sdk/ts
@@ -112,10 +113,11 @@ $(DOCS_JSON) $(DOCS_YAML): $(GO_SRCS)
@echo ">> Generating Swagger docs..." @echo ">> Generating Swagger docs..."
@if ! command -v swag >/dev/null 2>&1; then \ @if ! command -v swag >/dev/null 2>&1; then \
echo "Installing swag/v2 CLI @v2.0.0-rc4..."; \ echo "Installing swag/v2 CLI @v2.0.0-rc4..."; \
$(GOINSTALL) github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc4; \ $(GOINSTALL) github.com/swaggo/swag/v2/cmd/swag@latest; \
fi fi
@rm -rf docs/swagger.* docs/docs.go @rm -rf docs/swagger.* docs/docs.go
@swag init -g $(MAIN) -o docs @swag fmt -d .
@swag init $(SWAG_FLAGS) -g $(MAIN) -o docs
# --- spec validation + tag guard --- # --- spec validation + tag guard ---
validate-spec: $(DOCS_JSON) ## Validate docs/swagger.json and pin the core OpenAPI Generator version 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: Once you have an org - create a set of api keys for your org:
They will be in the format of: They will be in the format of:
Example values only; these are not real secrets.
```text ```text
Org Key: org_lnJwmyyWH7JC-JgZo5v3Kw Org Key: org_lnJwmyyWH7JC-JgZo5v3Kw
Org Secret: fqd9yebGMfK6h5HSgWn4sXrwr9xlFbvbIYtNylRElMQ 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 dbURL = cfg.DbURL
} }
studio, err := api.PgwebHandler( studio, err := api.MountDbStudio(
dbURL, dbURL,
"db-studio", "db-studio",
false, false,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

62
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/glueops/autoglue
go 1.25.4 go 1.25.4
require ( require (
ariga.io/atlas-provider-gorm v0.6.0
github.com/alexedwards/argon2id v1.0.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 v1.39.6
github.com/aws/aws-sdk-go-v2/config v1.31.18 github.com/aws/aws-sdk-go-v2/config v1.31.18
@@ -11,6 +12,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0
github.com/coreos/go-oidc/v3 v3.16.0 github.com/coreos/go-oidc/v3 v3.16.0
github.com/dyaksa/archer v1.1.3 github.com/dyaksa/archer v1.1.3
github.com/fergusstrange/embedded-postgres v1.33.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
@@ -23,7 +25,6 @@ require (
github.com/sosedoff/pgweb v0.16.2 github.com/sosedoff/pgweb v0.16.2
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0 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 github.com/swaggo/swag/v2 v2.0.0-rc4
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.44.0
golang.org/x/oauth2 v0.33.0 golang.org/x/oauth2 v0.33.0
@@ -34,8 +35,20 @@ require (
) )
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 filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml 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/KyleBanks/depth v1.2.1 // indirect
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 // 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 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
@@ -55,13 +68,19 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.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/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/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/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // 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/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.9 // 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/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // 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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // 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_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/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/quic-go/qpack v0.5.1 // 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/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // 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/subosito/gotenv v1.6.0 // indirect
github.com/sv-tools/openapi v0.2.1 // 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/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // 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 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.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // 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/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.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 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 google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v2 v2.4.0 // 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
) )

1697
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) mountAnnotationRoutes(v1, db, authOrg)
mountNodePoolRoutes(v1, db, authOrg) mountNodePoolRoutes(v1, db, authOrg)
mountDNSRoutes(v1, db, authOrg) mountDNSRoutes(v1, db, authOrg)
mountLoadBalancerRoutes(v1, db, authOrg)
mountClusterRoutes(v1, db, authOrg)
}) })
}) })
} }

View File

@@ -0,0 +1,39 @@
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))
})
}

View File

@@ -10,7 +10,7 @@ import (
pgcmd "github.com/sosedoff/pgweb/pkg/command" 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: // Normalize prefix for pgweb:
// - no leading slash // - no leading slash
// - always trailing slash if not empty // - 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 package api
import ( import (
"fmt"
"html/template"
"net/http"
"github.com/glueops/autoglue/docs" "github.com/glueops/autoglue/docs"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
httpSwagger "github.com/swaggo/http-swagger/v2"
) )
func mountSwaggerRoutes(r chi.Router) { func mountSwaggerRoutes(r chi.Router) {
r.Get("/swagger/*", httpSwagger.Handler( r.Get("/swagger", RapidDocHandler("/swagger/swagger.yaml"))
httpSwagger.URL("swagger.json"), r.Get("/swagger/index.html", RapidDocHandler("/swagger/swagger.yaml"))
))
r.Get("/swagger/swagger.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/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/swagger.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'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
// Vite dev & inline preamble/eval: // 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 // allow dev style + Google Fonts
"style-src 'self' 'unsafe-inline' http://localhost:5173 https://fonts.googleapis.com", "style-src 'self' 'unsafe-inline' http://localhost:5173 https://fonts.googleapis.com",
"img-src 'self' data: blob:", "img-src 'self' data: blob:",
// Google font files // Google font files
"font-src 'self' data: https://fonts.gstatic.com", "font-src 'self' data: https://fonts.gstatic.com",
// HMR connections // 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'", "frame-ancestors 'none'",
}, "; ")) }, "; "))
} else { } else {
@@ -49,11 +49,11 @@ func SecurityHeaders(next http.Handler) http.Handler {
"default-src 'self'", "default-src 'self'",
"base-uri 'self'", "base-uri 'self'",
"form-action '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", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob:", "img-src 'self' data: blob:",
"font-src 'self' data: https://fonts.gstatic.com", "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'", "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(SecurityHeaders)
r.Use(requestBodyLimit(10 << 20)) r.Use(requestBodyLimit(10 << 20))
r.Use(httprate.LimitByIP(100, 1*time.Minute)) r.Use(httprate.LimitByIP(100, 1*time.Minute))
r.Use(middleware.StripSlashes)
allowed := getAllowedOrigins() allowed := getAllowedOrigins()
r.Use(cors.Handler(cors.Options{ 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 := http.NewServeMux()
mux.Handle("/api/", r) mux.Handle("/api/", r)
mux.Handle("/api", r) mux.Handle("/api", r)
mux.Handle("/swagger", r)
mux.Handle("/swagger/", r) mux.Handle("/swagger/", r)
mux.Handle("/db-studio/", r) mux.Handle("/db-studio/", r)
mux.Handle("/debug/pprof/", r) mux.Handle("/debug/pprof/", r)
mux.Handle("/", proxy) mux.Handle("/", proxy)
return mux return mux
}
fmt.Println("Running in production mode")
if h, err := web.SPAHandler(); err == nil {
r.NotFound(h.ServeHTTP)
} else { } 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 return r

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,6 @@ import (
// @Summary List annotations (org scoped) // @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. // @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 // @Tags Annotations
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key" // @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) // @Summary Get annotation by ID (org scoped)
// @Description Returns one annotation. Add `include=node_pools` to include node pools. // @Description Returns one annotation. Add `include=node_pools` to include node pools.
// @Tags Annotations // @Tags Annotations
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Annotation ID (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) // @Summary Delete annotation (org scoped)
// @Description Permanently deletes the annotation. // @Description Permanently deletes the annotation.
// @Tags Annotations // @Tags Annotations
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Annotation ID (UUID)" // @Param id path string true "Annotation ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"

View File

@@ -2,7 +2,9 @@ package handlers
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"html/template"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -252,10 +254,11 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
accessTTL := 1 * time.Hour accessTTL := 1 * time.Hour
refreshTTL := 30 * 24 * time.Hour refreshTTL := 30 * 24 * time.Hour
cfgLoaded, _ := config.Load()
access, err := auth.IssueAccessToken(auth.IssueOpts{ access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: user.ID.String(), Subject: user.ID.String(),
Issuer: cfg.JWTIssuer, Issuer: cfgLoaded.JWTIssuer,
Audience: cfg.JWTAudience, Audience: cfgLoaded.JWTAudience,
TTL: accessTTL, TTL: accessTTL,
Claims: map[string]any{ Claims: map[string]any{
"email": email, "email": email,
@@ -273,7 +276,10 @@ func AuthCallback(db *gorm.DB) http.HandlerFunc {
return 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 != "" { if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
secure = strings.EqualFold(xf, "https") 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 // If the state indicates SPA popup mode, postMessage tokens to the opener and close
state := r.URL.Query().Get("state") state := r.URL.Query().Get("state")
if strings.Contains(state, "mode=spa") { if strings.Contains(state, "mode=spa") {
origin := "" origin := canonicalOrigin(cfg.OAuthRedirectBase)
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
if origin == "" { if origin == "" {
origin = cfg.OAuthRedirectBase origin = cfg.OAuthRedirectBase
} }
@@ -371,7 +370,10 @@ func Refresh(db *gorm.DB) http.HandlerFunc {
return 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 != "" { if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
secure = strings.EqualFold(xf, "https") secure = strings.EqualFold(xf, "https")
} }
@@ -424,6 +426,11 @@ func Logout(db *gorm.DB) http.HandlerFunc {
} }
clearCookie: clearCookie:
secure := true
if uParsed, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(uParsed) {
secure = false
}
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "ag_jwt", Name: "ag_jwt",
Value: "", Value: "",
@@ -432,11 +439,10 @@ func Logout(db *gorm.DB) http.HandlerFunc {
MaxAge: -1, MaxAge: -1,
Expires: time.Unix(0, 0), Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(cfg.OAuthRedirectBase, "https"), Secure: secure,
}) })
w.WriteHeader(204) w.WriteHeader(204)
} }
} }
@@ -506,21 +512,63 @@ func ensureAutoMembership(db *gorm.DB, userID uuid.UUID, email string) error {
}).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. // 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) { func writePostMessageHTML(w http.ResponseWriter, origin string, payload dto.TokenPair) {
b, _ := json.Marshal(payload) 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("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<!doctype html><html><body><script> _ = postMessageTpl.Execute(w, data)
(function(){ }
try {
var data = ` + string(b) + `; // canonicalOrigin returns scheme://host[:port] for a given URL, or "" if invalid.
if (window.opener) { func canonicalOrigin(raw string) string {
window.opener.postMessage({ type: 'autoglue:auth', payload: data }, '` + origin + `'); u, err := url.Parse(raw)
} if err != nil || u.Scheme == "" || u.Host == "" {
} catch (e) {} return ""
window.close(); }
})();
</script></body></html>`)) // 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 // ListCredentials godoc
// @ID ListCredentials //
// @Summary List credentials (metadata only) // @ID ListCredentials
// @Description Returns credential metadata for the current org. Secrets are never returned. // @Summary List credentials (metadata only)
// @Tags Credentials // @Description Returns credential metadata for the current org. Secrets are never returned.
// @Accept json // @Tags Credentials
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)" // @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param provider query string false "Filter by provider (e.g., aws)" // @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 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)" // @Param scope_kind query string false "Filter by scope kind (provider/service/resource)"
// @Success 200 {array} dto.CredentialOut // @Success 200 {array} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error" // @Failure 500 {string} string "internal server error"
// @Router /credentials [get] // @Router /credentials [get]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth // @Security OrgSecretAuth
func ListCredentials(db *gorm.DB) http.HandlerFunc { func ListCredentials(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -73,21 +73,21 @@ func ListCredentials(db *gorm.DB) http.HandlerFunc {
} }
// GetCredential godoc // GetCredential godoc
// @ID GetCredential //
// @Summary Get credential by ID (metadata only) // @ID GetCredential
// @Tags Credentials // @Summary Get credential by ID (metadata only)
// @Accept json // @Tags Credentials
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)" // @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)" // @Param id path string true "Credential ID (UUID)"
// @Success 200 {object} dto.CredentialOut // @Success 200 {object} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error" // @Failure 500 {string} string "internal server error"
// @Router /credentials/{id} [get] // @Router /credentials/{id} [get]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth // @Security OrgSecretAuth
func GetCredential(db *gorm.DB) http.HandlerFunc { func GetCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -117,21 +117,22 @@ func GetCredential(db *gorm.DB) http.HandlerFunc {
} }
// CreateCredential godoc // CreateCredential godoc
// @ID CreateCredential //
// @Summary Create a credential (encrypts secret) // @ID CreateCredential
// @Tags Credentials // @Summary Create a credential (encrypts secret)
// @Accept json // @Tags Credentials
// @Produce json // @Accept json
// @Param X-Org-ID header string false "Organization ID (UUID)" // @Produce json
// @Param body body dto.CreateCredentialRequest true "Credential payload" // @Param X-Org-ID header string false "Organization ID (UUID)"
// @Success 201 {object} dto.CredentialOut // @Param body body dto.CreateCredentialRequest true "Credential payload"
// @Failure 401 {string} string "Unauthorized" // @Success 201 {object} dto.CredentialOut
// @Failure 403 {string} string "organization required" // @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "internal server error" // @Failure 403 {string} string "organization required"
// @Router /credentials [post] // @Failure 500 {string} string "internal server error"
// @Security BearerAuth // @Router /credentials [post]
// @Security OrgKeyAuth // @Security BearerAuth
// @Security OrgSecretAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateCredential(db *gorm.DB) http.HandlerFunc { func CreateCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -166,21 +167,22 @@ func CreateCredential(db *gorm.DB) http.HandlerFunc {
} }
// UpdateCredential godoc // UpdateCredential godoc
// @ID UpdateCredential //
// @Summary Update credential metadata and/or rotate secret // @ID UpdateCredential
// @Tags Credentials // @Summary Update credential metadata and/or rotate secret
// @Accept json // @Tags Credentials
// @Produce json // @Accept json
// @Param X-Org-ID header string false "Organization ID (UUID)" // @Produce json
// @Param id path string true "Credential ID (UUID)" // @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param body body dto.UpdateCredentialRequest true "Fields to update" // @Param id path string true "Credential ID (UUID)"
// @Success 200 {object} dto.CredentialOut // @Param body body dto.UpdateCredentialRequest true "Fields to update"
// @Failure 403 {string} string "X-Org-ID required" // @Success 200 {object} dto.CredentialOut
// @Failure 404 {string} string "not found" // @Failure 403 {string} string "X-Org-ID required"
// @Router /credentials/{id} [patch] // @Failure 404 {string} string "not found"
// @Security BearerAuth // @Router /credentials/{id} [patch]
// @Security OrgKeyAuth // @Security BearerAuth
// @Security OrgSecretAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateCredential(db *gorm.DB) http.HandlerFunc { func UpdateCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -296,19 +298,19 @@ func UpdateCredential(db *gorm.DB) http.HandlerFunc {
} }
// DeleteCredential godoc // DeleteCredential godoc
// @ID DeleteCredential //
// @Summary Delete credential // @ID DeleteCredential
// @Tags Credentials // @Summary Delete credential
// @Accept json // @Tags Credentials
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)" // @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)" // @Param id path string true "Credential ID (UUID)"
// @Success 204 // @Success 204
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Router /credentials/{id} [delete] // @Router /credentials/{id} [delete]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth // @Security OrgSecretAuth
func DeleteCredential(db *gorm.DB) http.HandlerFunc { func DeleteCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -335,20 +337,21 @@ func DeleteCredential(db *gorm.DB) http.HandlerFunc {
} }
// RevealCredential godoc // RevealCredential godoc
// @ID RevealCredential //
// @Summary Reveal decrypted secret (one-time read) // @ID RevealCredential
// @Tags Credentials // @Summary Reveal decrypted secret (one-time read)
// @Accept json // @Tags Credentials
// @Produce json // @Accept json
// @Param X-Org-ID header string false "Organization ID (UUID)" // @Produce json
// @Param id path string true "Credential ID (UUID)" // @Param X-Org-ID header string false "Organization ID (UUID)"
// @Success 200 {object} map[string]any // @Param id path string true "Credential ID (UUID)"
// @Failure 403 {string} string "organization required" // @Success 200 {object} map[string]any
// @Failure 404 {string} string "not found" // @Failure 403 {string} string "organization required"
// @Router /credentials/{id}/reveal [post] // @Failure 404 {string} string "not found"
// @Security BearerAuth // @Router /credentials/{id}/reveal [post]
// @Security OrgKeyAuth // @Security BearerAuth
// @Security OrgSecretAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth
func RevealCredential(db *gorm.DB) http.HandlerFunc { func RevealCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) 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) // @Summary List domains (org scoped)
// @Description Returns domains for X-Org-ID. Filters: `domain_name`, `status`, `q` (contains). // @Description Returns domains for X-Org-ID. Filters: `domain_name`, `status`, `q` (contains).
// @Tags DNS // @Tags DNS
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param domain_name query string false "Exact domain name (lowercase, no trailing dot)" // @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 // GetDomain godoc
// //
// @ID GetDomain // @ID GetDomain
// @Summary Get a domain (org scoped) // @Summary Get a domain (org scoped)
// @Tags DNS // @Tags DNS
// @Accept json // @Produce json
// @Produce json // @Param X-Org-ID header string false "Organization UUID"
// @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Domain ID (UUID)"
// @Param id path string true "Domain ID (UUID)" // @Success 200 {object} dto.DomainResponse
// @Success 200 {object} dto.DomainResponse // @Failure 401 {string} string "Unauthorized"
// @Failure 401 {string} string "Unauthorized" // @Failure 403 {string} string "organization required"
// @Failure 403 {string} string "organization required" // @Failure 404 {string} string "not found"
// @Failure 404 {string} string "not found" // @Router /dns/domains/{id} [get]
// @Router /dns/domains/{id} [get] // @Security BearerAuth
// @Security BearerAuth // @Security OrgKeyAuth
// @Security OrgKeyAuth // @Security OrgSecretAuth
// @Security OrgSecretAuth
func GetDomain(db *gorm.DB) http.HandlerFunc { func GetDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -261,7 +259,7 @@ func GetDomain(db *gorm.DB) http.HandlerFunc {
// @Tags DNS // @Tags DNS
// @Accept json // @Accept json
// @Produce 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" // @Param body body dto.CreateDomainRequest true "Domain payload"
// @Success 201 {object} dto.DomainResponse // @Success 201 {object} dto.DomainResponse
// @Failure 400 {string} string "validation error" // @Failure 400 {string} string "validation error"
@@ -312,22 +310,22 @@ func CreateDomain(db *gorm.DB) http.HandlerFunc {
// UpdateDomain godoc // UpdateDomain godoc
// //
// @ID UpdateDomain // @ID UpdateDomain
// @Summary Update a domain (org scoped) // @Summary Update a domain (org scoped)
// @Tags DNS // @Tags DNS
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Domain ID (UUID)" // @Param id path string true "Domain ID (UUID)"
// @Param body body dto.UpdateDomainRequest true "Fields to update" // @Param body body dto.UpdateDomainRequest true "Fields to update"
// @Success 200 {object} dto.DomainResponse // @Success 200 {object} dto.DomainResponse
// @Failure 400 {string} string "validation error" // @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Router /dns/domains/{id} [patch] // @Router /dns/domains/{id} [patch]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth // @Security OrgSecretAuth
func UpdateDomain(db *gorm.DB) http.HandlerFunc { func UpdateDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -390,20 +388,19 @@ func UpdateDomain(db *gorm.DB) http.HandlerFunc {
// DeleteDomain godoc // DeleteDomain godoc
// //
// @ID DeleteDomain // @ID DeleteDomain
// @Summary Delete a domain // @Summary Delete a domain
// @Tags DNS // @Tags DNS
// @Accept json // @Produce json
// @Produce json // @Param X-Org-ID header string false "Organization UUID"
// @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Domain ID (UUID)"
// @Param id path string true "Domain ID (UUID)" // @Success 204
// @Success 204 // @Failure 403 {string} string "organization required"
// @Failure 403 {string} string "organization required" // @Failure 404 {string} string "not found"
// @Failure 404 {string} string "not found" // @Router /dns/domains/{id} [delete]
// @Router /dns/domains/{id} [delete] // @Security BearerAuth
// @Security BearerAuth // @Security OrgKeyAuth
// @Security OrgKeyAuth // @Security OrgSecretAuth
// @Security OrgSecretAuth
func DeleteDomain(db *gorm.DB) http.HandlerFunc { func DeleteDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -437,13 +434,12 @@ func DeleteDomain(db *gorm.DB) http.HandlerFunc {
// @Summary List record sets for a domain // @Summary List record sets for a domain
// @Description Filters: `name`, `type`, `status`. // @Description Filters: `name`, `type`, `status`.
// @Tags DNS // @Tags DNS
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param domain_id path string true "Domain ID (UUID)" // @Param domain_id path string true "Domain ID (UUID)"
// @Param name query string false "Exact relative name or FQDN (server normalizes)" // @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 type query string false "RR type (A, AAAA, CNAME, TXT, MX, NS, SRV, CAA)"
// @Param status query string false "pending|provisioning|ready|failed" // @Param status query string false "pending|provisioning|ready|failed"
// @Success 200 {array} dto.RecordSetResponse // @Success 200 {array} dto.RecordSetResponse
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "domain not found" // @Failure 404 {string} string "domain not found"
@@ -509,22 +505,22 @@ func ListRecordSets(db *gorm.DB) http.HandlerFunc {
// CreateRecordSet godoc // CreateRecordSet godoc
// //
// @ID CreateRecordSet // @ID CreateRecordSet
// @Summary Create a record set (pending; Archer will UPSERT to Route 53) // @Summary Create a record set (pending; Archer will UPSERT to Route 53)
// @Tags DNS // @Tags DNS
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param domain_id path string true "Domain ID (UUID)" // @Param domain_id path string true "Domain ID (UUID)"
// @Param body body dto.CreateRecordSetRequest true "Record set payload" // @Param body body dto.CreateRecordSetRequest true "Record set payload"
// @Success 201 {object} dto.RecordSetResponse // @Success 201 {object} dto.RecordSetResponse
// @Failure 400 {string} string "validation error" // @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "domain not found" // @Failure 404 {string} string "domain not found"
// @Router /dns/domains/{domain_id}/records [post] // @Router /dns/domains/{domain_id}/records [post]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth // @Security OrgSecretAuth
func CreateRecordSet(db *gorm.DB) http.HandlerFunc { func CreateRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -610,22 +606,22 @@ func CreateRecordSet(db *gorm.DB) http.HandlerFunc {
// UpdateRecordSet godoc // UpdateRecordSet godoc
// //
// @ID UpdateRecordSet // @ID UpdateRecordSet
// @Summary Update a record set (flips to pending for reconciliation) // @Summary Update a record set (flips to pending for reconciliation)
// @Tags DNS // @Tags DNS
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Record Set ID (UUID)" // @Param id path string true "Record Set ID (UUID)"
// @Param body body dto.UpdateRecordSetRequest true "Fields to update" // @Param body body dto.UpdateRecordSetRequest true "Fields to update"
// @Success 200 {object} dto.RecordSetResponse // @Success 200 {object} dto.RecordSetResponse
// @Failure 400 {string} string "validation error" // @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Router /dns/records/{id} [patch] // @Router /dns/records/{id} [patch]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
// @Security OrgSecretAuth // @Security OrgSecretAuth
func UpdateRecordSet(db *gorm.DB) http.HandlerFunc { func UpdateRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
@@ -720,20 +716,19 @@ func UpdateRecordSet(db *gorm.DB) http.HandlerFunc {
// DeleteRecordSet godoc // DeleteRecordSet godoc
// //
// @ID DeleteRecordSet // @ID DeleteRecordSet
// @Summary Delete a record set (API removes row; worker can optionally handle external deletion policy) // @Summary Delete a record set (API removes row; worker can optionally handle external deletion policy)
// @Tags DNS // @Tags DNS
// @Accept json // @Produce json
// @Produce json // @Param X-Org-ID header string false "Organization UUID"
// @Param X-Org-ID header string false "Organization UUID" // @Param id path string true "Record Set ID (UUID)"
// @Param id path string true "Record Set ID (UUID)" // @Success 204
// @Success 204 // @Failure 403 {string} string "organization required"
// @Failure 403 {string} string "organization required" // @Failure 404 {string} string "not found"
// @Failure 404 {string} string "not found" // @Router /dns/records/{id} [delete]
// @Router /dns/records/{id} [delete] // @Security BearerAuth
// @Security BearerAuth // @Security OrgKeyAuth
// @Security OrgKeyAuth // @Security OrgSecretAuth
// @Security OrgSecretAuth
func DeleteRecordSet(db *gorm.DB) http.HandlerFunc { func DeleteRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context()) orgID, ok := httpmiddleware.OrgIDFrom(r.Context())

View File

@@ -7,28 +7,52 @@ import (
) )
type ClusterResponse struct { type ClusterResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Provider string `json:"provider"` CaptainDomain *DomainResponse `json:"captain_domain,omitempty"`
Region string `json:"region"` ControlPlaneRecordSet *RecordSetResponse `json:"control_plane_record_set,omitempty"`
Status string `json:"status"` AppsLoadBalancer *LoadBalancerResponse `json:"apps_load_balancer,omitempty"`
CaptainDomain string `json:"captain_domain"` GlueOpsLoadBalancer *LoadBalancerResponse `json:"glueops_load_balancer,omitempty"`
ClusterLoadBalancer string `json:"cluster_load_balancer"` BastionServer *ServerResponse `json:"bastion_server,omitempty"`
RandomToken string `json:"random_token"` Provider string `json:"provider"`
CertificateKey string `json:"certificate_key"` Region string `json:"region"`
ControlLoadBalancer string `json:"control_load_balancer"` Status string `json:"status"`
NodePools []NodePoolResponse `json:"node_pools,omitempty"` LastError string `json:"last_error"`
BastionServer *ServerResponse `json:"bastion_server,omitempty"` RandomToken string `json:"random_token"`
CreatedAt time.Time `json:"created_at"` CertificateKey string `json:"certificate_key"`
UpdatedAt time.Time `json:"updated_at"` NodePools []NodePoolResponse `json:"node_pools,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type CreateClusterRequest struct { type CreateClusterRequest struct {
Name string `json:"name"` Name string `json:"name"`
Provider string `json:"provider"` Provider string `json:"provider"`
Region string `json:"region"` Region string `json:"region"`
Status string `json:"status"` }
CaptainDomain string `json:"captain_domain"`
ClusterLoadBalancer *string `json:"cluster_load_balancer"` type UpdateClusterRequest struct {
ControlLoadBalancer *string `json:"control_load_balancer"` 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"`
} }

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" enums:"glueops|public"`
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 // @Description Returns 200 OK when the service is up
// @Tags Health // @Tags Health
// @ID HealthCheck // operationId // @ID HealthCheck // operationId
// @Accept json
// @Produce json // @Produce json
// @Success 200 {object} HealthStatus // @Success 200 {object} HealthStatus
// @Router /healthz [get] // @Router /healthz [get]

View File

@@ -25,7 +25,6 @@ import (
// @Summary List Archer jobs (admin) // @Summary List Archer jobs (admin)
// @Description Paginated background jobs with optional filters. Search `q` may match id, type, error, payload (implementation-dependent). // @Description Paginated background jobs with optional filters. Search `q` may match id, type, error, payload (implementation-dependent).
// @Tags ArcherAdmin // @Tags ArcherAdmin
// @Accept json
// @Produce json // @Produce json
// @Param status query string false "Filter by status" Enums(queued,running,succeeded,failed,canceled,retrying,scheduled) // @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" // @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) // @Summary List Archer queues (admin)
// @Description Summary metrics per queue (pending, running, failed, scheduled). // @Description Summary metrics per queue (pending, running, failed, scheduled).
// @Tags ArcherAdmin // @Tags ArcherAdmin
// @Accept json
// @Produce json // @Produce json
// @Success 200 {array} dto.QueueInfo // @Success 200 {array} dto.QueueInfo
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"

View File

@@ -22,7 +22,6 @@ import (
// @Summary List node labels (org scoped) // @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. // @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 // @Tags Labels
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key" // @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) // @Summary Get label by ID (org scoped)
// @Description Returns one label. // @Description Returns one label.
// @Tags Labels // @Tags Labels
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label ID (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) // @Summary Delete label (org scoped)
// @Description Permanently deletes the label. // @Description Permanently deletes the label.
// @Tags Labels // @Tags Labels
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label ID (UUID)" // @Param id path string true "Label ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"

View File

@@ -0,0 +1,283 @@
package handlers
import (
"encoding/json"
"errors"
"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" {
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 {
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) // @Summary List node pools (org scoped)
// @Description Returns node pools for the organization in X-Org-ID. // @Description Returns node pools for the organization in X-Org-ID.
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param q query string false "Name contains (case-insensitive)" // @Param q query string false "Name contains (case-insensitive)"
// @Success 200 {array} dto.NodePoolResponse // @Success 200 {array} dto.NodePoolResponse
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node pools" // @Failure 500 {string} string "failed to list node pools"
// @Router /node-pools [get] // @Router /node-pools [get]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -145,16 +144,15 @@ func ListNodePools(db *gorm.DB) http.HandlerFunc {
// @Summary Get node pool by ID (org scoped) // @Summary Get node pool by ID (org scoped)
// @Description Returns one node pool. Add `include=servers` to include servers. // @Description Returns one node pool. Add `include=servers` to include servers.
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Success 200 {object} dto.NodePoolResponse // @Success 200 {object} dto.NodePoolResponse
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed" // @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id} [get] // @Router /node-pools/{id} [get]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -194,13 +192,13 @@ func GetNodePool(db *gorm.DB) http.HandlerFunc {
// @Tags NodePools // @Tags NodePools
// @Accept json // @Accept json
// @Produce 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.CreateNodePoolRequest true "NodePool payload" // @Param body body dto.CreateNodePoolRequest true "NodePool payload"
// @Success 201 {object} dto.NodePoolResponse // @Success 201 {object} dto.NodePoolResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid server_ids" // @Failure 400 {string} string "invalid json / missing fields / invalid server_ids"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed" // @Failure 500 {string} string "create failed"
// @Router /node-pools [post] // @Router /node-pools [post]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -257,15 +255,15 @@ func CreateNodePool(db *gorm.DB) http.HandlerFunc {
// @Tags NodePools // @Tags NodePools
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Param body body dto.UpdateNodePoolRequest true "Fields to update" // @Param body body dto.UpdateNodePoolRequest true "Fields to update"
// @Success 200 {object} dto.NodePoolResponse // @Success 200 {object} dto.NodePoolResponse
// @Failure 400 {string} string "invalid id / invalid json" // @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed" // @Failure 500 {string} string "update failed"
// @Router /node-pools/{id} [patch] // @Router /node-pools/{id} [patch]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -327,15 +325,14 @@ func UpdateNodePool(db *gorm.DB) http.HandlerFunc {
// @Summary Delete node pool (org scoped) // @Summary Delete node pool (org scoped)
// @Description Permanently deletes the node pool. // @Description Permanently deletes the node pool.
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed" // @Failure 500 {string} string "delete failed"
// @Router /node-pools/{id} [delete] // @Router /node-pools/{id} [delete]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -369,16 +366,15 @@ func DeleteNodePool(db *gorm.DB) http.HandlerFunc {
// @ID ListNodePoolServers // @ID ListNodePoolServers
// @Summary List servers attached to a node pool (org scoped) // @Summary List servers attached to a node pool (org scoped)
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Success 200 {array} dto.ServerResponse // @Success 200 {array} dto.ServerResponse
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed" // @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id}/servers [get] // @Router /node-pools/{id}/servers [get]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -434,15 +430,15 @@ func ListNodePoolServers(db *gorm.DB) http.HandlerFunc {
// @Tags NodePools // @Tags NodePools
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Param body body dto.AttachServersRequest true "Server IDs to attach" // @Param body body dto.AttachServersRequest true "Server IDs to attach"
// @Success 204 {string} string "No Content" // @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid server_ids" // @Failure 400 {string} string "invalid id / invalid server_ids"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed" // @Failure 500 {string} string "attach failed"
// @Router /node-pools/{id}/servers [post] // @Router /node-pools/{id}/servers [post]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -521,17 +517,16 @@ func AttachNodePoolServers(db *gorm.DB) http.HandlerFunc {
// @ID DetachNodePoolServer // @ID DetachNodePoolServer
// @Summary Detach one server from a node pool (org scoped) // @Summary Detach one server from a node pool (org scoped)
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Param serverId path string true "Server ID (UUID)" // @Param serverId path string true "Server ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed" // @Failure 500 {string} string "detach failed"
// @Router /node-pools/{id}/servers/{serverId} [delete] // @Router /node-pools/{id}/servers/{serverId} [delete]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -588,16 +583,15 @@ func DetachNodePoolServer(db *gorm.DB) http.HandlerFunc {
// @ID ListNodePoolTaints // @ID ListNodePoolTaints
// @Summary List taints attached to a node pool (org scoped) // @Summary List taints attached to a node pool (org scoped)
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Success 200 {array} dto.TaintResponse // @Success 200 {array} dto.TaintResponse
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed" // @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id}/taints [get] // @Router /node-pools/{id}/taints [get]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -646,15 +640,15 @@ func ListNodePoolTaints(db *gorm.DB) http.HandlerFunc {
// @Tags NodePools // @Tags NodePools
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Param body body dto.AttachTaintsRequest true "Taint IDs to attach" // @Param body body dto.AttachTaintsRequest true "Taint IDs to attach"
// @Success 204 {string} string "No Content" // @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid taint_ids" // @Failure 400 {string} string "invalid id / invalid taint_ids"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed" // @Failure 500 {string} string "attach failed"
// @Router /node-pools/{id}/taints [post] // @Router /node-pools/{id}/taints [post]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -730,17 +724,16 @@ func AttachNodePoolTaints(db *gorm.DB) http.HandlerFunc {
// @ID DetachNodePoolTaint // @ID DetachNodePoolTaint
// @Summary Detach one taint from a node pool (org scoped) // @Summary Detach one taint from a node pool (org scoped)
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Param taintId path string true "Taint ID (UUID)" // @Param taintId path string true "Taint ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed" // @Failure 500 {string} string "detach failed"
// @Router /node-pools/{id}/taints/{taintId} [delete] // @Router /node-pools/{id}/taints/{taintId} [delete]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -798,16 +791,15 @@ func DetachNodePoolTaint(db *gorm.DB) http.HandlerFunc {
// @ID ListNodePoolLabels // @ID ListNodePoolLabels
// @Summary List labels attached to a node pool (org scoped) // @Summary List labels attached to a node pool (org scoped)
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label Pool ID (UUID)" // @Param id path string true "Label Pool ID (UUID)"
// @Success 200 {array} dto.LabelResponse // @Success 200 {array} dto.LabelResponse
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed" // @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id}/labels [get] // @Router /node-pools/{id}/labels [get]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -859,15 +851,15 @@ func ListNodePoolLabels(db *gorm.DB) http.HandlerFunc {
// @Tags NodePools // @Tags NodePools
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Param body body dto.AttachLabelsRequest true "Label IDs to attach" // @Param body body dto.AttachLabelsRequest true "Label IDs to attach"
// @Success 204 {string} string "No Content" // @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid server_ids" // @Failure 400 {string} string "invalid id / invalid server_ids"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed" // @Failure 500 {string} string "attach failed"
// @Router /node-pools/{id}/labels [post] // @Router /node-pools/{id}/labels [post]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -940,17 +932,16 @@ func AttachNodePoolLabels(db *gorm.DB) http.HandlerFunc {
// @ID DetachNodePoolLabel // @ID DetachNodePoolLabel
// @Summary Detach one label from a node pool (org scoped) // @Summary Detach one label from a node pool (org scoped)
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Param labelId path string true "Label ID (UUID)" // @Param labelId path string true "Label ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed" // @Failure 500 {string} string "detach failed"
// @Router /node-pools/{id}/labels/{labelId} [delete] // @Router /node-pools/{id}/labels/{labelId} [delete]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -1008,16 +999,15 @@ func DetachNodePoolLabel(db *gorm.DB) http.HandlerFunc {
// @ID ListNodePoolAnnotations // @ID ListNodePoolAnnotations
// @Summary List annotations attached to a node pool (org scoped) // @Summary List annotations attached to a node pool (org scoped)
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Success 200 {array} dto.AnnotationResponse // @Success 200 {array} dto.AnnotationResponse
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed" // @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id}/annotations [get] // @Router /node-pools/{id}/annotations [get]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -1069,15 +1059,15 @@ func ListNodePoolAnnotations(db *gorm.DB) http.HandlerFunc {
// @Tags NodePools // @Tags NodePools
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Group ID (UUID)" // @Param id path string true "Node Group ID (UUID)"
// @Param body body dto.AttachAnnotationsRequest true "Annotation IDs to attach" // @Param body body dto.AttachAnnotationsRequest true "Annotation IDs to attach"
// @Success 204 {string} string "No Content" // @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid server_ids" // @Failure 400 {string} string "invalid id / invalid server_ids"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed" // @Failure 500 {string} string "attach failed"
// @Router /node-pools/{id}/annotations [post] // @Router /node-pools/{id}/annotations [post]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @Security OrgKeyAuth
@@ -1151,17 +1141,16 @@ func AttachNodePoolAnnotations(db *gorm.DB) http.HandlerFunc {
// @ID DetachNodePoolAnnotation // @ID DetachNodePoolAnnotation
// @Summary Detach one annotation from a node pool (org scoped) // @Summary Detach one annotation from a node pool (org scoped)
// @Tags NodePools // @Tags NodePools
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)" // @Param id path string true "Node Pool ID (UUID)"
// @Param annotationId path string true "Annotation ID (UUID)" // @Param annotationId path string true "Annotation ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found" // @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed" // @Failure 500 {string} string "detach failed"
// @Router /node-pools/{id}/annotations/{annotationId} [delete] // @Router /node-pools/{id}/annotations/{annotationId} [delete]
// @Security BearerAuth // @Security BearerAuth
// @Security OrgKeyAuth // @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) // @Summary List servers (org scoped)
// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role. // @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
// @Tags Servers // @Tags Servers
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param status query string false "Filter by status (pending|provisioning|ready|failed)" // @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) // @Summary Get server by ID (org scoped)
// @Description Returns one server in the given organization. // @Description Returns one server in the given organization.
// @Tags Servers // @Tags Servers
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (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) // @Summary Delete server (org scoped)
// @Description Permanently deletes the server. // @Description Permanently deletes the server.
// @Tags Servers // @Tags Servers
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)" // @Param id path string true "Server ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @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) // @Summary List ssh keys (org scoped)
// @Description Returns ssh keys for the organization in X-Org-ID. // @Description Returns ssh keys for the organization in X-Org-ID.
// @Tags Ssh // @Tags Ssh
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Success 200 {array} dto.SshResponse // @Success 200 {array} dto.SshResponse
@@ -189,7 +188,6 @@ func CreateSSHKey(db *gorm.DB) http.HandlerFunc {
// @Summary Get ssh key by ID (org scoped) // @Summary Get ssh key by ID (org scoped)
// @Description Returns public key fields. Append `?reveal=true` to include the private key PEM. // @Description Returns public key fields. Append `?reveal=true` to include the private key PEM.
// @Tags Ssh // @Tags Ssh
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (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) // @Summary Delete ssh keypair (org scoped)
// @Description Permanently deletes a keypair. // @Description Permanently deletes a keypair.
// @Tags Ssh // @Tags Ssh
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)" // @Param id path string true "SSH Key ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"

View File

@@ -22,7 +22,6 @@ import (
// @Summary List node pool taints (org scoped) // @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. // @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 // @Tags Taints
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key" // @Param key query string false "Exact key"
@@ -70,7 +69,6 @@ func ListTaints(db *gorm.DB) http.HandlerFunc {
// @ID GetTaint // @ID GetTaint
// @Summary Get node taint by ID (org scoped) // @Summary Get node taint by ID (org scoped)
// @Tags Taints // @Tags Taints
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (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) // @Summary Delete taint (org scoped)
// @Description Permanently deletes the taint. // @Description Permanently deletes the taint.
// @Tags Taints // @Tags Taints
// @Accept json
// @Produce json // @Produce json
// @Param X-Org-ID header string false "Organization UUID" // @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)" // @Param id path string true "Node Taint ID (UUID)"
// @Success 204 {string} string "No Content" // @Success 204 "No Content"
// @Failure 400 {string} string "invalid id" // @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized" // @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required" // @Failure 403 {string} string "organization required"

View File

@@ -30,7 +30,6 @@ type VersionResponse struct {
// @Description Returns build/runtime metadata for the running service. // @Description Returns build/runtime metadata for the running service.
// @Tags Meta // @Tags Meta
// @ID Version // operationId // @ID Version // operationId
// @Accept json
// @Produce json // @Produce json
// @Success 200 {object} VersionResponse // @Success 200 {object} VersionResponse
// @Router /version [get] // @Router /version [get]

View File

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

15
main.go
View File

@@ -1,21 +1,20 @@
package main package main
import ( import (
"os"
"github.com/glueops/autoglue/cmd" "github.com/glueops/autoglue/cmd"
"github.com/glueops/autoglue/docs"
"github.com/joho/godotenv"
) )
// @title AutoGlue API // @title AutoGlue API
// @version 1.0 // @version 1.0
// @description API for managing K3s clusters across cloud providers // @description API for managing K3s clusters across cloud providers
// @contact.name GlueOps // @contact.name GlueOps
// @BasePath /api/v1 // @servers.url https://autoglue.onglueops.rocks/api/v1
// @schemes http https // @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
// @securityDefinitions.apikey BearerAuth // @securityDefinitions.apikey BearerAuth
// @in header // @in header
@@ -38,7 +37,5 @@ import (
// @description Org-level secret // @description Org-level secret
func main() { func main() {
_ = godotenv.Load()
docs.SwaggerInfo.Host = os.Getenv("SWAGGER_HOST")
cmd.Execute() 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 && \ openssl req -x509 -in server.req -text -key server.key -out server.crt && \
chmod 600 server.key && \ chmod 600 server.key && \
chown postgres:postgres 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" ] CMD ["postgres", "-c", "ssl=on", "-c", "ssl_cert_file=/var/lib/postgresql/server.crt", "-c", "ssl_key_file=/var/lib/postgresql/server.key" ]

430
schema.sql Normal file
View File

@@ -0,0 +1,430 @@
-- 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 "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 "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 "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 "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 "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" text NULL,
"captain_domain" text NOT NULL,
"apps_load_balancer" text NULL,
"glue_ops_load_balancer" text NULL,
"control_plane" text NULL,
"random_token" text NULL,
"certificate_key" text NULL,
"encrypted_kubeconfig" text NULL,
"kube_iv" text NULL,
"kube_tag" text NULL,
"bastion_server_id" uuid NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
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_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 "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 "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 "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 "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 "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 "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 js from "@eslint/js"
import globals from 'globals' import reactHooks from "eslint-plugin-react-hooks"
import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from "eslint-plugin-react-refresh"
import reactRefresh from 'eslint-plugin-react-refresh' import { defineConfig, globalIgnores } from "eslint/config"
import tseslint from 'typescript-eslint' import globals from "globals"
import { defineConfig, globalIgnores } from 'eslint/config' import tseslint from "typescript-eslint"
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(["dist", "src/sdk", "src/components"]),
{ {
files: ['**/*.{ts,tsx}'], files: ["**/*.{ts,tsx}"],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, ...tseslint.configs.recommended,
reactHooks.configs['recommended-latest'], //reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite, //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: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,

View File

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

View File

@@ -6,6 +6,7 @@ import { AnnotationPage } from "@/pages/annotations/annotation-page.tsx"
import { Login } from "@/pages/auth/login.tsx" import { Login } from "@/pages/auth/login.tsx"
import { CredentialPage } from "@/pages/credentials/credential-page.tsx" import { CredentialPage } from "@/pages/credentials/credential-page.tsx"
import { DnsPage } from "@/pages/dns/dns-page.tsx" import { DnsPage } from "@/pages/dns/dns-page.tsx"
import { DocsPage } from "@/pages/docs/docs-page.tsx"
import { JobsPage } from "@/pages/jobs/jobs-page.tsx" import { JobsPage } from "@/pages/jobs/jobs-page.tsx"
import { LabelsPage } from "@/pages/labels/labels-page.tsx" import { LabelsPage } from "@/pages/labels/labels-page.tsx"
import { MePage } from "@/pages/me/me-page.tsx" import { MePage } from "@/pages/me/me-page.tsx"
@@ -21,6 +22,8 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/docs" element={<DocsPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route path="/me" element={<MePage />} /> <Route path="/me" element={<MePage />} />

View File

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

View File

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

View File

@@ -15,11 +15,13 @@ import {
import { AiOutlineCluster } from "react-icons/ai" import { AiOutlineCluster } from "react-icons/ai"
import { GrUserWorker } from "react-icons/gr" import { GrUserWorker } from "react-icons/gr"
import { MdOutlineDns } from "react-icons/md" import { MdOutlineDns } from "react-icons/md"
import { SiSwagger } from "react-icons/si"
export type NavItem = { export type NavItem = {
to: string to: string
label: string label: string
icon: ComponentType<{ className?: string }> icon: ComponentType<{ className?: string }>
target?: string
} }
export const mainNav: NavItem[] = [ export const mainNav: NavItem[] = [
@@ -45,4 +47,5 @@ export const userNav: NavItem[] = [{ to: "/me", label: "Profile", icon: User2 }]
export const adminNav: NavItem[] = [ export const adminNav: NavItem[] = [
{ to: "/admin/users", label: "Users Admin", icon: Users }, { to: "/admin/users", label: "Users Admin", icon: Users },
{ to: "/admin/jobs", label: "Jobs Admin", icon: GrUserWorker }, { 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 { useMemo } from "react"
import { ThemePillSwitcher } from "@/layouts/theme-switcher"
import { Link, useLocation } from "react-router-dom" import { Link, useLocation } from "react-router-dom"
import { useMe } from "@/hooks/use-me.ts" import { useMe } from "@/hooks/use-me.ts"
@@ -69,6 +70,7 @@ export const Topbar = () => {
</Breadcrumb> </Breadcrumb>
</div> </div>
<ThemePillSwitcher variant="wide" />
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<Link to="/me">{isLoading ? "…" : me?.display_name || "Profile"}</Link> <Link to="/me">{isLoading ? "…" : me?.display_name || "Profile"}</Link>
</Button> </Button>

View File

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

View File

@@ -1,66 +1,28 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react";
import { credentialsApi } from "@/api/credentials" import { credentialsApi } from "@/api/credentials";
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import { AlertTriangle, Eye, Loader2, MoreHorizontal, Pencil, Plus, Search, Trash2 } from "lucide-react";
AlertTriangle, import { Controller, useForm } from "react-hook-form";
Eye, import { toast } from "sonner";
Loader2, import { z } from "zod";
MoreHorizontal,
Pencil,
Plus,
Search, import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
Trash2, import { Badge } from "@/components/ui/badge";
} from "lucide-react" import { Button } from "@/components/ui/button";
import { Controller, useForm } from "react-hook-form" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { toast } from "sonner" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { z } from "zod" 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 { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
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 { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
// -------------------- Constants -------------------- // -------------------- Constants --------------------
@@ -192,7 +154,9 @@ function extractErr(e: any): string {
try { try {
const msg = (e as any)?.response?.data?.message || (e as any)?.message const msg = (e as any)?.response?.data?.message || (e as any)?.message
if (msg) return String(msg) if (msg) return String(msg)
} catch {} } catch {
return "Unknown error"
}
return "Unknown error" return "Unknown error"
} }

View File

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

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/swagger.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

@@ -1,8 +1,6 @@
import { useEffect, useMemo } from "react" import { useEffect } from "react"
import { credentialsApi } from "@/api/credentials.ts"
import { withRefresh } from "@/api/with-refresh.ts" import { withRefresh } from "@/api/with-refresh.ts"
import { orgStore } from "@/auth/org.ts" import { orgStore } from "@/auth/org.ts"
import type { DtoCredentialOut } from "@/sdk"
import { makeOrgsApi } from "@/sdkClient.ts" import { makeOrgsApi } from "@/sdkClient.ts"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
@@ -22,6 +20,7 @@ import {
} from "@/components/ui/form.tsx" } from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx" import { Input } from "@/components/ui/input.tsx"
/*
const isS3 = (c: DtoCredentialOut) => const isS3 = (c: DtoCredentialOut) =>
c.provider === "aws" && c.provider === "aws" &&
c.scope_kind === "service" && c.scope_kind === "service" &&
@@ -35,6 +34,7 @@ const isS3 = (c: DtoCredentialOut) =>
return false return false
} }
})() })()
*/
const schema = z.object({ const schema = z.object({
name: z.string().min(1, "Required"), name: z.string().min(1, "Required"),
@@ -54,13 +54,14 @@ export const OrgSettings = () => {
queryFn: () => withRefresh(() => api.getOrg({ id: orgId! })), queryFn: () => withRefresh(() => api.getOrg({ id: orgId! })),
}) })
/*
const credentialQ = useQuery({ const credentialQ = useQuery({
queryKey: ["credentials", "s3"], queryKey: ["credentials", "s3"],
queryFn: () => credentialsApi.listCredentials(), // client-side filter queryFn: () => credentialsApi.listCredentials(), // client-side filter
}) })
const s3Credentials = useMemo(() => (credentialQ.data ?? []).filter(isS3), [credentialQ.data]) const s3Credentials = useMemo(() => (credentialQ.data ?? []).filter(isS3), [credentialQ.data])
*/
const form = useForm<Values>({ const form = useForm<Values>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -76,7 +77,7 @@ export const OrgSettings = () => {
domain: q.data.domain ?? "", domain: q.data.domain ?? "",
}) })
} }
}, [q.data]) }, [q.data, form])
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: (v: Partial<Values>) => api.updateOrg({ id: orgId!, body: v }), 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { Plus, Search } from "lucide-react" import { Plus, Search } from "lucide-react"
import { useForm } from "react-hook-form" import { useForm, useWatch } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import { z } from "zod" import { z } from "zod"
@@ -136,8 +136,17 @@ export const ServerPage = () => {
mode: "onChange", mode: "onChange",
}) })
const roleIsBastion = createForm.watch("role") === "bastion" const watchedRoleCreate = useWatch({
const pubCreate = createForm.watch("public_ip_address")?.trim() ?? "" 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 needPubCreate = roleIsBastion && pubCreate === ""
const createMut = useMutation({ const createMut = useMutation({
@@ -160,8 +169,19 @@ export const ServerPage = () => {
mode: "onChange", mode: "onChange",
}) })
const roleIsBastionU = updateForm.watch("role") === "bastion" const watchedRoleUpdate = useWatch({
const pubUpdate = updateForm.watch("public_ip_address")?.trim() ?? "" 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 needPubUpdate = roleIsBastionU && pubUpdate === ""
const updateMut = useMutation({ const updateMut = useMutation({

View File

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

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/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"] "include": ["src", "src/types"]
} }

View File

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

File diff suppressed because it is too large Load Diff