diff --git a/atlas.hcl b/atlas.hcl
deleted file mode 100644
index 19e8eab..0000000
--- a/atlas.hcl
+++ /dev/null
@@ -1,20 +0,0 @@
-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
-}
\ No newline at end of file
diff --git a/cmd/serve.go b/cmd/serve.go
index 0d24495..c6742b2 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -34,7 +34,7 @@ var serveCmd = &cobra.Command{
return err
}
- jobs, err := bg.NewJobs(rt.DB, cfg.DbURL)
+ jobs, err := bg.NewJobs(rt.DB, cfg.DbURL, cfg.BaseURL)
if err != nil {
log.Fatalf("failed to init background jobs: %v", err)
}
@@ -184,7 +184,7 @@ var serveCmd = &cobra.Command{
}
}()
- r := api.NewRouter(rt.DB, jobs, nil)
+ r := api.NewRouter(rt.DB, jobs, cfg, nil)
if cfg.DBStudioEnabled {
dbURL := cfg.DbURLRO
@@ -200,7 +200,7 @@ var serveCmd = &cobra.Command{
if err != nil {
log.Fatalf("failed to init db studio: %v", err)
} else {
- r = api.NewRouter(rt.DB, jobs, studio)
+ r = api.NewRouter(rt.DB, jobs, cfg, studio)
log.Printf("pgweb mounted at /db-studio/")
}
}
diff --git a/go.sum b/go.sum
index 12f2ffc..5c56cce 100644
--- a/go.sum
+++ b/go.sum
@@ -10,80 +10,42 @@ github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 h1:VauE2GcJNZFun2O
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
-github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
-github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
-github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
-github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.62.2 h1:zoD/SoiVQi8l8tuQn//VexrXS2yorg/+717JNA4Ble8=
-github.com/aws/aws-sdk-go-v2/service/route53 v1.62.2/go.mod h1:Ll1DCasPTBFtHK5t/U5WIwGIyRuY3xY+x8/LmqIlqpM=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3 h1:JRPXnIr0WwFsSHBmuCvT/uh0Vgys+crvwkOghbJEqi8=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.3/go.mod h1:DHddp7OO4bY467WVCqWBzk5+aEWn7vqYkap7UigJzGk=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
@@ -92,12 +54,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
-github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
-github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
-github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
-github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
@@ -126,8 +84,6 @@ github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCK
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
-github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
-github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
@@ -167,8 +123,6 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
-github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -261,12 +215,8 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
-github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
-github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
-github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
-github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -320,8 +270,6 @@ github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u2
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/go.mod h1:a06d/M1pxWi51qiSrfGMHaEydtuXT06nha8N2aNQuXk=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
-github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
-github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
@@ -335,13 +283,10 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
-go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
-golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -360,8 +305,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
-golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
@@ -410,8 +353,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
-google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/api/mount_api_routes.go b/internal/api/mount_api_routes.go
index 300f25a..951f2ce 100644
--- a/internal/api/mount_api_routes.go
+++ b/internal/api/mount_api_routes.go
@@ -3,11 +3,12 @@ package api
import (
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
+ "github.com/glueops/autoglue/internal/config"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
-func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
+func mountAPIRoutes(r chi.Router, db *gorm.DB, cfg config.Config, jobs *bg.Jobs) {
r.Route("/api", func(api chi.Router) {
api.Route("/v1", func(v1 chi.Router) {
authUser := httpmiddleware.AuthMiddleware(db, false)
@@ -33,7 +34,7 @@ func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
mountNodePoolRoutes(v1, db, authOrg)
mountDNSRoutes(v1, db, authOrg)
mountLoadBalancerRoutes(v1, db, authOrg)
- mountClusterRoutes(v1, db, jobs, authOrg)
+ mountClusterRoutes(v1, db, cfg, jobs, authOrg)
})
})
}
diff --git a/internal/api/mount_cluster_routes.go b/internal/api/mount_cluster_routes.go
index 07fa0aa..1275196 100644
--- a/internal/api/mount_cluster_routes.go
+++ b/internal/api/mount_cluster_routes.go
@@ -4,40 +4,41 @@ import (
"net/http"
"github.com/glueops/autoglue/internal/bg"
+ "github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
-func mountClusterRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authOrg func(http.Handler) http.Handler) {
+func mountClusterRoutes(r chi.Router, db *gorm.DB, cfg config.Config, jobs *bg.Jobs, authOrg func(http.Handler) http.Handler) {
r.Route("/clusters", func(c chi.Router) {
c.Use(authOrg)
- c.Get("/", handlers.ListClusters(db))
- c.Post("/", handlers.CreateCluster(db))
+ c.Get("/", handlers.ListClusters(db, cfg))
+ c.Post("/", handlers.CreateCluster(db, cfg))
- c.Get("/{clusterID}", handlers.GetCluster(db))
- c.Patch("/{clusterID}", handlers.UpdateCluster(db))
+ c.Get("/{clusterID}", handlers.GetCluster(db, cfg))
+ c.Patch("/{clusterID}", handlers.UpdateCluster(db, cfg))
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}/captain-domain", handlers.AttachCaptainDomain(db, cfg))
+ c.Delete("/{clusterID}/captain-domain", handlers.DetachCaptainDomain(db, cfg))
- c.Post("/{clusterID}/control-plane-record-set", handlers.AttachControlPlaneRecordSet(db))
- c.Delete("/{clusterID}/control-plane-record-set", handlers.DetachControlPlaneRecordSet(db))
+ c.Post("/{clusterID}/control-plane-record-set", handlers.AttachControlPlaneRecordSet(db, cfg))
+ c.Delete("/{clusterID}/control-plane-record-set", handlers.DetachControlPlaneRecordSet(db, cfg))
- 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}/apps-load-balancer", handlers.AttachAppsLoadBalancer(db, cfg))
+ c.Delete("/{clusterID}/apps-load-balancer", handlers.DetachAppsLoadBalancer(db, cfg))
+ c.Post("/{clusterID}/glueops-load-balancer", handlers.AttachGlueOpsLoadBalancer(db, cfg))
+ c.Delete("/{clusterID}/glueops-load-balancer", handlers.DetachGlueOpsLoadBalancer(db, cfg))
- c.Post("/{clusterID}/bastion", handlers.AttachBastionServer(db))
- c.Delete("/{clusterID}/bastion", handlers.DetachBastionServer(db))
+ c.Post("/{clusterID}/bastion", handlers.AttachBastionServer(db, cfg))
+ c.Delete("/{clusterID}/bastion", handlers.DetachBastionServer(db, cfg))
- c.Post("/{clusterID}/kubeconfig", handlers.SetClusterKubeconfig(db))
- c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
+ c.Post("/{clusterID}/kubeconfig", handlers.SetClusterKubeconfig(db, cfg))
+ c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db, cfg))
- c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db))
- c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(db))
+ c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db, cfg))
+ c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(db, cfg))
c.Get("/{clusterID}/runs", handlers.ListClusterRuns(db))
c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db))
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 499e9ac..05e1c5b 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -24,7 +24,7 @@ import (
"github.com/rs/zerolog/log"
)
-func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
+func NewRouter(db *gorm.DB, jobs *bg.Jobs, cfg config.Config, studio http.Handler) http.Handler {
zerolog.TimeFieldFormat = time.RFC3339
l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"})
@@ -70,7 +70,7 @@ func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
// Versioned API
- mountAPIRoutes(r, db, jobs)
+ mountAPIRoutes(r, db, cfg, jobs)
// Optional DB studio
if studio != nil {
diff --git a/internal/bg/bg.go b/internal/bg/bg.go
index 336be20..2c533fd 100644
--- a/internal/bg/bg.go
+++ b/internal/bg/bg.go
@@ -41,7 +41,7 @@ func archerOptionsFromDSN(dsn string) (*archer.Options, error) {
}, nil
}
-func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
+func NewJobs(gdb *gorm.DB, dbUrl, baseURL string) (*Jobs, error) {
opts, err := archerOptionsFromDSN(dbUrl)
if err != nil {
return nil, err
@@ -140,7 +140,7 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
c.Register(
"cluster_action",
- ClusterActionWorker(gdb),
+ ClusterActionWorker(gdb, baseURL),
archer.WithInstances(1),
)
return jobs, nil
diff --git a/internal/bg/cluster_action.go b/internal/bg/cluster_action.go
index d521498..41e2d21 100644
--- a/internal/bg/cluster_action.go
+++ b/internal/bg/cluster_action.go
@@ -30,7 +30,7 @@ type ClusterActionResult struct {
ElapsedMs int `json:"elapsed_ms"`
}
-func ClusterActionWorker(db *gorm.DB) archer.WorkerFn {
+func ClusterActionWorker(db *gorm.DB, baseURL string) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
start := time.Now()
var args ClusterActionArgs
@@ -121,6 +121,7 @@ func ClusterActionWorker(db *gorm.DB) archer.WorkerFn {
}
dtoCluster.OrgKey = &orgKey
dtoCluster.OrgSecret = &orgSecret
+ dtoCluster.BaseURL = baseURL
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
if err != nil {
diff --git a/internal/config/config.go b/internal/config/config.go
index 7f7c051..2fe6ba1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -16,6 +16,7 @@ type Config struct {
DbURLRO string
Port string
Host string
+ BaseURL string
JWTIssuer string
JWTAudience string
JWTPrivateEncKey string
@@ -61,6 +62,7 @@ func Load() (Config, error) {
v.SetDefault("db_studio.port", "0") // 0 = random
v.SetDefault("db_studio.user", "")
v.SetDefault("db_studio.pass", "")
+ v.SetDefault("base.url", "")
v.SetDefault("ui.dev", false)
v.SetDefault("env", "development")
@@ -75,6 +77,7 @@ func Load() (Config, error) {
keys := []string{
"bind.address",
"bind.port",
+ "base.url",
"database.url",
"database.url_ro",
"jwt.issuer",
@@ -106,6 +109,7 @@ func Load() (Config, error) {
DbURLRO: v.GetString("database.url_ro"),
Port: v.GetString("bind.port"),
Host: v.GetString("bind.address"),
+ BaseURL: v.GetString("base.url"),
JWTIssuer: v.GetString("jwt.issuer"),
JWTAudience: v.GetString("jwt.audience"),
JWTPrivateEncKey: v.GetString("jwt.private.enc.key"),
diff --git a/internal/handlers/clusters.go b/internal/handlers/clusters.go
index 62d74c9..72b6fef 100644
--- a/internal/handlers/clusters.go
+++ b/internal/handlers/clusters.go
@@ -12,6 +12,7 @@ import (
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/common"
+ "github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
@@ -37,7 +38,7 @@ import (
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func ListClusters(db *gorm.DB) http.HandlerFunc {
+func ListClusters(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -69,7 +70,7 @@ func ListClusters(db *gorm.DB) http.HandlerFunc {
out := make([]dto.ClusterResponse, 0, len(rows))
for _, row := range rows {
- cr := clusterToDTO(row)
+ cr := clusterToDTO(row, cfg)
if row.EncryptedKubeconfig != "" && row.KubeIV != "" && row.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(orgID, row.EncryptedKubeconfig, row.KubeIV, row.KubeTag, db)
@@ -104,7 +105,7 @@ func ListClusters(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func GetCluster(db *gorm.DB) http.HandlerFunc {
+func GetCluster(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -141,7 +142,7 @@ func GetCluster(db *gorm.DB) http.HandlerFunc {
return
}
- resp := clusterToDTO(cluster)
+ resp := clusterToDTO(cluster, cfg)
if cluster.EncryptedKubeconfig != "" && cluster.KubeIV != "" && cluster.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(orgID, cluster.EncryptedKubeconfig, cluster.KubeIV, cluster.KubeTag, db)
@@ -175,7 +176,7 @@ func GetCluster(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func CreateCluster(db *gorm.DB) http.HandlerFunc {
+func CreateCluster(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -219,7 +220,7 @@ func CreateCluster(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusCreated, clusterToDTO(c))
+ utils.WriteJSON(w, http.StatusCreated, clusterToDTO(c, cfg))
}
}
@@ -244,7 +245,7 @@ func CreateCluster(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func UpdateCluster(db *gorm.DB) http.HandlerFunc {
+func UpdateCluster(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -317,7 +318,7 @@ func UpdateCluster(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -389,7 +390,7 @@ func DeleteCluster(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func AttachCaptainDomain(db *gorm.DB) http.HandlerFunc {
+func AttachCaptainDomain(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -455,7 +456,7 @@ func AttachCaptainDomain(db *gorm.DB) http.HandlerFunc {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -478,7 +479,7 @@ func AttachCaptainDomain(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func DetachCaptainDomain(db *gorm.DB) http.HandlerFunc {
+func DetachCaptainDomain(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -525,7 +526,7 @@ func DetachCaptainDomain(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -550,7 +551,7 @@ func DetachCaptainDomain(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func AttachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
+func AttachControlPlaneRecordSet(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -617,7 +618,7 @@ func AttachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -640,7 +641,7 @@ func AttachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func DetachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
+func DetachControlPlaneRecordSet(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -687,7 +688,7 @@ func DetachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -712,7 +713,7 @@ func DetachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func AttachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
+func AttachAppsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -775,7 +776,7 @@ func AttachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -798,7 +799,7 @@ func AttachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func DetachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
+func DetachAppsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -845,7 +846,7 @@ func DetachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -870,7 +871,7 @@ func DetachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func AttachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
+func AttachGlueOpsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -933,7 +934,7 @@ func AttachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -956,7 +957,7 @@ func AttachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func DetachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
+func DetachGlueOpsLoadBalancer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -1003,7 +1004,7 @@ func DetachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -1028,7 +1029,7 @@ func DetachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func AttachBastionServer(db *gorm.DB) http.HandlerFunc {
+func AttachBastionServer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -1091,7 +1092,7 @@ func AttachBastionServer(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -1114,7 +1115,7 @@ func AttachBastionServer(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func DetachBastionServer(db *gorm.DB) http.HandlerFunc {
+func DetachBastionServer(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -1161,7 +1162,7 @@ func DetachBastionServer(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -1186,7 +1187,7 @@ func DetachBastionServer(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func SetClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
+func SetClusterKubeconfig(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -1248,7 +1249,7 @@ func SetClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -1271,7 +1272,7 @@ func SetClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func ClearClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
+func ClearClusterKubeconfig(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -1321,7 +1322,7 @@ func ClearClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -1346,7 +1347,7 @@ func ClearClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func AttachNodePool(db *gorm.DB) http.HandlerFunc {
+func AttachNodePool(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -1420,7 +1421,7 @@ func AttachNodePool(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
@@ -1444,7 +1445,7 @@ func AttachNodePool(db *gorm.DB) http.HandlerFunc {
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
-func DetachNodePool(db *gorm.DB) http.HandlerFunc {
+func DetachNodePool(db *gorm.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
@@ -1514,13 +1515,13 @@ func DetachNodePool(db *gorm.DB) http.HandlerFunc {
return
}
- utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster, cfg))
}
}
// -- Helpers
-func clusterToDTO(c models.Cluster) dto.ClusterResponse {
+func clusterToDTO(c models.Cluster, cfg config.Config) dto.ClusterResponse {
var bastion *dto.ServerResponse
if c.BastionServer != nil {
b := serverToDTO(*c.BastionServer)
@@ -1561,10 +1562,11 @@ func clusterToDTO(c models.Cluster) dto.ClusterResponse {
for _, np := range c.NodePools {
nps = append(nps, nodePoolToDTO(np))
}
-
+ fmt.Println(cfg.BaseURL)
return dto.ClusterResponse{
ID: c.ID,
Name: c.Name,
+ BaseURL: cfg.BaseURL,
CaptainDomain: captainDomain,
ControlPlaneRecordSet: controlPlane,
ControlPlaneFQDN: cfqdn,
diff --git a/internal/handlers/dto/clusters.go b/internal/handlers/dto/clusters.go
index e60190f..b3b2f60 100644
--- a/internal/handlers/dto/clusters.go
+++ b/internal/handlers/dto/clusters.go
@@ -9,6 +9,7 @@ import (
type ClusterResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
+ BaseURL string `json:"base_url"`
CaptainDomain *DomainResponse `json:"captain_domain,omitempty"`
ControlPlaneRecordSet *RecordSetResponse `json:"control_plane_record_set,omitempty"`
ControlPlaneFQDN *string `json:"control_plane_fqdn,omitempty"`
diff --git a/postgres/Dockerfile b/postgres/Dockerfile
index 87186fb..0c1a91f 100644
--- a/postgres/Dockerfile
+++ b/postgres/Dockerfile
@@ -6,4 +6,4 @@ RUN cd /var/lib/postgresql/ && \
openssl req -x509 -in server.req -text -key server.key -out server.crt && \
chmod 600 server.key && \
chown postgres:postgres server.key
-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" ]
\ No newline at end of file
diff --git a/repomix-output.xml b/repomix-output.xml
new file mode 100644
index 0000000..6619731
--- /dev/null
+++ b/repomix-output.xml
@@ -0,0 +1,38823 @@
+This file is a merged representation of the entire codebase, combined into a single document by Repomix.
+
+
+This section contains a summary of this file.
+
+
+This file contains a packed representation of the entire repository's contents.
+It is designed to be easily consumable by AI systems for analysis, code review,
+or other automated processes.
+
+
+
+The content is organized as follows:
+1. This summary section
+2. Repository information
+3. Directory structure
+4. Repository files (if enabled)
+5. Multiple file entries, each consisting of:
+ - File path as an attribute
+ - Full contents of the file
+
+
+
+- This file should be treated as read-only. Any changes should be made to the
+ original repository files, not this packed version.
+- When processing this file, use the file path to distinguish
+ between different files in the repository.
+- Be aware that this file may contain sensitive information. Handle it with
+ the same level of security as you would the original repository.
+
+
+
+- Some files may have been excluded based on .gitignore rules and Repomix's configuration
+- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
+- Files matching patterns in .gitignore are excluded
+- Files matching default ignore patterns are excluded
+- Files are sorted by Git change count (files with more changes are at the bottom)
+
+
+
+
+
+.github/
+ configs/
+ labeler.yml
+ workflows/
+ docker-publish.yml
+ release.yml
+cmd/
+ db.go
+ encryption.go
+ keys_generate.go
+ root.go
+ serve.go
+ version.go
+internal/
+ api/
+ httpmiddleware/
+ auth.go
+ context.go
+ platform_admin.go
+ rbac.go
+ mount_admin_routes.go
+ mount_annotation_routes.go
+ mount_api_routes.go
+ mount_auth_routes.go
+ mount_cluster_routes.go
+ mount_credential_routes.go
+ mount_db_studio.go
+ mount_dns_routes.go
+ mount_label_routes.go
+ mount_load_balancer_routes.go
+ mount_me_routes.go
+ mount_meta_routes.go
+ mount_node_pool_routes.go
+ mount_org_routes.go
+ mount_pprof_routes.go
+ mount_server_routes.go
+ mount_ssh_routes.go
+ mount_swagger_routes.go
+ mount_taint_routes.go
+ mw_logger.go
+ mw_security.go
+ routes.go
+ utils.go
+ app/
+ runtime.go
+ auth/
+ hash.go
+ issue.go
+ jwks_export.go
+ jwt_issue.go
+ jwt_signer.go
+ jwt_validate.go
+ refresh.go
+ validate_keys.go
+ bg/
+ archer_cleanup.go
+ backup_s3.go
+ bastion.go
+ bg.go
+ cluster_action.go
+ cluster_bootstrap.go
+ cluster_setup.go
+ dns.go
+ org_key_sweeper.go
+ prepare_cluster.go
+ tokens_cleanup.go
+ common/
+ audit.go
+ config/
+ config.go
+ db/
+ db.go
+ migrate.go
+ handlers/
+ dto/
+ actions.go
+ annotations.go
+ auth.go
+ cluster_runs.go
+ clusters.go
+ credentials.go
+ dns.go
+ jobs.go
+ jwks.go
+ labels.go
+ load_balancers.go
+ node_pools.go
+ servers.go
+ ssh_keys.go
+ taints.go
+ actions.go
+ annotations.go
+ auth.go
+ cluster_runs.go
+ clusters.go
+ credentials.go
+ dns.go
+ health.go
+ jobs.go
+ jwks.go
+ labels.go
+ load_balancers.go
+ me_keys.go
+ me.go
+ node_pools_test.go
+ node_pools.go
+ orgs.go
+ servers_test.go
+ servers.go
+ ssh_keys.go
+ taints.go
+ version.go
+ keys/
+ base64util.go
+ export.go
+ keys.go
+ mapper/
+ cluster.go
+ models/
+ account.go
+ action.go
+ annotation.go
+ api_key.go
+ backup.go
+ cluster_runs.go
+ cluster.go
+ credential.go
+ domain.go
+ job.go
+ label.go
+ load_balancer.go
+ master_key.go
+ membership.go
+ node_pool.go
+ organization-key.go
+ organization.go
+ refresh_token.go
+ server.go
+ signing_key.go
+ ssh-key.go
+ taint.go
+ user_email.go
+ user.go
+ testutil/
+ pgtest/
+ pgtest.go
+ utils/
+ crypto.go
+ helpers.go
+ keys.go
+ org-crypto.go
+ version/
+ version.go
+ web/
+ devproxy.go
+ static.go
+ui/
+ public/
+ vite.svg
+ src/
+ api/
+ actions.ts
+ annotations.ts
+ archer_admin.ts
+ clusters.ts
+ credentials.ts
+ dns.ts
+ footer.ts
+ labels.ts
+ loadbalancers.ts
+ me.ts
+ node_pools.ts
+ servers.ts
+ ssh.ts
+ taints.ts
+ with-refresh.ts
+ auth/
+ logout.ts
+ org.ts
+ store.ts
+ components/
+ ui/
+ accordion.tsx
+ alert-dialog.tsx
+ alert.tsx
+ aspect-ratio.tsx
+ avatar.tsx
+ badge.tsx
+ breadcrumb.tsx
+ button-group.tsx
+ button.tsx
+ calendar.tsx
+ card.tsx
+ carousel.tsx
+ chart.tsx
+ checkbox.tsx
+ collapsible.tsx
+ command.tsx
+ context-menu.tsx
+ dialog.tsx
+ drawer.tsx
+ dropdown-menu.tsx
+ empty.tsx
+ field.tsx
+ form.tsx
+ hover-card.tsx
+ input-group.tsx
+ input-otp.tsx
+ input.tsx
+ item.tsx
+ kbd.tsx
+ label.tsx
+ menubar.tsx
+ navigation-menu.tsx
+ pagination.tsx
+ popover.tsx
+ progress.tsx
+ radio-group.tsx
+ resizable.tsx
+ scroll-area.tsx
+ select.tsx
+ separator.tsx
+ sheet.tsx
+ sidebar.tsx
+ skeleton.tsx
+ slider.tsx
+ sonner.tsx
+ spinner.tsx
+ switch.tsx
+ table.tsx
+ tabs.tsx
+ textarea.tsx
+ toggle-group.tsx
+ toggle.tsx
+ tooltip.tsx
+ protected-route.tsx
+ hooks/
+ use-auth-actions.ts
+ use-auth.ts
+ use-me.ts
+ use-mobile.ts
+ layouts/
+ app-shell.tsx
+ footer.tsx
+ nav-config.ts
+ org-switcher.tsx
+ theme-switcher.tsx
+ topbar.tsx
+ lib/
+ utils.ts
+ pages/
+ org/
+ api-keys.tsx
+ members.tsx
+ settings.tsx
+ actions-page.tsx
+ annotation-page.tsx
+ cluster-page.tsx
+ credential-page.tsx
+ dns-page.tsx
+ docs-page.tsx
+ jobs-page.tsx
+ labels-page.tsx
+ load-balancers-page.tsx
+ login.tsx
+ me-page.tsx
+ node-pools-page.tsx
+ server-page.tsx
+ ssh-page.tsx
+ taints-page.tsx
+ providers/
+ index.tsx
+ theme-provider.tsx
+ types/
+ rapidoc.d.ts
+ App.tsx
+ index.css
+ main.tsx
+ sdkClient.ts
+ .gitignore
+ .prettierignore
+ .prettierrc.json
+ components.json
+ eslint.config.js
+ index.html
+ package.json
+ README.md
+ tsconfig.app.json
+ tsconfig.json
+ tsconfig.node.json
+ tsconfig.tsbuildinfo
+ vite.config.ts
+.dockerignore
+.env.example
+.gitignore
+.semgrep.yml
+agents.md
+Archive.zip
+docker-compose.yml
+Dockerfile
+go.mod
+main.go
+Makefile
+README.md
+ui.zip
+
+
+
+This section contains the contents of the repository's files.
+
+
+####
+## This is managed via https://github.com/internal-GlueOps/github-shared-files-sync . Any changes to this file may be overridden by our automation
+####
+
+include-in-release-notes:
+ - changed-files:
+ - any-glob-to-any-file: '**'
+
+
+
+####
+## This is managed via https://github.com/internal-GlueOps/github-shared-files-sync . Any changes to this file may be overridden by our automation
+####
+
+changelog:
+ exclude:
+ labels:
+ - 'ignore'
+ # authors:
+ # - 'glueops-terraform-svc-account'
+ # - 'glueops-svc-account'
+ # - 'glueops-renovatebot'
+ categories:
+ - title: Breaking Changes π
+ labels:
+ - 'major'
+ - 'breaking-change'
+ - title: Enhancements π
+ labels:
+ - 'minor'
+ - 'enhancement'
+ - 'new-feature'
+ - title: Other π
+ labels:
+ - 'auto-update'
+ - 'patch'
+ - 'fix'
+ - 'bugfix'
+ - 'bug'
+ - 'hotfix'
+ - 'dependencies'
+ - 'include-in-release-notes'
+
+
+
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "runtime"
+ "time"
+
+ "github.com/glueops/autoglue/internal/config"
+ "github.com/spf13/cobra"
+)
+
+var dbCmd = &cobra.Command{
+ Use: "db",
+ Short: "Database utilities",
+}
+
+var dbPsqlCmd = &cobra.Command{
+ Use: "psql",
+ Short: "Open a psql session to the app database",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := config.Load()
+ if err != nil {
+ return err
+ }
+ if cfg.DbURL == "" {
+ return errors.New("database.url is empty")
+ }
+ psql := "psql"
+ if runtime.GOOS == "windows" {
+ psql = "psql.exe"
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 72*time.Hour)
+ defer cancel()
+
+ psqlCmd := exec.CommandContext(ctx, psql, cfg.DbURL)
+ psqlCmd.Stdin, psqlCmd.Stdout, psqlCmd.Stderr = os.Stdin, os.Stdout, os.Stderr
+ fmt.Println("Launching psqlβ¦")
+ return psqlCmd.Run()
+ },
+}
+
+func init() {
+ dbCmd.AddCommand(dbPsqlCmd)
+
+ rootCmd.AddCommand(dbCmd)
+}
+
+
+
+package cmd
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "io"
+
+ "github.com/glueops/autoglue/internal/app"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/spf13/cobra"
+)
+
+var rotateMasterCmd = &cobra.Command{
+ Use: "rotate-master",
+ Short: "Generate and activate a new master encryption key",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rt := app.NewRuntime()
+ db := rt.DB
+
+ key := make([]byte, 32)
+ if _, err := io.ReadFull(rand.Reader, key); err != nil {
+ return fmt.Errorf("generating random key: %w", err)
+ }
+
+ encoded := base64.StdEncoding.EncodeToString(key)
+
+ if err := db.Model(&models.MasterKey{}).
+ Where("is_active = ?", true).
+ Update("is_active", false).Error; err != nil {
+ return fmt.Errorf("deactivating previous key: %w", err)
+ }
+
+ if err := db.Create(&models.MasterKey{
+ Key: encoded,
+ IsActive: true,
+ }).Error; err != nil {
+ return fmt.Errorf("creating new master key: %w", err)
+ }
+
+ fmt.Println("Master key rotated successfully")
+ return nil
+ },
+}
+
+var createMasterCmd = &cobra.Command{
+ Use: "create-master",
+ Short: "Generate and activate a new master encryption key",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ rt := app.NewRuntime()
+ db := rt.DB
+ key := make([]byte, 32)
+ if _, err := io.ReadFull(rand.Reader, key); err != nil {
+ return fmt.Errorf("generating random key: %w", err)
+ }
+
+ encoded := base64.StdEncoding.EncodeToString(key)
+
+ if err := db.Create(&models.MasterKey{
+ Key: encoded,
+ IsActive: true,
+ }).Error; err != nil {
+ return fmt.Errorf("creating master key: %w", err)
+ }
+
+ fmt.Println("Master key created successfully")
+ return nil
+ },
+}
+
+var encryptCmd = &cobra.Command{
+ Use: "encrypt",
+ Short: "Manage autoglue encryption keys",
+ Long: "Manage autoglue master encryption keys used for securing data.",
+}
+
+func init() {
+ encryptCmd.AddCommand(rotateMasterCmd)
+ encryptCmd.AddCommand(createMasterCmd)
+ rootCmd.AddCommand(encryptCmd)
+}
+
+
+
+package cmd
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/glueops/autoglue/internal/app"
+ "github.com/glueops/autoglue/internal/keys"
+ "github.com/spf13/cobra"
+)
+
+var (
+ alg string
+ rsaBits int
+ kidFlag string
+ nbfStr string
+ expStr string
+)
+
+var keysCmd = &cobra.Command{
+ Use: "keys",
+ Short: "Manage JWT signing keys",
+}
+
+var keysGenCmd = &cobra.Command{
+ Use: "generate",
+ Short: "Generate and store a new signing key",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ rt := app.NewRuntime()
+
+ var nbfPtr, expPtr *time.Time
+ if nbfStr != "" {
+ t, err := time.Parse(time.RFC3339, nbfStr)
+ if err != nil {
+ return err
+ }
+ nbfPtr = &t
+ }
+ if expStr != "" {
+ t, err := time.Parse(time.RFC3339, expStr)
+ if err != nil {
+ return err
+ }
+ expPtr = &t
+ }
+
+ rec, err := keys.GenerateAndStore(rt.DB, rt.Cfg.JWTPrivateEncKey, keys.GenOpts{
+ Alg: alg,
+ Bits: rsaBits,
+ KID: kidFlag,
+ NBF: nbfPtr,
+ EXP: expPtr,
+ })
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("created signing key\n")
+ fmt.Printf(" kid: %s\n", rec.Kid)
+ fmt.Printf(" alg: %s\n", rec.Alg)
+ fmt.Printf(" active: %v\n", rec.IsActive)
+ if rec.NotBefore != nil {
+ fmt.Printf(" nbf: %s\n", rec.NotBefore.Format(time.RFC3339))
+ }
+ if rec.ExpiresAt != nil {
+ fmt.Printf(" exp: %s\n", rec.ExpiresAt.Format(time.RFC3339))
+ }
+ return nil
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(keysCmd)
+ keysCmd.AddCommand(keysGenCmd)
+
+ keysGenCmd.Flags().StringVarP(&alg, "alg", "a", "EdDSA", "Signing alg: EdDSA|RS256|RS384|RS512")
+ keysGenCmd.Flags().IntVarP(&rsaBits, "bits", "b", 3072, "RSA key size (when alg is RS*)")
+ keysGenCmd.Flags().StringVarP(&kidFlag, "kid", "k", "", "Key ID (optional; auto if empty)")
+ keysGenCmd.Flags().StringVarP(&nbfStr, "nbf", "n", "", "Not Before (RFC3339)")
+ keysGenCmd.Flags().StringVarP(&expStr, "exp", "e", "", "Expires At (RFC3339)")
+}
+
+
+
+package cmd
+
+import (
+ "log"
+
+ "github.com/spf13/cobra"
+)
+
+var rootCmd = &cobra.Command{
+ Use: "autoglue",
+ Short: "Autoglue Kubernetes Cluster Management",
+ Long: "autoglue is used to manage the lifecycle of kubernetes clusters on GlueOps supported cloud providers",
+ Run: func(cmd *cobra.Command, args []string) {
+ if len(args) == 0 {
+ err := serveCmd.RunE(cmd, args)
+ if err != nil {
+ log.Fatal(err)
+ }
+ } else {
+ _ = cmd.Help()
+ }
+ },
+}
+
+func Execute() {
+ if err := rootCmd.Execute(); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func init() {
+ cobra.OnInitialize()
+}
+
+
+
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/glueops/autoglue/internal/api"
+ "github.com/glueops/autoglue/internal/app"
+ "github.com/glueops/autoglue/internal/auth"
+ "github.com/glueops/autoglue/internal/bg"
+ "github.com/glueops/autoglue/internal/config"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+ "github.com/spf13/cobra"
+)
+
+var serveCmd = &cobra.Command{
+ Use: "serve",
+ Short: "Start API server",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ rt := app.NewRuntime()
+
+ cfg, err := config.Load()
+ if err != nil {
+ return err
+ }
+
+ jobs, err := bg.NewJobs(rt.DB, cfg.DbURL)
+ if err != nil {
+ log.Fatalf("failed to init background jobs: %v", err)
+ }
+
+ rt.DB.Where("status IN ?", []string{"scheduled", "queued", "pending"}).Delete(&models.Job{})
+
+ // Start workers in background ONCE
+ go func() {
+ if err := jobs.Start(); err != nil {
+ log.Fatalf("failed to start background jobs: %v", err)
+ }
+ }()
+ defer jobs.Stop()
+
+ // daily cleanups
+ {
+ // schedule next 03:30 local time
+ next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 30*time.Minute)
+ _, err = jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "archer_cleanup",
+ bg.CleanupArgs{RetainDays: 7, Table: "jobs"},
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ )
+ if err != nil {
+ log.Fatalf("failed to enqueue archer cleanup job: %v", err)
+ }
+
+ // schedule next 03:45 local time
+ next2 := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 45*time.Minute)
+ _, err = jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "tokens_cleanup",
+ bg.TokensCleanupArgs{},
+ archer.WithScheduleTime(next2),
+ archer.WithMaxRetries(1),
+ )
+ if err != nil {
+ log.Fatalf("failed to enqueue token cleanup job: %v", err)
+ }
+
+ _, err = jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "db_backup_s3",
+ bg.DbBackupArgs{IntervalS: 3600},
+ archer.WithMaxRetries(1),
+ archer.WithScheduleTime(time.Now().Add(1*time.Hour)),
+ )
+ if err != nil {
+ log.Fatalf("failed to enqueue backup jobs: %v", err)
+ }
+
+ _, err = jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "dns_reconcile",
+ bg.DNSReconcileArgs{MaxDomains: 25, MaxRecords: 100, IntervalS: 10},
+ archer.WithScheduleTime(time.Now().Add(5*time.Second)),
+ archer.WithMaxRetries(1),
+ )
+ if err != nil {
+ log.Fatalf("failed to enqueue dns reconcile: %v", err)
+ }
+
+ _, err := jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "bootstrap_bastion",
+ bg.BastionBootstrapArgs{IntervalS: 10},
+ archer.WithMaxRetries(3),
+ // while debugging, avoid extra schedule delay:
+ archer.WithScheduleTime(time.Now().Add(60*time.Second)),
+ )
+ if err != nil {
+ log.Printf("failed to enqueue bootstrap_bastion: %v", err)
+ }
+
+ /*
+ _, err = jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "prepare_cluster",
+ bg.ClusterPrepareArgs{IntervalS: 120},
+ archer.WithMaxRetries(3),
+ archer.WithScheduleTime(time.Now().Add(60*time.Second)),
+ )
+ if err != nil {
+ log.Printf("failed to enqueue prepare_cluster: %v", err)
+ }
+
+ _, err = jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "cluster_setup",
+ bg.ClusterSetupArgs{
+ IntervalS: 120,
+ },
+ archer.WithMaxRetries(3),
+ archer.WithScheduleTime(time.Now().Add(60*time.Second)),
+ )
+
+ if err != nil {
+ log.Printf("failed to enqueue cluster setup: %v", err)
+ }
+
+ _, err = jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "cluster_bootstrap",
+ bg.ClusterBootstrapArgs{
+ IntervalS: 120,
+ },
+ archer.WithMaxRetries(3),
+ archer.WithScheduleTime(time.Now().Add(60*time.Second)),
+ )
+ if err != nil {
+ log.Printf("failed to enqueue cluster bootstrap: %v", err)
+ }
+ */
+
+ _, err = jobs.Enqueue(
+ context.Background(),
+ uuid.NewString(),
+ "org_key_sweeper",
+ bg.OrgKeySweeperArgs{
+ IntervalS: 3600,
+ RetentionDays: 10,
+ },
+ archer.WithMaxRetries(1),
+ archer.WithScheduleTime(time.Now()),
+ )
+ if err != nil {
+ log.Printf("failed to enqueue org_key_sweeper: %v", err)
+ }
+ }
+
+ _ = auth.Refresh(rt.DB, rt.Cfg.JWTPrivateEncKey)
+ go func() {
+ t := time.NewTicker(60 * time.Second)
+ defer t.Stop()
+ for range t.C {
+ _ = auth.Refresh(rt.DB, rt.Cfg.JWTPrivateEncKey)
+ }
+ }()
+
+ r := api.NewRouter(rt.DB, jobs, nil)
+
+ if cfg.DBStudioEnabled {
+ dbURL := cfg.DbURLRO
+ if dbURL == "" {
+ dbURL = cfg.DbURL
+ }
+
+ studio, err := api.MountDbStudio(
+ dbURL,
+ "db-studio",
+ false,
+ )
+ if err != nil {
+ log.Fatalf("failed to init db studio: %v", err)
+ } else {
+ r = api.NewRouter(rt.DB, jobs, studio)
+ log.Printf("pgweb mounted at /db-studio/")
+ }
+ }
+
+ addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
+
+ srv := &http.Server{
+ Addr: addr,
+ Handler: TimeoutExceptUpgrades(r, 60*time.Second, "request timed out"), // global safety
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ go func() {
+ fmt.Printf("π API running on http://%s (ui.dev=%v)\n", addr, cfg.UIDev)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("server error: %v", err)
+ }
+ }()
+
+ <-ctx.Done()
+ fmt.Println("\nβ³ Shutting down...")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(serveCmd)
+}
+
+func TimeoutExceptUpgrades(next http.Handler, d time.Duration, msg string) http.Handler {
+ timeout := http.TimeoutHandler(next, d, msg)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // If this is an upgrade (e.g., websocket), don't wrap.
+ if isUpgrade(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
+ timeout.ServeHTTP(w, r)
+ })
+}
+
+func isUpgrade(r *http.Request) bool {
+ // Connection: Upgrade, Upgrade: websocket
+ if strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
+ return true
+ }
+ return false
+}
+
+
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/glueops/autoglue/internal/version"
+ "github.com/spf13/cobra"
+)
+
+var versionCmd = &cobra.Command{
+ Use: "version",
+ Short: "Show version information",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println(version.Info())
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(versionCmd)
+}
+
+
+
+package httpmiddleware
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/glueops/autoglue/internal/auth"
+ "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"
+)
+
+// AuthMiddleware authenticates either a user principal (JWT, user API key, app key/secret)
+// or an org principal (org key/secret). If requireOrg is true, the request must have
+// an organization resolved; otherwise org is optional.
+//
+// Org resolution order for user principals (when requireOrg == true):
+// 1. X-Org-ID header (UUID)
+// 2. chi URL param {id} (useful under /orgs/{id}/... routers)
+// 3. single-membership fallback (exactly one membership)
+//
+// If none resolves, respond with org_required.
+func AuthMiddleware(db *gorm.DB, requireOrg bool) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var user *models.User
+ var org *models.Organization
+ var roles []string
+
+ // --- 1) Authenticate principal ---
+ // Prefer org principal if explicit machine access is provided.
+ if orgKey := r.Header.Get("X-ORG-KEY"); orgKey != "" {
+ secret := r.Header.Get("X-ORG-SECRET")
+ org = auth.ValidateOrgKeyPair(orgKey, secret, db)
+ if org == nil {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid org credentials")
+ return
+ }
+ // org principal implies machine role
+ roles = []string{"org:machine"}
+ } else {
+ // User principals
+ if ah := r.Header.Get("Authorization"); strings.HasPrefix(ah, "Bearer ") {
+ user = auth.ValidateJWT(ah[7:], db)
+ } else if apiKey := r.Header.Get("X-API-KEY"); apiKey != "" {
+ user = auth.ValidateAPIKey(apiKey, db)
+ } else if appKey := r.Header.Get("X-APP-KEY"); appKey != "" {
+ secret := r.Header.Get("X-APP-SECRET")
+ user = auth.ValidateAppKeyPair(appKey, secret, db)
+ } else if c, err := r.Cookie("ag_jwt"); err == nil {
+ tok := strings.TrimSpace(c.Value)
+ if strings.HasPrefix(strings.ToLower(tok), "bearer ") {
+ tok = tok[7:]
+ }
+ if tok != "" {
+ user = auth.ValidateJWT(tok, db)
+ }
+ }
+
+ if user == nil {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
+ return
+ }
+
+ // --- 2) Resolve organization (user principal) ---
+ // A) Try X-Org-ID if present
+ if s := r.Header.Get("X-Org-ID"); s != "" {
+ oid, err := uuid.Parse(s)
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_org_id", "X-Org-ID must be a UUID")
+ return
+ }
+ var o models.Organization
+ if err := db.First(&o, "id = ?", oid).Error; err != nil {
+ // Header provided but org not found
+ utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "organization not found")
+ return
+ }
+ // Verify membership
+ if !userIsMember(db, user.ID, o.ID) {
+ utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "user is not a member of specified org")
+ return
+ }
+ org = &o
+ }
+
+ // B) If still no org and requireOrg==true, try chi URL param {id}
+ if org == nil && requireOrg {
+ if sid := chi.URLParam(r, "id"); sid != "" {
+ if oid, err := uuid.Parse(sid); err == nil {
+ var o models.Organization
+ if err := db.First(&o, "id = ?", oid).Error; err == nil && userIsMember(db, user.ID, o.ID) {
+ org = &o
+ } else {
+ utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "user is not a member of specified org")
+ return
+ }
+ }
+ }
+ }
+
+ // C) Single-membership fallback (only if requireOrg==true and still nil)
+ if org == nil && requireOrg {
+ var ms []models.Membership
+ if err := db.Where("user_id = ?", user.ID).Find(&ms).Error; err == nil && len(ms) == 1 {
+ var o models.Organization
+ if err := db.First(&o, "id = ?", ms[0].OrganizationID).Error; err == nil {
+ org = &o
+ }
+ }
+ }
+
+ // D) Final check
+ if requireOrg && org == nil {
+ utils.WriteError(w, http.StatusUnauthorized, "org_required", "specify X-Org-ID or use an endpoint that does not require org")
+ return
+ }
+
+ // Populate roles if an org was resolved (optional for org-optional endpoints)
+ if org != nil {
+ roles = userRolesInOrg(db, user.ID, org.ID)
+ if len(roles) == 0 {
+ utils.WriteError(w, http.StatusForbidden, "forbidden", "no roles in organization")
+ return
+ }
+ }
+ }
+
+ // --- 3) Attach to context and proceed ---
+ ctx := r.Context()
+ if user != nil {
+ ctx = WithUser(ctx, user)
+ }
+ if org != nil {
+ ctx = WithOrg(ctx, org)
+ }
+ if roles != nil {
+ ctx = WithRoles(ctx, roles)
+ }
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+func userIsMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
+ var count int64
+ db.Model(&models.Membership{}).
+ Where("user_id = ? AND organization_id = ?", userID, orgID).
+ Count(&count)
+ return count > 0
+}
+
+func userRolesInOrg(db *gorm.DB, userID, orgID uuid.UUID) []string {
+ var m models.Membership
+ if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err == nil {
+ switch m.Role {
+ case "owner":
+ return []string{"role:owner", "role:admin", "role:member"}
+ case "admin":
+ return []string{"role:admin", "role:member"}
+ default:
+ return []string{"role:member"}
+ }
+ }
+ return nil
+}
+
+
+
+package httpmiddleware
+
+import (
+ "context"
+
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+)
+
+type ctxKey string
+
+const (
+ ctxUserKey ctxKey = "ctx_user"
+ ctxOrgKey ctxKey = "ctx_org"
+ ctxRolesKey ctxKey = "ctx_roles" // []string, user roles in current org
+)
+
+func WithUser(ctx context.Context, u *models.User) context.Context {
+ return context.WithValue(ctx, ctxUserKey, u)
+}
+func WithOrg(ctx context.Context, o *models.Organization) context.Context {
+ return context.WithValue(ctx, ctxOrgKey, o)
+}
+func WithRoles(ctx context.Context, roles []string) context.Context {
+ return context.WithValue(ctx, ctxRolesKey, roles)
+}
+
+func UserFrom(ctx context.Context) (*models.User, bool) {
+ u, ok := ctx.Value(ctxUserKey).(*models.User)
+ return u, ok && u != nil
+}
+func OrgFrom(ctx context.Context) (*models.Organization, bool) {
+ o, ok := ctx.Value(ctxOrgKey).(*models.Organization)
+ return o, ok && o != nil
+}
+func OrgIDFrom(ctx context.Context) (uuid.UUID, bool) {
+ if o, ok := OrgFrom(ctx); ok {
+ return o.ID, true
+ }
+ return uuid.Nil, false
+}
+func RolesFrom(ctx context.Context) ([]string, bool) {
+ r, ok := ctx.Value(ctxRolesKey).([]string)
+ return r, ok && r != nil
+}
+
+
+
+package httpmiddleware
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/utils"
+)
+
+// RequireAuthenticatedUser ensures a user principal is present (i.e. not an org/machine key).
+func RequireAuthenticatedUser() func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if user, ok := UserFrom(r.Context()); !ok || user == nil {
+ // No user in context -> probably org/machine principal, or unauthenticated
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user principal required")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// RequirePlatformAdmin requires a user principal with IsAdmin=true.
+// This is platform-wide (non-org) admin and does NOT depend on org roles.
+func RequirePlatformAdmin() func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, ok := UserFrom(r.Context())
+ if !ok || user == nil {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user principal required")
+ return
+ }
+ if !user.IsAdmin {
+ utils.WriteError(w, http.StatusForbidden, "forbidden", "platform admin required")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// RequireUserAdmin is an alias for RequirePlatformAdmin for readability at call sites.
+func RequireUserAdmin() func(http.Handler) http.Handler {
+ return RequirePlatformAdmin()
+}
+
+
+
+package httpmiddleware
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/utils"
+)
+
+func RequireRole(minRole string) func(http.Handler) http.Handler {
+ // order: owner > admin > member
+ rank := map[string]int{
+ "role:member": 1,
+ "role:admin": 2,
+ "role:owner": 3,
+ "org:machine": 2,
+ "org:machine:ro": 1,
+ }
+ need := map[string]bool{
+ "member": true, "admin": true, "owner": true,
+ }
+ if !need[minRole] {
+ minRole = "member"
+ }
+
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ roles, ok := RolesFrom(r.Context())
+ if !ok || len(roles) == 0 {
+ utils.WriteError(w, http.StatusForbidden, "forbidden", "no roles in context")
+ return
+ }
+ max := 0
+ for _, ro := range roles {
+ if rank[ro] > max {
+ max = rank[ro]
+ }
+ }
+ if max < rank["role:"+minRole] {
+ utils.WriteError(w, http.StatusForbidden, "forbidden", "insufficient role")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/bg"
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountAdminRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authUser func(http.Handler) http.Handler) {
+ r.Route("/admin", func(admin chi.Router) {
+ admin.Route("/archer", func(archer chi.Router) {
+ archer.Use(authUser)
+ archer.Use(httpmiddleware.RequirePlatformAdmin())
+
+ archer.Get("/jobs", handlers.AdminListArcherJobs(db))
+ archer.Post("/jobs", handlers.AdminEnqueueArcherJob(db, jobs))
+ archer.Post("/jobs/{id}/retry", handlers.AdminRetryArcherJob(db))
+ archer.Post("/jobs/{id}/cancel", handlers.AdminCancelArcherJob(db))
+ archer.Get("/queues", handlers.AdminListArcherQueues(db))
+ })
+ admin.Route("/actions", func(action chi.Router) {
+ action.Use(authUser)
+ action.Use(httpmiddleware.RequirePlatformAdmin())
+
+ action.Get("/", handlers.ListActions(db))
+ action.Post("/", handlers.CreateAction(db))
+
+ action.Get("/{actionID}", handlers.GetAction(db))
+ action.Patch("/{actionID}", handlers.UpdateAction(db))
+ action.Delete("/{actionID}", handlers.DeleteAction(db))
+ })
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountAnnotationRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
+ r.Route("/annotations", func(a chi.Router) {
+ a.Use(authOrg)
+ a.Get("/", handlers.ListAnnotations(db))
+ a.Post("/", handlers.CreateAnnotation(db))
+ a.Get("/{id}", handlers.GetAnnotation(db))
+ a.Patch("/{id}", handlers.UpdateAnnotation(db))
+ a.Delete("/{id}", handlers.DeleteAnnotation(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/bg"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
+ r.Route("/api", func(api chi.Router) {
+ api.Route("/v1", func(v1 chi.Router) {
+ authUser := httpmiddleware.AuthMiddleware(db, false)
+ authOrg := httpmiddleware.AuthMiddleware(db, true)
+
+ // shared basics
+ mountMetaRoutes(v1)
+ mountAuthRoutes(v1, db)
+
+ // admin
+ mountAdminRoutes(v1, db, jobs, authUser)
+
+ // user/org scoped
+ mountMeRoutes(v1, db, authUser)
+ mountOrgRoutes(v1, db, authUser, authOrg)
+
+ mountCredentialRoutes(v1, db, authOrg)
+ mountSSHRoutes(v1, db, authOrg)
+ mountServerRoutes(v1, db, authOrg)
+ mountTaintRoutes(v1, db, authOrg)
+ mountLabelRoutes(v1, db, authOrg)
+ mountAnnotationRoutes(v1, db, authOrg)
+ mountNodePoolRoutes(v1, db, authOrg)
+ mountDNSRoutes(v1, db, authOrg)
+ mountLoadBalancerRoutes(v1, db, authOrg)
+ mountClusterRoutes(v1, db, jobs, authOrg)
+ })
+ })
+}
+
+
+
+package api
+
+import (
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountAuthRoutes(r chi.Router, db *gorm.DB) {
+ r.Route("/auth", func(a chi.Router) {
+ a.Post("/{provider}/start", handlers.AuthStart(db))
+ a.Get("/{provider}/callback", handlers.AuthCallback(db))
+ a.Post("/refresh", handlers.Refresh(db))
+ a.Post("/logout", handlers.Logout(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/bg"
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountClusterRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authOrg func(http.Handler) http.Handler) {
+ r.Route("/clusters", func(c chi.Router) {
+ c.Use(authOrg)
+ c.Get("/", handlers.ListClusters(db))
+ c.Post("/", handlers.CreateCluster(db))
+
+ c.Get("/{clusterID}", handlers.GetCluster(db))
+ c.Patch("/{clusterID}", handlers.UpdateCluster(db))
+ c.Delete("/{clusterID}", handlers.DeleteCluster(db))
+
+ c.Post("/{clusterID}/captain-domain", handlers.AttachCaptainDomain(db))
+ c.Delete("/{clusterID}/captain-domain", handlers.DetachCaptainDomain(db))
+
+ c.Post("/{clusterID}/control-plane-record-set", handlers.AttachControlPlaneRecordSet(db))
+ c.Delete("/{clusterID}/control-plane-record-set", handlers.DetachControlPlaneRecordSet(db))
+
+ c.Post("/{clusterID}/apps-load-balancer", handlers.AttachAppsLoadBalancer(db))
+ c.Delete("/{clusterID}/apps-load-balancer", handlers.DetachAppsLoadBalancer(db))
+ c.Post("/{clusterID}/glueops-load-balancer", handlers.AttachGlueOpsLoadBalancer(db))
+ c.Delete("/{clusterID}/glueops-load-balancer", handlers.DetachGlueOpsLoadBalancer(db))
+
+ c.Post("/{clusterID}/bastion", handlers.AttachBastionServer(db))
+ c.Delete("/{clusterID}/bastion", handlers.DetachBastionServer(db))
+
+ c.Post("/{clusterID}/kubeconfig", handlers.SetClusterKubeconfig(db))
+ c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
+
+ c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db))
+ c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(db))
+
+ c.Get("/{clusterID}/runs", handlers.ListClusterRuns(db))
+ c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db))
+ c.Post("/{clusterID}/actions/{actionID}/runs", handlers.RunClusterAction(db, jobs))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountCredentialRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
+ r.Route("/credentials", func(c chi.Router) {
+ c.Use(authOrg)
+ c.Get("/", handlers.ListCredentials(db))
+ c.Post("/", handlers.CreateCredential(db))
+ c.Get("/{id}", handlers.GetCredential(db))
+ c.Patch("/{id}", handlers.UpdateCredential(db))
+ c.Delete("/{id}", handlers.DeleteCredential(db))
+ c.Post("/{id}/reveal", handlers.RevealCredential(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ pgapi "github.com/sosedoff/pgweb/pkg/api"
+ pgclient "github.com/sosedoff/pgweb/pkg/client"
+ pgcmd "github.com/sosedoff/pgweb/pkg/command"
+)
+
+func MountDbStudio(dbURL, prefix string, readonly bool) (http.Handler, error) {
+ // Normalize prefix for pgweb:
+ // - no leading slash
+ // - always trailing slash if not empty
+ prefix = strings.Trim(prefix, "/")
+ if prefix != "" {
+ prefix = prefix + "/"
+ }
+
+ pgcmd.Opts = pgcmd.Options{
+ URL: dbURL,
+ Prefix: prefix, // e.g. "db-studio/"
+ ReadOnly: readonly,
+ Sessions: false,
+ LockSession: true,
+ SkipOpen: true,
+ }
+
+ cli, err := pgclient.NewFromUrl(dbURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ if readonly {
+ _ = cli.SetReadOnlyMode()
+ }
+
+ if err := cli.Test(); err != nil {
+ return nil, err
+ }
+
+ pgapi.DbClient = cli
+
+ gin.SetMode(gin.ReleaseMode)
+ g := gin.New()
+ g.Use(gin.Recovery())
+
+ pgapi.SetupRoutes(g)
+ pgapi.SetupMetrics(g)
+
+ return g, nil
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountDNSRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
+ r.Route("/dns", func(d chi.Router) {
+ d.Use(authOrg)
+
+ d.Get("/domains", handlers.ListDomains(db))
+ d.Post("/domains", handlers.CreateDomain(db))
+ d.Get("/domains/{id}", handlers.GetDomain(db))
+ d.Patch("/domains/{id}", handlers.UpdateDomain(db))
+ d.Delete("/domains/{id}", handlers.DeleteDomain(db))
+
+ d.Get("/domains/{domain_id}/records", handlers.ListRecordSets(db))
+ d.Post("/domains/{domain_id}/records", handlers.CreateRecordSet(db))
+ d.Get("/records/{id}", handlers.GetRecordSet(db))
+ d.Patch("/records/{id}", handlers.UpdateRecordSet(db))
+ d.Delete("/records/{id}", handlers.DeleteRecordSet(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountLabelRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
+ r.Route("/labels", func(l chi.Router) {
+ l.Use(authOrg)
+ l.Get("/", handlers.ListLabels(db))
+ l.Post("/", handlers.CreateLabel(db))
+ l.Get("/{id}", handlers.GetLabel(db))
+ l.Patch("/{id}", handlers.UpdateLabel(db))
+ l.Delete("/{id}", handlers.DeleteLabel(db))
+ })
+}
+
+
+
+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))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountMeRoutes(r chi.Router, db *gorm.DB, authUser func(http.Handler) http.Handler) {
+ r.Route("/me", func(me chi.Router) {
+ me.Use(authUser)
+
+ me.Get("/", handlers.GetMe(db))
+ me.Patch("/", handlers.UpdateMe(db))
+
+ me.Get("/api-keys", handlers.ListUserAPIKeys(db))
+ me.Post("/api-keys", handlers.CreateUserAPIKey(db))
+ me.Delete("/api-keys/{id}", handlers.DeleteUserAPIKey(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+)
+
+func mountMetaRoutes(r chi.Router) {
+ // Versioned JWKS for swagger
+ r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
+ r.Get("/healthz", handlers.HealthCheck)
+ r.Get("/version", handlers.Version)
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountNodePoolRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
+ r.Route("/node-pools", func(n chi.Router) {
+ n.Use(authOrg)
+ n.Get("/", handlers.ListNodePools(db))
+ n.Post("/", handlers.CreateNodePool(db))
+ n.Get("/{id}", handlers.GetNodePool(db))
+ n.Patch("/{id}", handlers.UpdateNodePool(db))
+ n.Delete("/{id}", handlers.DeleteNodePool(db))
+
+ // Servers
+ n.Get("/{id}/servers", handlers.ListNodePoolServers(db))
+ n.Post("/{id}/servers", handlers.AttachNodePoolServers(db))
+ n.Delete("/{id}/servers/{serverId}", handlers.DetachNodePoolServer(db))
+
+ // Taints
+ n.Get("/{id}/taints", handlers.ListNodePoolTaints(db))
+ n.Post("/{id}/taints", handlers.AttachNodePoolTaints(db))
+ n.Delete("/{id}/taints/{taintId}", handlers.DetachNodePoolTaint(db))
+
+ // Labels
+ n.Get("/{id}/labels", handlers.ListNodePoolLabels(db))
+ n.Post("/{id}/labels", handlers.AttachNodePoolLabels(db))
+ n.Delete("/{id}/labels/{labelId}", handlers.DetachNodePoolLabel(db))
+
+ // Annotations
+ n.Get("/{id}/annotations", handlers.ListNodePoolAnnotations(db))
+ n.Post("/{id}/annotations", handlers.AttachNodePoolAnnotations(db))
+ n.Delete("/{id}/annotations/{annotationId}", handlers.DetachNodePoolAnnotation(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountOrgRoutes(r chi.Router, db *gorm.DB, authUser, authOrg func(http.Handler) http.Handler) {
+ r.Route("/orgs", func(o chi.Router) {
+ o.Use(authUser)
+ o.Get("/", handlers.ListMyOrgs(db))
+ o.Post("/", handlers.CreateOrg(db))
+
+ o.Group(func(og chi.Router) {
+ og.Use(authOrg)
+
+ og.Get("/{id}", handlers.GetOrg(db))
+ og.Patch("/{id}", handlers.UpdateOrg(db))
+ og.Delete("/{id}", handlers.DeleteOrg(db))
+
+ // members
+ og.Get("/{id}/members", handlers.ListMembers(db))
+ og.Post("/{id}/members", handlers.AddOrUpdateMember(db))
+ og.Delete("/{id}/members/{user_id}", handlers.RemoveMember(db))
+
+ // org-scoped key/secret pair
+ og.Get("/{id}/api-keys", handlers.ListOrgKeys(db))
+ og.Post("/{id}/api-keys", handlers.CreateOrgKey(db))
+ og.Delete("/{id}/api-keys/{key_id}", handlers.DeleteOrgKey(db))
+ })
+ })
+}
+
+
+
+package api
+
+import (
+ httpPprof "net/http/pprof"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func mountPprofRoutes(r chi.Router) {
+ r.Route("/debug/pprof", func(pr chi.Router) {
+ pr.Get("/", httpPprof.Index)
+ pr.Get("/cmdline", httpPprof.Cmdline)
+ pr.Get("/profile", httpPprof.Profile)
+ pr.Get("/symbol", httpPprof.Symbol)
+ pr.Get("/trace", httpPprof.Trace)
+
+ pr.Handle("/allocs", httpPprof.Handler("allocs"))
+ pr.Handle("/block", httpPprof.Handler("block"))
+ pr.Handle("/goroutine", httpPprof.Handler("goroutine"))
+ pr.Handle("/heap", httpPprof.Handler("heap"))
+ pr.Handle("/mutex", httpPprof.Handler("mutex"))
+ pr.Handle("/threadcreate", httpPprof.Handler("threadcreate"))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountServerRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
+ r.Route("/servers", func(s chi.Router) {
+ s.Use(authOrg)
+ s.Get("/", handlers.ListServers(db))
+ s.Post("/", handlers.CreateServer(db))
+ s.Get("/{id}", handlers.GetServer(db))
+ s.Patch("/{id}", handlers.UpdateServer(db))
+ s.Delete("/{id}", handlers.DeleteServer(db))
+ s.Post("/{id}/reset-hostkey", handlers.ResetServerHostKey(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountSSHRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
+ r.Route("/ssh", func(s chi.Router) {
+ s.Use(authOrg)
+ s.Get("/", handlers.ListPublicSshKeys(db))
+ s.Post("/", handlers.CreateSSHKey(db))
+ s.Get("/{id}", handlers.GetSSHKey(db))
+ s.Delete("/{id}", handlers.DeleteSSHKey(db))
+ s.Get("/{id}/download", handlers.DownloadSSHKey(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+
+ "github.com/glueops/autoglue/docs"
+ "github.com/go-chi/chi/v5"
+)
+
+func mountSwaggerRoutes(r chi.Router) {
+ r.Get("/swagger", RapidDocHandler("/swagger/swagger.yaml"))
+ r.Get("/swagger/index.html", RapidDocHandler("/swagger/swagger.yaml"))
+ r.Get("/swagger/openapi.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/json"))
+ r.Get("/swagger/openapi.yaml", serveSwaggerFromEmbed(docs.SwaggerYAML, "application/x-yaml"))
+}
+
+var rapidDocTmpl = template.Must(template.New("redoc").Parse(`
+
+
+
+ AutoGlue API Docs
+
+
+
+
+
+
+
+
+
+`))
+
+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
+ }
+ }
+}
+
+
+
+package api
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+func mountTaintRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
+ r.Route("/taints", func(t chi.Router) {
+ t.Use(authOrg)
+ t.Get("/", handlers.ListTaints(db))
+ t.Post("/", handlers.CreateTaint(db))
+ t.Get("/{id}", handlers.GetTaint(db))
+ t.Patch("/{id}", handlers.UpdateTaint(db))
+ t.Delete("/{id}", handlers.DeleteTaint(db))
+ })
+}
+
+
+
+package api
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/rs/zerolog/log"
+)
+
+func zeroLogMiddleware() func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
+ start := time.Now()
+
+ next.ServeHTTP(ww, r)
+
+ dur := time.Since(start)
+ ev := log.Info()
+ if ww.Status() >= 500 {
+ ev = log.Error()
+ }
+ ev.
+ Str("remote_ip", r.RemoteAddr).
+ Str("request_id", middleware.GetReqID(r.Context())).
+ Str("method", r.Method).
+ Str("path", r.URL.Path).
+ Int("status", ww.Status()).
+ Int("bytes", ww.BytesWritten()).
+ Dur("duration", dur).
+ Msg("http_request")
+ })
+ }
+}
+
+
+
+package api
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/glueops/autoglue/internal/config"
+)
+
+func SecurityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // HSTS (enable only over TLS/behind HTTPS)
+ // HSTS only when not in dev and over TLS/behind a proxy that terminates TLS
+ if !config.IsDev() {
+ w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
+ }
+
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")
+
+ if config.IsDev() {
+ // --- Relaxed CSP for Vite dev server & Google Fonts ---
+ // Allows inline/eval for React Refresh preamble, HMR websocket, and fonts.
+ // Tighten these as you move to prod or self-host fonts.
+ w.Header().Set("Content-Security-Policy", strings.Join([]string{
+ "default-src 'self'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ // Vite dev & inline preamble/eval:
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173 https://unpkg.com",
+ // allow dev style + Google Fonts
+ "style-src 'self' 'unsafe-inline' http://localhost:5173 https://fonts.googleapis.com",
+ "img-src 'self' data: blob:",
+ // Google font files
+ "font-src 'self' data: https://fonts.gstatic.com",
+ // HMR connections
+ "connect-src 'self' http://localhost:5173 ws://localhost:5173 ws://localhost:8080 https://api.github.com https://unpkg.com",
+ "frame-ancestors 'none'",
+ }, "; "))
+ } else {
+ // --- Strict CSP for production ---
+ // If you keep using Google Fonts in prod, add:
+ // style-src ... https://fonts.googleapis.com
+ // font-src ... https://fonts.gstatic.com
+ // Recommended: self-host fonts in prod and keep these tight.
+ w.Header().Set("Content-Security-Policy", strings.Join([]string{
+ "default-src 'self'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "script-src 'self' 'unsafe-inline' https://unpkg.com",
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
+ "img-src 'self' data: blob:",
+ "font-src 'self' data: https://fonts.gstatic.com",
+ "connect-src 'self' ws://localhost:8080 https://api.github.com https://unpkg.com",
+ "frame-ancestors 'none'",
+ }, "; "))
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+
+
+package api
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/bg"
+ "github.com/glueops/autoglue/internal/config"
+ "github.com/glueops/autoglue/internal/handlers"
+ "github.com/glueops/autoglue/internal/web"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/go-chi/cors"
+ "github.com/go-chi/httprate"
+
+ "gorm.io/gorm"
+
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+)
+
+func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
+ zerolog.TimeFieldFormat = time.RFC3339
+
+ l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"})
+ log.Logger = l
+
+ r := chi.NewRouter()
+ r.Use(middleware.RequestID)
+ r.Use(middleware.RealIP)
+ r.Use(zeroLogMiddleware())
+ r.Use(middleware.Recoverer)
+ r.Use(SecurityHeaders)
+ r.Use(requestBodyLimit(10 << 20))
+ r.Use(httprate.LimitByIP(1000, 1*time.Minute))
+ r.Use(middleware.StripSlashes)
+
+ allowed := getAllowedOrigins()
+ r.Use(cors.Handler(cors.Options{
+ AllowedOrigins: allowed,
+ AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
+ AllowedHeaders: []string{
+ "Content-Type",
+ "Authorization",
+ "X-Org-ID",
+ "X-API-KEY",
+ "X-ORG-KEY",
+ "X-ORG-SECRET",
+ },
+ ExposedHeaders: []string{"Link"},
+ AllowCredentials: true,
+ MaxAge: 600,
+ }))
+
+ r.Use(middleware.Maybe(
+ middleware.AllowContentType("application/json"),
+ func(r *http.Request) bool {
+ // return true => run AllowContentType
+ // return false => skip AllowContentType for this request
+ return !strings.HasPrefix(r.URL.Path, "/db-studio")
+ }))
+ //r.Use(middleware.AllowContentType("application/json"))
+
+ // Unversioned, non-auth endpoints
+ r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
+
+ // Versioned API
+ mountAPIRoutes(r, db, jobs)
+
+ // Optional DB studio
+ if studio != nil {
+ r.Group(func(gr chi.Router) {
+ authUser := httpmiddleware.AuthMiddleware(db, false)
+ adminOnly := httpmiddleware.RequirePlatformAdmin()
+ gr.Use(authUser, adminOnly)
+ gr.Mount("/db-studio", studio)
+ })
+ }
+
+ // pprof
+ if config.IsDebug() {
+ mountPprofRoutes(r)
+ }
+
+ // Swagger
+ if config.IsSwaggerEnabled() {
+ mountSwaggerRoutes(r)
+ }
+
+ // UI dev/prod
+ if config.IsUIDev() {
+ fmt.Println("Running in development mode")
+ proxy, err := web.DevProxy("http://localhost:5173")
+ if err != nil {
+ log.Error().Err(err).Msg("dev proxy init failed")
+ return r // fallback
+ }
+
+ mux := http.NewServeMux()
+ mux.Handle("/api/", r)
+ mux.Handle("/api", r)
+ mux.Handle("/swagger", r)
+ mux.Handle("/swagger/", r)
+ mux.Handle("/db-studio/", r)
+ mux.Handle("/debug/pprof/", r)
+ mux.Handle("/", proxy)
+ return mux
+ } else {
+ 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
+}
+
+
+
+package api
+
+import (
+ "net/http"
+ "os"
+ "strings"
+)
+
+func requestBodyLimit(maxBytes int64) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+func getAllowedOrigins() []string {
+ if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
+ parts := strings.Split(v, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ s := strings.TrimSpace(p)
+ if s != "" {
+ out = append(out, s)
+ }
+ }
+ if len(out) > 0 {
+ return out
+ }
+ }
+ // Defaults (dev)
+ return []string{
+ "http://localhost:5173",
+ "http://localhost:8080",
+ }
+}
+
+func serveSwaggerFromEmbed(data []byte, contentType string) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", contentType)
+ w.WriteHeader(http.StatusOK)
+ // nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter
+ _, _ = w.Write(data)
+ }
+}
+
+
+
+package app
+
+import (
+ "log"
+
+ "github.com/glueops/autoglue/internal/config"
+ "github.com/glueops/autoglue/internal/db"
+ "github.com/glueops/autoglue/internal/models"
+ "gorm.io/gorm"
+)
+
+type Runtime struct {
+ Cfg config.Config
+ DB *gorm.DB
+}
+
+func NewRuntime() *Runtime {
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatal(err)
+ }
+ d := db.Open(cfg.DbURL)
+
+ err = db.Run(d,
+ &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.Credential{},
+ &models.Domain{},
+ &models.RecordSet{},
+ &models.LoadBalancer{},
+ &models.Cluster{},
+ &models.Action{},
+ &models.Cluster{},
+ &models.ClusterRun{},
+ )
+
+ if err != nil {
+ log.Fatalf("Error initializing database: %v", err)
+ }
+ return &Runtime{
+ Cfg: cfg,
+ DB: d,
+ }
+}
+
+
+
+package auth
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "time"
+
+ "github.com/alexedwards/argon2id"
+)
+
+func SHA256Hex(s string) string {
+ sum := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(sum[:])
+}
+
+var argonParams = &argon2id.Params{
+ Memory: 64 * 1024, // 64MB
+ Iterations: 3,
+ Parallelism: 2,
+ SaltLength: 16,
+ KeyLength: 32,
+}
+
+func HashSecretArgon2id(plain string) (string, error) {
+ return argon2id.CreateHash(plain, argonParams)
+}
+
+func VerifySecretArgon2id(encodedHash, plain string) (bool, error) {
+ if encodedHash == "" {
+ return false, errors.New("empty hash")
+ }
+ return argon2id.ComparePasswordAndHash(plain, encodedHash)
+}
+
+func NotExpired(expiresAt *time.Time) bool {
+ return expiresAt == nil || time.Now().Before(*expiresAt)
+}
+
+
+
+package auth
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "time"
+
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+func randomToken(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ // URL-safe, no padding
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// IssueUserAPIKey creates a single-token user API key (X-API-KEY)
+func IssueUserAPIKey(db *gorm.DB, userID uuid.UUID, name string, ttl *time.Duration) (plaintext string, rec models.APIKey, err error) {
+ plaintext, err = randomToken(32)
+ if err != nil {
+ return "", models.APIKey{}, err
+ }
+ rec = models.APIKey{
+ Name: name,
+ Scope: "user",
+ UserID: &userID,
+ KeyHash: SHA256Hex(plaintext), // deterministic lookup
+ }
+ if ttl != nil {
+ ex := time.Now().Add(*ttl)
+ rec.ExpiresAt = &ex
+ }
+ if err = db.Create(&rec).Error; err != nil {
+ return "", models.APIKey{}, err
+ }
+ return plaintext, rec, nil
+}
+
+
+
+package auth
+
+import (
+ "crypto/ed25519"
+ "crypto/rsa"
+ "encoding/base64"
+ "fmt"
+ "math/big"
+)
+
+// base64url (no padding)
+func b64url(b []byte) string {
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+// convert small int (RSA exponent) to big-endian bytes
+func fromInt(i int) []byte {
+ var x big.Int
+ x.SetInt64(int64(i))
+ return x.Bytes()
+}
+
+// --- public accessors for JWKS ---
+
+// KeyMeta is a minimal metadata view exposed for JWKS rendering.
+type KeyMeta struct {
+ Alg string
+}
+
+// MetaFor returns minimal metadata (currently the alg) for a given kid.
+// If not found, returns zero value (Alg == "").
+func MetaFor(kid string) KeyMeta {
+ kc.mu.RLock()
+ defer kc.mu.RUnlock()
+ if m, ok := kc.meta[kid]; ok {
+ return KeyMeta{Alg: m.Alg}
+ }
+ return KeyMeta{}
+}
+
+// KcCopy invokes fn with a shallow copy of the public key map (kid -> public key instance).
+// Useful to iterate without holding the lock during JSON building.
+func KcCopy(fn func(map[string]interface{})) {
+ kc.mu.RLock()
+ defer kc.mu.RUnlock()
+ out := make(map[string]interface{}, len(kc.pub))
+ for kid, pk := range kc.pub {
+ out[kid] = pk
+ }
+ fmt.Println(out)
+ fn(out)
+}
+
+// PubToJWK converts a parsed public key into bare JWK parameters + kty.
+// - RSA: returns n/e (base64url) and kty="RSA"
+// - Ed25519: returns x (base64url) and kty="OKP"
+func PubToJWK(_kid, _alg string, pub any) (map[string]string, string) {
+ switch k := pub.(type) {
+ case *rsa.PublicKey:
+ return map[string]string{
+ "n": b64url(k.N.Bytes()),
+ "e": b64url(fromInt(k.E)),
+ }, "RSA"
+ case ed25519.PublicKey:
+ return map[string]string{
+ "x": b64url([]byte(k)),
+ }, "OKP"
+ default:
+ return nil, ""
+ }
+}
+
+
+
+package auth
+
+import (
+ "errors"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type IssueOpts struct {
+ Subject string
+ Issuer string
+ Audience string
+ TTL time.Duration
+ Claims map[string]any // extra app claims
+}
+
+func IssueAccessToken(opts IssueOpts) (string, error) {
+ kc.mu.RLock()
+ defer kc.mu.RUnlock()
+
+ if kc.selPriv == nil || kc.selKid == "" || kc.selAlg == "" {
+ return "", errors.New("no active signing key")
+ }
+
+ claims := jwt.MapClaims{
+ "iss": opts.Issuer,
+ "aud": opts.Audience,
+ "sub": opts.Subject,
+ "iat": time.Now().Unix(),
+ "exp": time.Now().Add(opts.TTL).Unix(),
+ }
+ for k, v := range opts.Claims {
+ claims[k] = v
+ }
+
+ var method jwt.SigningMethod
+ switch kc.selAlg {
+ case "RS256":
+ method = jwt.SigningMethodRS256
+ case "RS384":
+ method = jwt.SigningMethodRS384
+ case "RS512":
+ method = jwt.SigningMethodRS512
+ case "EdDSA":
+ method = jwt.SigningMethodEdDSA
+ default:
+ return "", errors.New("unsupported alg")
+ }
+
+ token := jwt.NewWithClaims(method, claims)
+ token.Header["kid"] = kc.selKid
+
+ return token.SignedString(kc.selPriv)
+}
+
+
+
+package auth
+
+import (
+ "crypto/ed25519"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/glueops/autoglue/internal/keys"
+ "github.com/glueops/autoglue/internal/models"
+ "gorm.io/gorm"
+)
+
+type keyCache struct {
+ mu sync.RWMutex
+ pub map[string]interface{} // kid -> public key object
+ meta map[string]models.SigningKey
+ selKid string
+ selAlg string
+ selPriv any
+}
+
+var kc keyCache
+
+// Refresh loads active keys into memory. Call on startup and periodically (ticker/cron).
+func Refresh(db *gorm.DB, encKeyB64 string) error {
+ var rows []models.SigningKey
+ if err := db.Where("is_active = true AND (expires_at IS NULL OR expires_at > ?)", time.Now()).
+ Order("created_at desc").Find(&rows).Error; err != nil {
+ return err
+ }
+
+ pub := make(map[string]interface{}, len(rows))
+ meta := make(map[string]models.SigningKey, len(rows))
+ var selKid string
+ var selAlg string
+ var selPriv any
+
+ for i, r := range rows {
+ // parse public
+ block, _ := pem.Decode([]byte(r.PublicPEM))
+ if block == nil {
+ continue
+ }
+ var pubKey any
+ switch r.Alg {
+ case "RS256", "RS384", "RS512":
+ pubKey, _ = x509.ParsePKCS1PublicKey(block.Bytes)
+ if pubKey == nil {
+ // also allow PKIX format
+ if k, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
+ pubKey = k
+ }
+ }
+ case "EdDSA":
+ k, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err == nil {
+ if edk, ok := k.(ed25519.PublicKey); ok {
+ pubKey = edk
+ }
+ }
+ }
+ if pubKey == nil {
+ continue
+ }
+ pub[r.Kid] = pubKey
+ meta[r.Kid] = r
+
+ // pick first row as current signer (most recent because of order desc)
+ if i == 0 {
+ privPEM := r.PrivatePEM
+ // decrypt if necessary
+ if len(privPEM) > 10 && privPEM[:10] == "enc:aesgcm" {
+ pt, err := keysDecrypt(encKeyB64, privPEM)
+ if err != nil {
+ continue
+ }
+ privPEM = string(pt)
+ }
+ blockPriv, _ := pem.Decode([]byte(privPEM))
+ if blockPriv == nil {
+ continue
+ }
+ switch r.Alg {
+ case "RS256", "RS384", "RS512":
+ if k, err := x509.ParsePKCS1PrivateKey(blockPriv.Bytes); err == nil {
+ selPriv = k
+ selAlg = r.Alg
+ selKid = r.Kid
+ } else if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
+ if k, ok := kAny.(*rsa.PrivateKey); ok {
+ selPriv = k
+ selAlg = r.Alg
+ selKid = r.Kid
+ }
+ }
+ case "EdDSA":
+ if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
+ if k, ok := kAny.(ed25519.PrivateKey); ok {
+ selPriv = k
+ selAlg = r.Alg
+ selKid = r.Kid
+ }
+ }
+ }
+ }
+ }
+
+ kc.mu.Lock()
+ defer kc.mu.Unlock()
+ kc.pub = pub
+ kc.meta = meta
+ kc.selKid = selKid
+ kc.selAlg = selAlg
+ kc.selPriv = selPriv
+ return nil
+}
+
+func keysDecrypt(encKey, enc string) ([]byte, error) {
+ return keysDecryptImpl(encKey, enc)
+}
+
+// indirection for same package
+var keysDecryptImpl = func(encKey, enc string) ([]byte, error) {
+ return nil, errors.New("not wired")
+}
+
+// Wire up from keys package
+func init() {
+ keysDecryptImpl = keysDecryptShim
+}
+
+func keysDecryptShim(encKey, enc string) ([]byte, error) {
+ return keys.Decrypt(encKey, enc)
+}
+
+
+
+package auth
+
+import (
+ "github.com/glueops/autoglue/internal/config"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ValidateJWT verifies RS256/RS384/RS512/EdDSA tokens using the in-memory key cache.
+// It honors kid when present, and falls back to any active key.
+func ValidateJWT(tokenStr string, db *gorm.DB) *models.User {
+ cfg, _ := config.Load()
+
+ parser := jwt.NewParser(
+ jwt.WithIssuer(cfg.JWTIssuer),
+ jwt.WithAudience(cfg.JWTAudience),
+ jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "EdDSA"}),
+ )
+
+ token, err := parser.Parse(tokenStr, func(t *jwt.Token) (any, error) {
+ // Resolve by kid first
+ kid, _ := t.Header["kid"].(string)
+
+ kc.mu.RLock()
+ defer kc.mu.RUnlock()
+
+ if kid != "" {
+ if k, ok := kc.pub[kid]; ok {
+ return k, nil
+ }
+ }
+ // Fallback: try first active key
+ for _, k := range kc.pub {
+ return k, nil
+ }
+ return nil, jwt.ErrTokenUnverifiable
+ })
+ if err != nil || !token.Valid {
+ return nil
+ }
+
+ claims, _ := token.Claims.(jwt.MapClaims)
+ sub, _ := claims["sub"].(string)
+ uid, err := uuid.Parse(sub)
+ if err != nil {
+ return nil
+ }
+
+ var u models.User
+ if err := db.First(&u, "id = ? AND is_disabled = false", uid).Error; err != nil {
+ return nil
+ }
+ return &u
+}
+
+
+
+package auth
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "time"
+
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// random opaque token (returned to client once)
+func generateOpaqueToken(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+type RefreshPair struct {
+ Plain string
+ Record models.RefreshToken
+}
+
+// Issue a new refresh token (new family if familyID == nil)
+func IssueRefreshToken(db *gorm.DB, userID uuid.UUID, ttl time.Duration, familyID *uuid.UUID) (RefreshPair, error) {
+ plain, err := generateOpaqueToken(32)
+ if err != nil {
+ return RefreshPair{}, err
+ }
+ hash, err := HashSecretArgon2id(plain)
+ if err != nil {
+ return RefreshPair{}, err
+ }
+
+ fid := uuid.New()
+ if familyID != nil {
+ fid = *familyID
+ }
+
+ rec := models.RefreshToken{
+ UserID: userID,
+ FamilyID: fid,
+ TokenHash: hash,
+ ExpiresAt: time.Now().Add(ttl),
+ }
+ if err := db.Create(&rec).Error; err != nil {
+ return RefreshPair{}, err
+ }
+ return RefreshPair{Plain: plain, Record: rec}, nil
+}
+
+// ValidateRefreshToken refresh token; returns record if valid & not revoked/expired
+func ValidateRefreshToken(db *gorm.DB, plain string) (*models.RefreshToken, error) {
+ if plain == "" {
+ return nil, errors.New("empty")
+ }
+ // var rec models.RefreshToken
+ // We can't query by hash w/ Argon; scan candidates by expiry window. Keep small TTL (e.g. 30d).
+ if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
+ Find(&[]models.RefreshToken{}).Error; err != nil {
+ return nil, err
+ }
+ // Better: add a prefix column to narrow scan; omitted for brevity.
+
+ // Pragmatic approach: single SELECT per token:
+ // Add a TokenHashSHA256 column for deterministic lookup if you want O(1). (Optional)
+
+ // Minimal: iterate limited set; for simplicity we fetch by created window:
+ var recs []models.RefreshToken
+ if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
+ Order("created_at desc").Limit(500).Find(&recs).Error; err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ ok, _ := VerifySecretArgon2id(r.TokenHash, plain)
+ if ok {
+ return &r, nil
+ }
+ }
+ return nil, errors.New("invalid")
+}
+
+// RevokeFamily revokes all tokens in a family (logout everywhere)
+func RevokeFamily(db *gorm.DB, familyID uuid.UUID) error {
+ now := time.Now()
+ return db.Model(&models.RefreshToken{}).
+ Where("family_id = ? AND revoked_at IS NULL", familyID).
+ Update("revoked_at", &now).Error
+}
+
+// RotateRefreshToken replaces one token with a fresh one within the same family
+func RotateRefreshToken(db *gorm.DB, used *models.RefreshToken, ttl time.Duration) (RefreshPair, error) {
+ // revoke the used token (one-time use)
+ now := time.Now()
+ if err := db.Model(&models.RefreshToken{}).
+ Where("id = ? AND revoked_at IS NULL", used.ID).
+ Update("revoked_at", &now).Error; err != nil {
+ return RefreshPair{}, err
+ }
+ return IssueRefreshToken(db, used.UserID, ttl, &used.FamilyID)
+}
+
+
+
+package auth
+
+import (
+ "time"
+
+ "github.com/glueops/autoglue/internal/models"
+ "gorm.io/gorm"
+)
+
+// ValidateAPIKey validates a single-token user API key sent via X-API-KEY.
+func ValidateAPIKey(rawKey string, db *gorm.DB) *models.User {
+ if rawKey == "" {
+ return nil
+ }
+ digest := SHA256Hex(rawKey)
+
+ var k models.APIKey
+ if err := db.
+ Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
+ First(&k).Error; err != nil {
+ return nil
+ }
+ if k.UserID == nil {
+ return nil
+ }
+ var u models.User
+ if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
+ return nil
+ }
+ // Optional: touch last_used_at here if you've added it on the model.
+ return &u
+}
+
+// ValidateAppKeyPair validates a user key/secret pair via X-APP-KEY / X-APP-SECRET.
+func ValidateAppKeyPair(appKey, secret string, db *gorm.DB) *models.User {
+ if appKey == "" || secret == "" {
+ return nil
+ }
+ digest := SHA256Hex(appKey)
+
+ var k models.APIKey
+ if err := db.
+ Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
+ First(&k).Error; err != nil {
+ return nil
+ }
+ ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
+ if !ok || k.UserID == nil {
+ return nil
+ }
+ var u models.User
+ if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
+ return nil
+ }
+ return &u
+}
+
+// ValidateOrgKeyPair validates an org key/secret via X-ORG-KEY / X-ORG-SECRET.
+func ValidateOrgKeyPair(orgKey, secret string, db *gorm.DB) *models.Organization {
+ if orgKey == "" || secret == "" {
+ return nil
+ }
+ digest := SHA256Hex(orgKey)
+
+ var k models.APIKey
+ if err := db.
+ Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "org", time.Now()).
+ First(&k).Error; err != nil {
+ return nil
+ }
+ ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
+ if !ok || k.OrgID == nil {
+ return nil
+ }
+ var o models.Organization
+ if err := db.First(&o, "id = ?", *k.OrgID).Error; err != nil {
+ return nil
+ }
+ return &o
+}
+
+// local helper; avoids nil-deref when comparing secrets
+func zeroIfNil(s *string) string {
+ if s == nil {
+ return ""
+ }
+ return *s
+}
+
+
+
+package bg
+
+import (
+ "context"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type CleanupArgs struct {
+ RetainDays int `json:"retain_days"`
+ Table string `json:"table"`
+}
+
+type JobRow struct {
+ ID string `gorm:"primaryKey"`
+ Status string
+ UpdatedAt time.Time
+}
+
+func (JobRow) TableName() string { return "jobs" }
+
+func CleanupWorker(gdb *gorm.DB, jobs *Jobs, retainDays int) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ if err := CleanupJobs(gdb, retainDays); err != nil {
+ return nil, err
+ }
+
+ // schedule tomorrow 03:30
+ next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 30*time.Minute)
+
+ _, _ = jobs.Enqueue(
+ ctx,
+ uuid.NewString(),
+ "archer_cleanup",
+ CleanupArgs{RetainDays: retainDays, Table: "jobs"},
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ )
+ return nil, nil
+ }
+}
+
+func CleanupJobs(db *gorm.DB, retainDays int) error {
+ cutoff := time.Now().AddDate(0, 0, -retainDays)
+ return db.
+ Where("status IN ?", []string{"success", "failed", "cancelled"}).
+ Where("updated_at < ?", cutoff).
+ Delete(&JobRow{}).Error
+}
+
+
+
+package bg
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "mime"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/glueops/autoglue/internal/config"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog/log"
+ "gorm.io/gorm"
+)
+
+type DbBackupArgs struct {
+ IntervalS int `json:"interval_seconds,omitempty"`
+}
+
+type s3Scope struct {
+ Service string `json:"service"`
+ Region string `json:"region"`
+}
+
+type encAWS struct {
+ AccessKeyID string `json:"access_key_id"`
+ SecretAccessKey string `json:"secret_access_key"`
+}
+
+func DbBackupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ args := DbBackupArgs{IntervalS: 3600}
+ _ = j.ParseArguments(&args)
+
+ if args.IntervalS <= 0 {
+ args.IntervalS = 3600
+ }
+
+ if err := DbBackup(ctx, db); err != nil {
+ return nil, err
+ }
+
+ queue := j.QueueName
+ if strings.TrimSpace(queue) == "" {
+ queue = "db_backup_s3"
+ }
+
+ next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
+
+ payload := DbBackupArgs{}
+
+ opts := []archer.FnOptions{
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ }
+
+ if _, err := jobs.Enqueue(ctx, uuid.NewString(), queue, payload, opts...); err != nil {
+ log.Error().Err(err).Str("queue", queue).Time("next", next).Msg("failed to enqueue next db backup")
+ } else {
+ log.Info().Str("queue", queue).Time("next", next).Msg("scheduled next db backup")
+ }
+ return nil, nil
+ }
+}
+
+func DbBackup(ctx context.Context, db *gorm.DB) error {
+ cfg, err := config.Load()
+ if err != nil {
+ return fmt.Errorf("load config: %w", err)
+ }
+
+ cred, sc, err := loadS3Credential(ctx, db)
+ if err != nil {
+ return fmt.Errorf("load credential: %w", err)
+ }
+
+ ak, sk, err := decryptAwsAccessKeys(ctx, db, cred)
+ if err != nil {
+ return fmt.Errorf("decrypt aws keys: %w", err)
+ }
+
+ region := sc.Region
+
+ if strings.TrimSpace(region) == "" {
+ region = cred.Region
+ if strings.TrimSpace(region) == "" {
+ region = "us-west-1"
+ }
+ }
+
+ bucket := strings.ToLower(fmt.Sprintf("%s-autoglue-backups-%s", cred.OrganizationID, region))
+
+ s3cli, err := makeS3Client(ctx, ak, sk, region)
+ if err != nil {
+ return err
+ }
+
+ if err := ensureBucket(ctx, s3cli, bucket, region); err != nil {
+ return fmt.Errorf("ensure bucket: %w", err)
+ }
+
+ tmpDir := os.TempDir()
+ now := time.Now().UTC()
+ key := fmt.Sprintf("%04d/%02d/%02d/backup-%02d.sql", now.Year(), now.Month(), now.Day(), now.Hour())
+ outPath := filepath.Join(tmpDir, "autoglue-backup-"+now.Format("20060102T150405Z")+".sql")
+
+ if err := runPgDump(ctx, cfg.DbURL, outPath); err != nil {
+ return fmt.Errorf("pg_dump: %w", err)
+ }
+ defer os.Remove(outPath)
+
+ if err := uploadFileToS3(ctx, s3cli, bucket, key, outPath); err != nil {
+ return fmt.Errorf("s3 upload: %w", err)
+ }
+
+ log.Info().Str("bucket", bucket).Str("key", key).Msg("backup uploaded")
+
+ return nil
+}
+
+// --- Helpers
+
+func loadS3Credential(ctx context.Context, db *gorm.DB) (models.Credential, s3Scope, error) {
+ var c models.Credential
+ err := db.
+ WithContext(ctx).
+ Where("provider = ? AND kind = ? AND scope_kind = ?", "aws", "aws_access_key", "service").
+ Where("scope ->> 'service' = ?", "s3").
+ Order("created_at DESC").
+ First(&c).Error
+ if err != nil {
+ return models.Credential{}, s3Scope{}, fmt.Errorf("load credential: %w", err)
+ }
+
+ var sc s3Scope
+ _ = json.Unmarshal(c.Scope, &sc)
+ return c, sc, nil
+}
+
+func decryptAwsAccessKeys(ctx context.Context, db *gorm.DB, c models.Credential) (string, string, error) {
+ plain, err := utils.DecryptForOrg(c.OrganizationID, c.EncryptedData, c.IV, c.Tag, db)
+ if err != nil {
+ return "", "", err
+ }
+
+ var payload encAWS
+ if err := json.Unmarshal([]byte(plain), &payload); err != nil {
+ return "", "", fmt.Errorf("parse decrypted payload: %w", err)
+ }
+
+ if payload.AccessKeyID == "" || payload.SecretAccessKey == "" {
+ return "", "", errors.New("decrypted payload missing keys")
+ }
+ return payload.AccessKeyID, payload.SecretAccessKey, nil
+}
+
+func makeS3Client(ctx context.Context, accessKey, secret, region string) (*s3.Client, error) {
+ staticCredentialsProvider := credentials.NewStaticCredentialsProvider(accessKey, secret, "")
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithCredentialsProvider(staticCredentialsProvider), awsconfig.WithRegion(region))
+ if err != nil {
+ return nil, fmt.Errorf("aws config: %w", err)
+ }
+ return s3.NewFromConfig(cfg), nil
+}
+
+func ensureBucket(ctx context.Context, s3cli *s3.Client, bucket, region string) error {
+ _, err := s3cli.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
+ if err == nil {
+ return nil
+ }
+
+ if out, err := s3cli.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: aws.String(bucket)}); err == nil {
+ existing := string(out.LocationConstraint)
+ if existing == "" {
+ existing = "us-east-1"
+ }
+ if existing != region {
+ return fmt.Errorf("bucket %q already exists in region %q (requested %q)", bucket, existing, region)
+ }
+ }
+
+ // Create; LocationConstraint except us-east-1
+ in := &s3.CreateBucketInput{Bucket: aws.String(bucket)}
+ if region != "us-east-1" {
+ in.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{
+ LocationConstraint: s3types.BucketLocationConstraint(region),
+ }
+ }
+ if _, err := s3cli.CreateBucket(ctx, in); err != nil {
+ return fmt.Errorf("create bucket: %w", err)
+ }
+
+ // default SSE (best-effort)
+ _, _ = s3cli.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
+ Bucket: aws.String(bucket),
+ ServerSideEncryptionConfiguration: &s3types.ServerSideEncryptionConfiguration{
+ Rules: []s3types.ServerSideEncryptionRule{
+ {ApplyServerSideEncryptionByDefault: &s3types.ServerSideEncryptionByDefault{
+ SSEAlgorithm: s3types.ServerSideEncryptionAes256,
+ }},
+ },
+ },
+ })
+ return nil
+}
+
+func runPgDump(ctx context.Context, dbURL, outPath string) error {
+ if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+ return err
+ }
+
+ args := []string{
+ "--no-owner",
+ "--no-privileges",
+ "--format=plain",
+ "--file", outPath,
+ dbURL,
+ }
+
+ cmd := exec.CommandContext(ctx, "pg_dump", args...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("pg_dump failed: %v | %s", err, stderr.String())
+ }
+
+ return nil
+}
+
+func uploadFileToS3(ctx context.Context, s3cli *s3.Client, bucket, key, path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+
+ defer f.Close()
+
+ info, _ := f.Stat()
+ _, err = s3cli.PutObject(ctx, &s3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(key),
+ Body: f,
+ ContentLength: aws.Int64(info.Size()),
+ ContentType: aws.String(mime.TypeByExtension(filepath.Ext(path))),
+ ServerSideEncryption: s3types.ServerSideEncryptionAes256,
+ })
+
+ return err
+}
+
+
+
+package bg
+
+import (
+ "context"
+ "net"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/rs/zerolog/log"
+ "github.com/spf13/viper"
+ "gorm.io/gorm"
+)
+
+type Jobs struct{ Client *archer.Client }
+
+func archerOptionsFromDSN(dsn string) (*archer.Options, error) {
+ u, err := url.Parse(dsn)
+ if err != nil {
+ return nil, err
+ }
+
+ var user, pass string
+ if u.User != nil {
+ user = u.User.Username()
+ pass, _ = u.User.Password()
+ }
+
+ host := u.Host
+ if !strings.Contains(host, ":") {
+ host = net.JoinHostPort(host, "5432")
+ }
+
+ return &archer.Options{
+ Addr: host,
+ User: user,
+ Password: pass,
+ DBName: strings.TrimPrefix(u.Path, "/"),
+ SSL: u.Query().Get("sslmode"), // forward sslmode
+ }, nil
+}
+
+func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
+ opts, err := archerOptionsFromDSN(dbUrl)
+ if err != nil {
+ return nil, err
+ }
+
+ instances := viper.GetInt("archer.instances")
+ if instances <= 0 {
+ instances = 1
+ }
+
+ timeoutSec := viper.GetInt("archer.timeoutSec")
+ if timeoutSec <= 0 {
+ timeoutSec = 60
+ }
+
+ retainDays := viper.GetInt("archer.cleanup_retain_days")
+ if retainDays <= 0 {
+ retainDays = 7
+ }
+
+ c := archer.NewClient(
+ opts,
+ archer.WithSetTableName("jobs"), // <- ensure correct table
+ archer.WithSleepInterval(1*time.Second), // fast poll while debugging
+ archer.WithErrHandler(func(err error) { // bubble up worker SQL errors
+ log.Error().Err(err).Msg("[archer] worker error")
+ }),
+ )
+
+ jobs := &Jobs{Client: c}
+
+ c.Register(
+ "bootstrap_bastion",
+ BastionBootstrapWorker(gdb, jobs),
+ archer.WithInstances(instances),
+ archer.WithTimeout(time.Duration(timeoutSec)*time.Second),
+ )
+
+ c.Register(
+ "archer_cleanup",
+ CleanupWorker(gdb, jobs, retainDays),
+ archer.WithInstances(1),
+ archer.WithTimeout(5*time.Minute),
+ )
+
+ c.Register(
+ "tokens_cleanup",
+ TokensCleanupWorker(gdb, jobs),
+ archer.WithInstances(1),
+ archer.WithTimeout(5*time.Minute),
+ )
+
+ c.Register(
+ "db_backup_s3",
+ DbBackupWorker(gdb, jobs),
+ archer.WithInstances(1),
+ archer.WithTimeout(15*time.Minute),
+ )
+
+ c.Register(
+ "dns_reconcile",
+ DNSReconsileWorker(gdb, jobs),
+ archer.WithInstances(1),
+ archer.WithTimeout(2*time.Minute),
+ )
+ /*
+ c.Register(
+ "prepare_cluster",
+ ClusterPrepareWorker(gdb, jobs),
+ archer.WithInstances(1),
+ archer.WithTimeout(2*time.Minute),
+ )
+
+ c.Register(
+ "cluster_setup",
+ ClusterSetupWorker(gdb, jobs),
+ archer.WithInstances(1),
+ archer.WithTimeout(2*time.Minute),
+ )
+
+ c.Register(
+ "cluster_bootstrap",
+ ClusterBootstrapWorker(gdb, jobs),
+ archer.WithInstances(1),
+ archer.WithTimeout(60*time.Minute),
+ )
+
+ */
+
+ c.Register(
+ "org_key_sweeper",
+ OrgKeySweeperWorker(gdb, jobs),
+ archer.WithInstances(1),
+ archer.WithTimeout(5*time.Minute),
+ )
+
+ c.Register(
+ "cluster_action",
+ ClusterActionWorker(gdb),
+ archer.WithInstances(1),
+ )
+ return jobs, nil
+}
+
+func (j *Jobs) Start() error { return j.Client.Start() }
+func (j *Jobs) Stop() { j.Client.Stop() }
+
+func (j *Jobs) Enqueue(ctx context.Context, id, queue string, args any, opts ...archer.FnOptions) (any, error) {
+ return j.Client.Schedule(ctx, id, queue, args, opts...)
+}
+
+
+
+package bg
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/glueops/autoglue/internal/mapper"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog/log"
+ "gorm.io/gorm"
+)
+
+type ClusterActionArgs struct {
+ OrgID uuid.UUID `json:"org_id"`
+ ClusterID uuid.UUID `json:"cluster_id"`
+ Action string `json:"action"`
+ MakeTarget string `json:"make_target"`
+}
+
+type ClusterActionResult struct {
+ Status string `json:"status"`
+ Action string `json:"action"`
+ ClusterID string `json:"cluster_id"`
+ ElapsedMs int `json:"elapsed_ms"`
+}
+
+func ClusterActionWorker(db *gorm.DB) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ start := time.Now()
+ var args ClusterActionArgs
+ _ = j.ParseArguments(&args)
+
+ runID, _ := uuid.Parse(j.ID)
+
+ updateRun := func(status string, errMsg string) {
+ updates := map[string]any{
+ "status": status,
+ "error": errMsg,
+ }
+ if status == "succeeded" || status == "failed" {
+ updates["finished_at"] = time.Now().UTC().Format(time.RFC3339)
+ }
+ db.Model(&models.ClusterRun{}).Where("id = ?", runID).Updates(updates)
+ }
+
+ updateRun("running", "")
+
+ logger := log.With().
+ Str("job", j.ID).
+ Str("cluster_id", args.ClusterID.String()).
+ Str("action", args.Action).
+ Logger()
+
+ var c models.Cluster
+ if err := db.
+ Preload("BastionServer.SshKey").
+ Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers.SshKey").
+ Where("id = ? AND organization_id = ?", args.ClusterID, args.OrgID).
+ First(&c).Error; err != nil {
+ updateRun("failed", fmt.Errorf("load cluster: %w", err).Error())
+ return nil, fmt.Errorf("load cluster: %w", err)
+ }
+
+ // ---- Step 1: Prepare (mostly lifted from ClusterPrepareWorker)
+ if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("mark bootstrapping: %w", err)
+ }
+ c.Status = clusterStatusBootstrapping
+
+ if err := validateClusterForPrepare(&c); err != nil {
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("validate: %w", err)
+ }
+
+ allServers := flattenClusterServers(&c)
+ keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, &c, allServers)
+ if err != nil {
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("build ssh assets: %w", err)
+ }
+
+ dtoCluster := mapper.ClusterToDTO(c)
+
+ if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
+ kubeconfig, err := utils.DecryptForOrg(
+ c.OrganizationID,
+ c.EncryptedKubeconfig,
+ c.KubeIV,
+ c.KubeTag,
+ db,
+ )
+ if err != nil {
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ return nil, fmt.Errorf("decrypt kubeconfig: %w", err)
+ }
+ dtoCluster.Kubeconfig = &kubeconfig
+ }
+
+ orgKey, orgSecret, err := findOrCreateClusterAutomationKey(db, c.OrganizationID, c.ID, 24*time.Hour)
+ if err != nil {
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("org key: %w", err)
+ }
+ dtoCluster.OrgKey = &orgKey
+ dtoCluster.OrgSecret = &orgSecret
+
+ payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
+ if err != nil {
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("marshal payload: %w", err)
+ }
+
+ {
+ runCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
+ err := pushAssetsToBastion(runCtx, db, &c, sshConfig, keyPayloads, payloadJSON)
+ cancel()
+ if err != nil {
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("push assets: %w", err)
+ }
+ }
+
+ if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("mark pending: %w", err)
+ }
+ c.Status = clusterStatusPending
+
+ // ---- Step 2: Setup (ping-servers)
+ {
+ runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
+ out, err := runMakeOnBastion(runCtx, db, &c, "ping-servers")
+ cancel()
+ if err != nil {
+ logger.Error().Err(err).Str("output", out).Msg("ping-servers failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("ping-servers: %w", err)
+ }
+ }
+
+ if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("mark provisioning: %w", err)
+ }
+ c.Status = clusterStatusProvisioning
+
+ // ---- Step 3: Bootstrap (parameterized target)
+ {
+ runCtx, cancel := context.WithTimeout(ctx, 60*time.Minute)
+ out, err := runMakeOnBastion(runCtx, db, &c, args.MakeTarget)
+ cancel()
+ if err != nil {
+ logger.Error().Err(err).Str("output", out).Msg("bootstrap target failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make %s: %v", args.MakeTarget, err))
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("make %s: %w", args.MakeTarget, err)
+ }
+ }
+
+ if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
+ updateRun("failed", err.Error())
+ return nil, fmt.Errorf("mark ready: %w", err)
+ }
+
+ updateRun("succeeded", "")
+
+ return ClusterActionResult{
+ Status: "ok",
+ Action: args.Action,
+ ClusterID: c.ID.String(),
+ ElapsedMs: int(time.Since(start).Milliseconds()),
+ }, nil
+ }
+}
+
+
+
+package bg
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog/log"
+ "gorm.io/gorm"
+)
+
+type ClusterBootstrapArgs struct {
+ IntervalS int `json:"interval_seconds,omitempty"`
+}
+
+type ClusterBootstrapResult struct {
+ Status string `json:"status"`
+ Processed int `json:"processed"`
+ Ready int `json:"ready"`
+ Failed int `json:"failed"`
+ ElapsedMs int `json:"elapsed_ms"`
+ FailedIDs []uuid.UUID `json:"failed_cluster_ids"`
+}
+
+func ClusterBootstrapWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ args := ClusterBootstrapArgs{IntervalS: 120}
+ jobID := j.ID
+ start := time.Now()
+
+ _ = j.ParseArguments(&args)
+ if args.IntervalS <= 0 {
+ args.IntervalS = 120
+ }
+
+ var clusters []models.Cluster
+ if err := db.
+ Preload("BastionServer.SshKey").
+ Where("status = ?", clusterStatusProvisioning).
+ Find(&clusters).Error; err != nil {
+ log.Error().Err(err).Msg("[cluster_bootstrap] query clusters failed")
+ return nil, err
+ }
+
+ proc, ready, failCount := 0, 0, 0
+ var failedIDs []uuid.UUID
+
+ perClusterTimeout := 60 * time.Minute
+
+ for i := range clusters {
+ c := &clusters[i]
+ proc++
+
+ if c.BastionServer.ID == uuid.Nil || c.BastionServer.Status != "ready" {
+ continue
+ }
+
+ logger := log.With().
+ Str("job", jobID).
+ Str("cluster_id", c.ID.String()).
+ Str("cluster_name", c.Name).
+ Logger()
+
+ logger.Info().Msg("[cluster_bootstrap] running make bootstrap")
+
+ runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
+ out, err := runMakeOnBastion(runCtx, db, c, "setup")
+ cancel()
+
+ if err != nil {
+ failCount++
+ failedIDs = append(failedIDs, c.ID)
+ logger.Error().Err(err).Str("output", out).Msg("[cluster_bootstrap] make setup failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make setup: %v", err))
+ continue
+ }
+
+ // you can choose a different terminal status here if you like
+ if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
+ failCount++
+ failedIDs = append(failedIDs, c.ID)
+ logger.Error().Err(err).Msg("[cluster_bootstrap] failed to mark cluster ready")
+ continue
+ }
+
+ ready++
+ logger.Info().Msg("[cluster_bootstrap] cluster marked ready")
+ }
+
+ res := ClusterBootstrapResult{
+ Status: "ok",
+ Processed: proc,
+ Ready: ready,
+ Failed: failCount,
+ ElapsedMs: int(time.Since(start).Milliseconds()),
+ FailedIDs: failedIDs,
+ }
+
+ log.Info().
+ Int("processed", proc).
+ Int("ready", ready).
+ Int("failed", failCount).
+ Msg("[cluster_bootstrap] reconcile tick ok")
+
+ // self-reschedule
+ next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
+ _, _ = jobs.Enqueue(
+ ctx,
+ uuid.NewString(),
+ "cluster_bootstrap",
+ args,
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ )
+ return res, nil
+ }
+}
+
+
+
+package bg
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog/log"
+ "gorm.io/gorm"
+)
+
+type ClusterSetupArgs struct {
+ IntervalS int `json:"interval_seconds,omitempty"`
+}
+
+type ClusterSetupResult struct {
+ Status string `json:"status"`
+ Processed int `json:"processed"`
+ Provisioning int `json:"provisioning"`
+ Failed int `json:"failed"`
+ ElapsedMs int `json:"elapsed_ms"`
+ FailedCluster []uuid.UUID `json:"failed_cluster_ids"`
+}
+
+func ClusterSetupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ args := ClusterSetupArgs{IntervalS: 120}
+ jobID := j.ID
+ start := time.Now()
+
+ _ = j.ParseArguments(&args)
+ if args.IntervalS <= 0 {
+ args.IntervalS = 120
+ }
+
+ var clusters []models.Cluster
+ if err := db.
+ Preload("BastionServer.SshKey").
+ Where("status = ?", clusterStatusPending).
+ Find(&clusters).Error; err != nil {
+ log.Error().Err(err).Msg("[cluster_setup] query clusters failed")
+ return nil, err
+ }
+
+ proc, prov, failCount := 0, 0, 0
+ var failedIDs []uuid.UUID
+
+ perClusterTimeout := 30 * time.Minute
+
+ for i := range clusters {
+ c := &clusters[i]
+ proc++
+
+ if c.BastionServer.ID == uuid.Nil || c.BastionServer.Status != "ready" {
+ continue
+ }
+
+ logger := log.With().
+ Str("job", jobID).
+ Str("cluster_id", c.ID.String()).
+ Str("cluster_name", c.Name).
+ Logger()
+
+ logger.Info().Msg("[cluster_setup] running make setup")
+
+ runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
+ out, err := runMakeOnBastion(runCtx, db, c, "ping-servers")
+ cancel()
+
+ if err != nil {
+ failCount++
+ failedIDs = append(failedIDs, c.ID)
+ logger.Error().Err(err).Str("output", out).Msg("[cluster_setup] make ping-servers failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
+ continue
+ }
+
+ if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
+ failCount++
+ failedIDs = append(failedIDs, c.ID)
+ logger.Error().Err(err).Msg("[cluster_setup] failed to mark cluster provisioning")
+ continue
+ }
+
+ prov++
+ logger.Info().Msg("[cluster_setup] cluster moved to provisioning")
+ }
+
+ res := ClusterSetupResult{
+ Status: "ok",
+ Processed: proc,
+ Provisioning: prov,
+ Failed: failCount,
+ ElapsedMs: int(time.Since(start).Milliseconds()),
+ FailedCluster: failedIDs,
+ }
+
+ log.Info().
+ Int("processed", proc).
+ Int("provisioning", prov).
+ Int("failed", failCount).
+ Msg("[cluster_setup] reconcile tick ok")
+
+ // self-reschedule
+ next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
+ _, _ = jobs.Enqueue(
+ ctx,
+ uuid.NewString(),
+ "cluster_setup",
+ args,
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ )
+ return res, nil
+ }
+}
+
+
+
+package bg
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+ "gorm.io/gorm"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ r53 "github.com/aws/aws-sdk-go-v2/service/route53"
+ r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
+ "github.com/aws/smithy-go"
+ smithyhttp "github.com/aws/smithy-go/transport/http"
+)
+
+/************* args & small DTOs *************/
+
+type DNSReconcileArgs struct {
+ MaxDomains int `json:"max_domains,omitempty"`
+ MaxRecords int `json:"max_records,omitempty"`
+ IntervalS int `json:"interval_seconds,omitempty"`
+}
+
+// TXT marker content (compact)
+type ownershipMarker struct {
+ Ver string `json:"v"` // "ag1"
+ Org string `json:"org"` // org UUID
+ Rec string `json:"rec"` // record UUID
+ Fp string `json:"fp"` // short fp (first 16 of sha256)
+}
+
+// ExternalDNS poison owner id β MUST NOT match any real external-dns --txt-owner-id
+const externalDNSPoisonOwner = "autoglue-lock"
+
+// ExternalDNS poison content β fake owner so real external-dns skips it.
+const externalDNSPoisonValue = "heritage=external-dns,external-dns/owner=" + externalDNSPoisonOwner + ",external-dns/resource=manual/autoglue"
+
+// Default TTL for non-alias records (alias not supported in this reconciler)
+const defaultRecordTTLSeconds int64 = 300
+
+/************* entrypoint worker *************/
+
+func DNSReconsileWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ args := DNSReconcileArgs{MaxDomains: 25, MaxRecords: 100, IntervalS: 30}
+ _ = j.ParseArguments(&args)
+
+ if args.MaxDomains <= 0 {
+ args.MaxDomains = 25
+ }
+ if args.MaxRecords <= 0 {
+ args.MaxRecords = 100
+ }
+ if args.IntervalS <= 0 {
+ args.IntervalS = 30
+ }
+
+ processedDomains, processedRecords, err := reconcileDNSOnce(ctx, db, args)
+ if err != nil {
+ log.Error().Err(err).Msg("[dns] reconcile tick failed")
+ } else {
+ log.Debug().
+ Int("domains", processedDomains).
+ Int("records", processedRecords).
+ Msg("[dns] reconcile tick ok")
+ }
+
+ next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
+ _, _ = jobs.Enqueue(ctx, uuid.NewString(), "dns_reconcile", args,
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ )
+
+ return map[string]any{
+ "domains_processed": processedDomains,
+ "records_processed": processedRecords,
+ }, nil
+ }
+}
+
+/************* core tick *************/
+
+func reconcileDNSOnce(ctx context.Context, db *gorm.DB, args DNSReconcileArgs) (int, int, error) {
+ var domains []models.Domain
+
+ // 1) validate/backfill pending domains
+ if err := db.
+ Where("status = ?", "pending").
+ Order("created_at ASC").
+ Limit(args.MaxDomains).
+ Find(&domains).Error; err != nil {
+ return 0, 0, err
+ }
+
+ domainsProcessed := 0
+ for i := range domains {
+ if err := processDomain(ctx, db, &domains[i]); err != nil {
+ log.Error().Err(err).Str("domain", domains[i].DomainName).Msg("[dns] domain processing failed")
+ } else {
+ domainsProcessed++
+ }
+ }
+
+ // 2) apply pending record sets for ready domains
+ var readyDomains []models.Domain
+ if err := db.Where("status = ?", "ready").Find(&readyDomains).Error; err != nil {
+ return domainsProcessed, 0, err
+ }
+
+ recordsProcessed := 0
+ for i := range readyDomains {
+ n, err := processPendingRecordsForDomain(ctx, db, &readyDomains[i], args.MaxRecords)
+ if err != nil {
+ log.Error().Err(err).Str("domain", readyDomains[i].DomainName).Msg("[dns] record processing failed")
+ continue
+ }
+ recordsProcessed += n
+ }
+
+ return domainsProcessed, recordsProcessed, nil
+}
+
+/************* domain processing *************/
+
+func processDomain(ctx context.Context, db *gorm.DB, d *models.Domain) error {
+ orgID := d.OrganizationID
+
+ // 1) Load credential (org-guarded)
+ var cred models.Credential
+ if err := db.Where("id = ? AND organization_id = ?", d.CredentialID, orgID).First(&cred).Error; err != nil {
+ return setDomainFailed(db, d, fmt.Errorf("credential not found: %w", err))
+ }
+
+ // 2) Decrypt β dto.AWSCredential
+ secret, err := utils.DecryptForOrg(orgID, cred.EncryptedData, cred.IV, cred.Tag, db)
+ if err != nil {
+ return setDomainFailed(db, d, fmt.Errorf("decrypt: %w", err))
+ }
+ var awsCred dto.AWSCredential
+ if err := jsonUnmarshalStrict([]byte(secret), &awsCred); err != nil {
+ return setDomainFailed(db, d, fmt.Errorf("secret decode: %w", err))
+ }
+
+ // 3) Client
+ r53c, _, err := newRoute53Client(ctx, awsCred)
+ if err != nil {
+ return setDomainFailed(db, d, err)
+ }
+
+ // 4) Backfill zone id if missing
+ zoneID := strings.TrimSpace(d.ZoneID)
+ if zoneID == "" {
+ zid, err := findHostedZoneID(ctx, r53c, d.DomainName)
+ if err != nil {
+ return setDomainFailed(db, d, fmt.Errorf("discover zone id: %w", err))
+ }
+ zoneID = zid
+ d.ZoneID = zoneID
+ }
+
+ // 5) Sanity: can fetch zone
+ if _, err := r53c.GetHostedZone(ctx, &r53.GetHostedZoneInput{Id: aws.String(zoneID)}); err != nil {
+ return setDomainFailed(db, d, fmt.Errorf("get hosted zone: %w", err))
+ }
+
+ // 6) Mark ready
+ d.Status = "ready"
+ d.LastError = ""
+ if err := db.Save(d).Error; err != nil {
+ return err
+ }
+ return nil
+}
+
+func setDomainFailed(db *gorm.DB, d *models.Domain, cause error) error {
+ d.Status = "failed"
+ d.LastError = truncateErr(cause.Error())
+ _ = db.Save(d).Error
+ return cause
+}
+
+/************* record processing *************/
+
+func processPendingRecordsForDomain(ctx context.Context, db *gorm.DB, d *models.Domain, max int) (int, error) {
+ orgID := d.OrganizationID
+
+ // reload credential
+ var cred models.Credential
+ if err := db.Where("id = ? AND organization_id = ?", d.CredentialID, orgID).First(&cred).Error; err != nil {
+ return 0, err
+ }
+
+ secret, err := utils.DecryptForOrg(orgID, cred.EncryptedData, cred.IV, cred.Tag, db)
+ if err != nil {
+ return 0, err
+ }
+
+ var awsCred dto.AWSCredential
+ if err := jsonUnmarshalStrict([]byte(secret), &awsCred); err != nil {
+ return 0, err
+ }
+ r53c, _, err := newRoute53Client(ctx, awsCred)
+ if err != nil {
+ return 0, err
+ }
+
+ var records []models.RecordSet
+ if err := db.
+ Where("domain_id = ? AND status = ?", d.ID, "pending").
+ Order("created_at ASC").
+ Limit(max).
+ Find(&records).Error; err != nil {
+ return 0, err
+ }
+
+ applied := 0
+ for i := range records {
+ if err := applyRecord(ctx, db, r53c, d, &records[i]); err != nil {
+ log.Error().
+ Err(err).
+ Str("zone_id", d.ZoneID).
+ Str("domain", d.DomainName).
+ Str("record_id", records[i].ID.String()).
+ Str("name", records[i].Name).
+ Str("type", strings.ToUpper(records[i].Type)).
+ Msg("[dns] apply record failed")
+ _ = setRecordFailed(db, &records[i], err)
+ continue
+ }
+ applied++
+ }
+ return applied, nil
+}
+
+// core write + ownership + external-dns hardening
+
+func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.Domain, r *models.RecordSet) error {
+ zoneID := strings.TrimSpace(d.ZoneID)
+ if zoneID == "" {
+ return errors.New("domain has no zone_id")
+ }
+
+ rt := strings.ToUpper(r.Type)
+
+ // FQDN & marker
+ fq := recordFQDN(r.Name, d.DomainName) // ends with "."
+ mname := markerName(fq)
+ expected := buildMarkerValue(d.OrganizationID.String(), r.ID.String(), r.Fingerprint)
+
+ logCtx := log.With().
+ Str("zone_id", zoneID).
+ Str("domain", d.DomainName).
+ Str("fqdn", fq).
+ Str("rr_type", rt).
+ Str("record_id", r.ID.String()).
+ Str("org_id", d.OrganizationID.String()).
+ Logger()
+
+ start := time.Now()
+
+ // ---- ExternalDNS preflight ----
+ extOwned, err := hasExternalDNSOwnership(ctx, r53c, zoneID, fq, rt)
+ if err != nil {
+ return fmt.Errorf("external_dns_lookup: %w", err)
+ }
+ if extOwned {
+ logCtx.Warn().Msg("[dns] ownership conflict: external-dns claims this record")
+ r.Owner = "external"
+ _ = db.Save(r).Error
+ return fmt.Errorf("ownership_conflict: external-dns claims %s; refusing to modify", strings.TrimSuffix(fq, "."))
+ }
+
+ // ---- Autoglue ownership preflight via _autoglue. TXT ----
+ markerVals, err := getMarkerTXTValues(ctx, r53c, zoneID, mname)
+ if err != nil {
+ return fmt.Errorf("marker lookup: %w", err)
+ }
+
+ hasForeignOwner := false
+ hasOurExact := false
+ for _, v := range markerVals {
+ mk, ok := parseMarkerValue(v)
+ if !ok {
+ continue
+ }
+ switch {
+ case mk.Org == d.OrganizationID.String() && mk.Rec == r.ID.String() && mk.Fp == shortFP(r.Fingerprint):
+ hasOurExact = true
+ case mk.Org != d.OrganizationID.String() || mk.Rec != r.ID.String():
+ hasForeignOwner = true
+ }
+ }
+
+ logCtx.Debug().
+ Bool("externaldns_owned", extOwned).
+ Int("marker_txt_count", len(markerVals)).
+ Bool("marker_has_our_exact", hasOurExact).
+ Bool("marker_has_foreign", hasForeignOwner).
+ Msg("[dns] ownership preflight")
+
+ if hasForeignOwner {
+ logCtx.Warn().Msg("[dns] ownership conflict: foreign _autoglue marker")
+ r.Owner = "external"
+ _ = db.Save(r).Error
+ return fmt.Errorf("ownership_conflict: marker for %s is owned by another controller; refusing to modify", strings.TrimSuffix(fq, "."))
+ }
+
+ // Decode user values
+ var userVals []string
+ rawVals := strings.TrimSpace(string(r.Values))
+ if rawVals != "" && rawVals != "null" {
+ if err := jsonUnmarshalStrict([]byte(rawVals), &userVals); err != nil {
+ return fmt.Errorf("values decode: %w", err)
+ }
+ }
+
+ // Quote TXT values as required by Route53
+ recs := make([]r53types.ResourceRecord, 0, len(userVals))
+ for _, v := range userVals {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ continue
+ }
+ if rt == "TXT" && !(strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`)) {
+ v = strconv.Quote(v)
+ }
+ recs = append(recs, r53types.ResourceRecord{Value: aws.String(v)})
+ }
+
+ // Alias is NOT supported - enforce at least one value for all record types we manage
+ if len(recs) == 0 {
+ logCtx.Warn().
+ Str("raw_values", truncateForLog(string(r.Values), 240)).
+ Int("decoded_value_count", len(userVals)).
+ Msg("[dns] invalid record: no values (alias not supported)")
+ return fmt.Errorf("invalid_record: %s %s requires at least one value (alias not supported)", strings.TrimSuffix(fq, "."), rt)
+ }
+
+ ttl := defaultRecordTTLSeconds
+ if r.TTL != nil && *r.TTL > 0 {
+ ttl = int64(*r.TTL)
+ }
+
+ // Build RR change (UPSERT)
+ rrChange := r53types.Change{
+ Action: r53types.ChangeActionUpsert,
+ ResourceRecordSet: &r53types.ResourceRecordSet{
+ Name: aws.String(fq),
+ Type: r53types.RRType(rt),
+ TTL: aws.Int64(ttl),
+ ResourceRecords: recs,
+ },
+ }
+
+ // Build marker TXT change (UPSERT)
+ markerChange := r53types.Change{
+ Action: r53types.ChangeActionUpsert,
+ ResourceRecordSet: &r53types.ResourceRecordSet{
+ Name: aws.String(mname),
+ Type: r53types.RRTypeTxt,
+ TTL: aws.Int64(defaultRecordTTLSeconds),
+ ResourceRecords: []r53types.ResourceRecord{
+ {Value: aws.String(strconv.Quote(expected))},
+ },
+ },
+ }
+
+ // Build external-dns poison TXT changes
+ poisonChanges := buildExternalDNSPoisonTXTChanges(fq, rt)
+
+ // Apply all in one batch (atomic-ish)
+ changes := []r53types.Change{rrChange, markerChange}
+ changes = append(changes, poisonChanges...)
+
+ // Log what we are about to send
+ logCtx.Debug().
+ Interface("route53_change_batch", toLogChangeBatch(zoneID, changes)).
+ Msg("[dns] route53 request preview")
+
+ _, err = r53c.ChangeResourceRecordSets(ctx, &r53.ChangeResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ ChangeBatch: &r53types.ChangeBatch{Changes: changes},
+ })
+ if err != nil {
+ logAWSError(logCtx, err)
+ logCtx.Info().Dur("elapsed", time.Since(start)).Msg("[dns] apply failed")
+ return err
+ }
+
+ logCtx.Info().
+ Dur("elapsed", time.Since(start)).
+ Int("change_count", len(changes)).
+ Msg("[dns] apply ok")
+
+ // Success β mark ready & ownership
+ r.Status = "ready"
+ r.LastError = ""
+ r.Owner = "autoglue"
+ if err := db.Save(r).Error; err != nil {
+ return err
+ }
+
+ _ = hasOurExact // could be used to skip marker write in future
+ return nil
+}
+
+func setRecordFailed(db *gorm.DB, r *models.RecordSet, cause error) error {
+ msg := truncateErr(cause.Error())
+ r.Status = "failed"
+ r.LastError = msg
+ // classify ownership on conflict
+ if strings.HasPrefix(msg, "ownership_conflict") {
+ r.Owner = "external"
+ } else if r.Owner == "" || r.Owner == "unknown" {
+ r.Owner = "unknown"
+ }
+ _ = db.Save(r).Error
+ return cause
+}
+
+/************* AWS helpers *************/
+
+func newRoute53Client(ctx context.Context, cred dto.AWSCredential) (*r53.Client, *aws.Config, error) {
+ // Route53 is global, but config still wants a region
+ region := strings.TrimSpace(cred.Region)
+ if region == "" {
+ region = "us-east-1"
+ }
+ cfg, err := config.LoadDefaultConfig(ctx,
+ config.WithRegion(region),
+ config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
+ cred.AccessKeyID, cred.SecretAccessKey, "",
+ )),
+ )
+ if err != nil {
+ return nil, nil, err
+ }
+ return r53.NewFromConfig(cfg), &cfg, nil
+}
+
+func findHostedZoneID(ctx context.Context, c *r53.Client, domain string) (string, error) {
+ d := normalizeDomain(domain)
+ out, err := c.ListHostedZonesByName(ctx, &r53.ListHostedZonesByNameInput{
+ DNSName: aws.String(d),
+ })
+ if err != nil {
+ return "", err
+ }
+ for _, hz := range out.HostedZones {
+ if strings.TrimSuffix(aws.ToString(hz.Name), ".") == d {
+ return trimZoneID(aws.ToString(hz.Id)), nil
+ }
+ }
+ return "", fmt.Errorf("hosted zone not found for %q", d)
+}
+
+func trimZoneID(id string) string {
+ return strings.TrimPrefix(id, "/hostedzone/")
+}
+
+func normalizeDomain(s string) string {
+ s = strings.TrimSpace(strings.ToLower(s))
+ return strings.TrimSuffix(s, ".")
+}
+
+func recordFQDN(name, domain string) string {
+ name = strings.TrimSpace(name)
+ if name == "" || name == "@" {
+ return normalizeDomain(domain) + "."
+ }
+ if strings.HasSuffix(name, ".") {
+ return name
+ }
+ return fmt.Sprintf("%s.%s.", name, normalizeDomain(domain))
+}
+
+/************* TXT marker / external-dns helpers *************/
+
+func markerName(fqdn string) string {
+ trimmed := strings.TrimSuffix(fqdn, ".")
+ return "_autoglue." + trimmed + "."
+}
+
+func shortFP(full string) string {
+ if len(full) > 16 {
+ return full[:16]
+ }
+ return full
+}
+
+func buildMarkerValue(orgID, recID, fp string) string {
+ return "v=ag1 org=" + orgID + " rec=" + recID + " fp=" + shortFP(fp)
+}
+
+func parseMarkerValue(s string) (ownershipMarker, bool) {
+ out := ownershipMarker{}
+ fields := strings.Fields(s)
+ if len(fields) < 4 {
+ return out, false
+ }
+ kv := map[string]string{}
+ for _, f := range fields {
+ parts := strings.SplitN(f, "=", 2)
+ if len(parts) == 2 {
+ kv[parts[0]] = parts[1]
+ }
+ }
+ if kv["v"] == "" || kv["org"] == "" || kv["rec"] == "" || kv["fp"] == "" {
+ return out, false
+ }
+ out.Ver, out.Org, out.Rec, out.Fp = kv["v"], kv["org"], kv["rec"], kv["fp"]
+ return out, true
+}
+
+func getMarkerTXTValues(ctx context.Context, c *r53.Client, zoneID, marker string) ([]string, error) {
+ return getTXTValues(ctx, c, zoneID, marker)
+}
+
+// generic TXT fetcher
+func getTXTValues(ctx context.Context, c *r53.Client, zoneID, name string) ([]string, error) {
+ out, err := c.ListResourceRecordSets(ctx, &r53.ListResourceRecordSetsInput{
+ HostedZoneId: aws.String(zoneID),
+ StartRecordName: aws.String(name),
+ StartRecordType: r53types.RRTypeTxt,
+ MaxItems: aws.Int32(1),
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(out.ResourceRecordSets) == 0 {
+ return nil, nil
+ }
+ rrset := out.ResourceRecordSets[0]
+ if aws.ToString(rrset.Name) != name || rrset.Type != r53types.RRTypeTxt {
+ return nil, nil
+ }
+ vals := make([]string, 0, len(rrset.ResourceRecords))
+ for _, rr := range rrset.ResourceRecords {
+ vals = append(vals, aws.ToString(rr.Value))
+ }
+ return vals, nil
+}
+
+// detect external-dns-style ownership for this fqdn/type
+func hasExternalDNSOwnership(ctx context.Context, c *r53.Client, zoneID, fqdn, rrType string) (bool, error) {
+ base := strings.TrimSuffix(fqdn, ".")
+ candidates := []string{
+ // with txtPrefix=extdns-, external-dns writes both:
+ // extdns- and extdns--
+ "extdns-" + base + ".",
+ "extdns-" + strings.ToLower(rrType) + "-" + base + ".",
+ }
+ for _, name := range candidates {
+ vals, err := getTXTValues(ctx, c, zoneID, name)
+ if err != nil {
+ return false, err
+ }
+ for _, raw := range vals {
+ v := strings.TrimSpace(raw)
+ // strip surrounding quotes if present
+ if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
+ if unq, err := strconv.Unquote(v); err == nil {
+ v = unq
+ }
+ }
+ meta := parseExternalDNSMeta(v)
+ if meta == nil {
+ continue
+ }
+ if meta["heritage"] == "external-dns" &&
+ meta["external-dns/owner"] != "" &&
+ meta["external-dns/owner"] != externalDNSPoisonOwner {
+ return true, nil
+ }
+ }
+ }
+ return false, nil
+}
+
+// parseExternalDNSMeta parses the comma-separated external-dns TXT format into a small map.
+func parseExternalDNSMeta(v string) map[string]string {
+ parts := strings.Split(v, ",")
+ if len(parts) == 0 {
+ return nil
+ }
+ meta := make(map[string]string, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ kv := strings.SplitN(p, "=", 2)
+ if len(kv) != 2 {
+ continue
+ }
+ meta[kv[0]] = kv[1]
+ }
+ if len(meta) == 0 {
+ return nil
+ }
+ return meta
+}
+
+// build poison TXT records so external-dns thinks some *other* owner manages this
+func buildExternalDNSPoisonTXTChanges(fqdn, rrType string) []r53types.Change {
+ base := strings.TrimSuffix(fqdn, ".")
+ names := []string{
+ "extdns-" + base + ".",
+ "extdns-" + strings.ToLower(rrType) + "-" + base + ".",
+ }
+ val := strconv.Quote(externalDNSPoisonValue)
+ changes := make([]r53types.Change, 0, len(names))
+ for _, n := range names {
+ changes = append(changes, r53types.Change{
+ Action: r53types.ChangeActionUpsert,
+ ResourceRecordSet: &r53types.ResourceRecordSet{
+ Name: aws.String(n),
+ Type: r53types.RRTypeTxt,
+ TTL: aws.Int64(defaultRecordTTLSeconds),
+ ResourceRecords: []r53types.ResourceRecord{
+ {Value: aws.String(val)},
+ },
+ },
+ })
+ }
+ return changes
+}
+
+/************* misc utils *************/
+
+func truncateErr(s string) string {
+ const max = 2000
+ if len(s) > max {
+ return s[:max]
+ }
+ return s
+}
+
+// Strict unmarshal that treats "null" -> zero value correctly.
+func jsonUnmarshalStrict(b []byte, dst any) error {
+ if len(b) == 0 {
+ return errors.New("empty json")
+ }
+ return json.Unmarshal(b, dst)
+}
+
+/************* logging DTOs & helpers *************/
+
+type logRR struct {
+ Value string `json:"value"`
+}
+
+type logRRSet struct {
+ Action string `json:"action"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ TTL *int64 `json:"ttl,omitempty"`
+ Records []logRR `json:"records,omitempty"`
+ RecordCount int `json:"record_count"`
+ HasAliasTarget bool `json:"has_alias_target"`
+ SetIdentifier *string `json:"set_identifier,omitempty"`
+}
+
+type logChangeBatch struct {
+ HostedZoneID string `json:"hosted_zone_id"`
+ ChangeCount int `json:"change_count"`
+ Changes []logRRSet `json:"changes"`
+}
+
+func truncateForLog(s string, max int) string {
+ s = strings.TrimSpace(s)
+ if max <= 0 || len(s) <= max {
+ return s
+ }
+ return s[:max] + "β¦"
+}
+
+func toLogChangeBatch(zoneID string, changes []r53types.Change) logChangeBatch {
+ out := logChangeBatch{
+ HostedZoneID: zoneID,
+ ChangeCount: len(changes),
+ Changes: make([]logRRSet, 0, len(changes)),
+ }
+
+ for _, ch := range changes {
+ if ch.ResourceRecordSet == nil {
+ continue
+ }
+ rrs := ch.ResourceRecordSet
+ lc := logRRSet{
+ Action: string(ch.Action),
+ Name: aws.ToString(rrs.Name),
+ Type: string(rrs.Type),
+ TTL: rrs.TTL,
+ HasAliasTarget: rrs.AliasTarget != nil,
+ SetIdentifier: rrs.SetIdentifier,
+ RecordCount: len(rrs.ResourceRecords),
+ Records: make([]logRR, 0, min(len(rrs.ResourceRecords), 5)),
+ }
+
+ // Log up to first 5 values (truncate each) to avoid log bloat / secrets
+ for i, rr := range rrs.ResourceRecords {
+ if i >= 5 {
+ break
+ }
+ lc.Records = append(lc.Records, logRR{Value: truncateForLog(aws.ToString(rr.Value), 160)})
+ }
+
+ out.Changes = append(out.Changes, lc)
+ }
+ return out
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+// logAWSError extracts useful smithy/HTTP metadata (status code + request id + api code) into logs.
+// logAWSError extracts useful smithy/HTTP metadata (status code + request id + api code) into logs.
+func logAWSError(l zerolog.Logger, err error) {
+ // Add operation context if present
+ var opErr *smithy.OperationError
+ if errors.As(err, &opErr) {
+ l = l.With().
+ Str("aws_service", opErr.ServiceID).
+ Str("aws_operation", opErr.OperationName).
+ Logger()
+ err = opErr.Unwrap()
+ }
+
+ // HTTP status + request id (smithy-go transport/http)
+ var re *smithyhttp.ResponseError
+ if errors.As(err, &re) {
+ status := re.HTTPStatusCode()
+
+ reqID := ""
+ if resp := re.HTTPResponse(); resp != nil && resp.Header != nil {
+ reqID = resp.Header.Get("x-amzn-RequestId")
+ if reqID == "" {
+ reqID = resp.Header.Get("x-amz-request-id")
+ }
+ }
+
+ ev := l.Error().Int("http_status", status).Err(err)
+ if reqID != "" {
+ ev = ev.Str("aws_request_id", reqID)
+ }
+ ev.Msg("[dns] aws route53 call failed")
+ return
+ }
+
+ // API error code/message (best-effort)
+ var apiErr smithy.APIError
+ if errors.As(err, &apiErr) {
+ l.Error().
+ Str("aws_error_code", apiErr.ErrorCode()).
+ Str("aws_error_message", apiErr.ErrorMessage()).
+ Err(err).
+ Msg("[dns] aws route53 api error")
+ return
+ }
+
+ l.Error().Err(err).Msg("[dns] aws route53 error")
+}
+
+
+
+package bg
+
+import (
+ "context"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog/log"
+ "gorm.io/gorm"
+)
+
+type OrgKeySweeperArgs struct {
+ IntervalS int `json:"interval_seconds,omitempty"`
+ RetentionDays int `json:"retention_days,omitempty"`
+}
+
+type OrgKeySweeperResult struct {
+ Status string `json:"status"`
+ MarkedRevoked int `json:"marked_revoked"`
+ DeletedEphemeral int `json:"deleted_ephemeral"`
+ ElapsedMs int `json:"elapsed_ms"`
+}
+
+func OrgKeySweeperWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ args := OrgKeySweeperArgs{
+ IntervalS: 3600,
+ RetentionDays: 10,
+ }
+ start := time.Now()
+
+ _ = j.ParseArguments(&args)
+ if args.IntervalS <= 0 {
+ args.IntervalS = 3600
+ }
+ if args.RetentionDays <= 0 {
+ args.RetentionDays = 10
+ }
+
+ now := time.Now()
+
+ // 1) Mark expired keys as revoked
+ res1 := db.Model(&models.APIKey{}).
+ Where("expires_at IS NOT NULL AND expires_at <= ? AND revoked = false", now).
+ Updates(map[string]any{
+ "revoked": true,
+ "updated_at": now,
+ })
+
+ if res1.Error != nil {
+ log.Error().Err(res1.Error).Msg("[org_key_sweeper] mark expired revoked failed")
+ return nil, res1.Error
+ }
+ markedRevoked := int(res1.RowsAffected)
+
+ // 2) Hard-delete ephemeral keys that are revoked and older than retention
+ cutoff := now.Add(-time.Duration(args.RetentionDays) * 24 * time.Hour)
+ res2 := db.
+ Where("is_ephemeral = ? AND revoked = ? AND updated_at <= ?", true, true, cutoff).
+ Delete(&models.APIKey{})
+
+ if res2.Error != nil {
+ log.Error().Err(res2.Error).Msg("[org_key_sweeper] delete revoked ephemeral keys failed")
+ return nil, res2.Error
+ }
+ deletedEphemeral := int(res2.RowsAffected)
+
+ out := OrgKeySweeperResult{
+ Status: "ok",
+ MarkedRevoked: markedRevoked,
+ DeletedEphemeral: deletedEphemeral,
+ ElapsedMs: int(time.Since(start).Milliseconds()),
+ }
+
+ log.Info().
+ Int("marked_revoked", markedRevoked).
+ Int("deleted_ephemeral", deletedEphemeral).
+ Msg("[org_key_sweeper] cleanup tick ok")
+
+ // Re-enqueue the sweeper
+ next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
+ _, _ = jobs.Enqueue(
+ ctx,
+ uuid.NewString(),
+ "org_key_sweeper",
+ args,
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ )
+ return out, nil
+ }
+}
+
+
+
+package bg
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/glueops/autoglue/internal/auth"
+ "github.com/glueops/autoglue/internal/mapper"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog/log"
+ "golang.org/x/crypto/ssh"
+ "gorm.io/gorm"
+)
+
+type ClusterPrepareArgs struct {
+ IntervalS int `json:"interval_seconds,omitempty"`
+}
+
+type ClusterPrepareFailure struct {
+ ClusterID uuid.UUID `json:"cluster_id"`
+ Step string `json:"step"`
+ Reason string `json:"reason"`
+}
+
+type ClusterPrepareResult struct {
+ Status string `json:"status"`
+ Processed int `json:"processed"`
+ MarkedPending int `json:"marked_pending"`
+ Failed int `json:"failed"`
+ ElapsedMs int `json:"elapsed_ms"`
+ FailedIDs []uuid.UUID `json:"failed_cluster_ids"`
+ Failures []ClusterPrepareFailure `json:"failures"`
+}
+
+// Alias the status constants from models to avoid string drift.
+const (
+ clusterStatusPrePending = models.ClusterStatusPrePending
+ clusterStatusPending = models.ClusterStatusPending
+ clusterStatusProvisioning = models.ClusterStatusProvisioning
+ clusterStatusReady = models.ClusterStatusReady
+ clusterStatusFailed = models.ClusterStatusFailed
+ clusterStatusBootstrapping = models.ClusterStatusBootstrapping
+)
+
+func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ args := ClusterPrepareArgs{IntervalS: 120}
+ jobID := j.ID
+ start := time.Now()
+
+ _ = j.ParseArguments(&args)
+ if args.IntervalS <= 0 {
+ args.IntervalS = 120
+ }
+
+ // Load all clusters that are pre_pending; weβll filter for bastion.ready in memory.
+ var clusters []models.Cluster
+ if err := db.
+ Preload("BastionServer.SshKey").
+ Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers.SshKey").
+ Where("status = ?", clusterStatusPrePending).
+ Find(&clusters).Error; err != nil {
+ log.Error().Err(err).Msg("[cluster_prepare] query clusters failed")
+ return nil, err
+ }
+
+ proc, ok, fail := 0, 0, 0
+ var failedIDs []uuid.UUID
+ var failures []ClusterPrepareFailure
+
+ perClusterTimeout := 8 * time.Minute
+
+ for i := range clusters {
+ c := &clusters[i]
+ proc++
+
+ // bastion must exist and be ready
+ if c.BastionServer == nil || c.BastionServerID == nil || *c.BastionServerID == uuid.Nil || c.BastionServer.Status != "ready" {
+ continue
+ }
+
+ if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
+ log.Error().Err(err).Msg("[cluster_prepare] failed to mark cluster bootstrapping")
+ continue
+ }
+
+ c.Status = clusterStatusBootstrapping
+
+ clusterLog := log.With().
+ Str("job", jobID).
+ Str("cluster_id", c.ID.String()).
+ Str("cluster_name", c.Name).
+ Logger()
+
+ clusterLog.Info().Msg("[cluster_prepare] starting")
+
+ if err := validateClusterForPrepare(c); err != nil {
+ fail++
+ failedIDs = append(failedIDs, c.ID)
+ failures = append(failures, ClusterPrepareFailure{
+ ClusterID: c.ID,
+ Step: "validate",
+ Reason: err.Error(),
+ })
+ clusterLog.Error().Err(err).Msg("[cluster_prepare] validation failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ continue
+ }
+
+ allServers := flattenClusterServers(c)
+ keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, c, allServers)
+ if err != nil {
+ fail++
+ failedIDs = append(failedIDs, c.ID)
+ failures = append(failures, ClusterPrepareFailure{
+ ClusterID: c.ID,
+ Step: "build_ssh_assets",
+ Reason: err.Error(),
+ })
+ clusterLog.Error().Err(err).Msg("[cluster_prepare] build ssh assets failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ continue
+ }
+
+ dtoCluster := mapper.ClusterToDTO(*c)
+
+ if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
+ kubeconfig, err := utils.DecryptForOrg(
+ c.OrganizationID,
+ c.EncryptedKubeconfig,
+ c.KubeIV,
+ c.KubeTag,
+ db,
+ )
+ if err != nil {
+ fail++
+ failedIDs = append(failedIDs, c.ID)
+ failures = append(failures, ClusterPrepareFailure{
+ ClusterID: c.ID,
+ Step: "decrypt_kubeconfig",
+ Reason: err.Error(),
+ })
+ clusterLog.Error().Err(err).Msg("[cluster_prepare] decrypt kubeconfig failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ continue
+ }
+ dtoCluster.Kubeconfig = &kubeconfig
+ }
+
+ orgKey, orgSecret, err := findOrCreateClusterAutomationKey(
+ db,
+ c.OrganizationID,
+ c.ID,
+ 24*time.Hour,
+ )
+
+ if err != nil {
+ fail++
+ failedIDs = append(failedIDs, c.ID)
+ failures = append(failures, ClusterPrepareFailure{
+ ClusterID: c.ID,
+ Step: "create_org_key",
+ Reason: err.Error(),
+ })
+ clusterLog.Error().Err(err).Msg("[cluster_prepare] create org key for payload failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ continue
+ }
+
+ dtoCluster.OrgKey = &orgKey
+ dtoCluster.OrgSecret = &orgSecret
+
+ payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
+ if err != nil {
+ fail++
+ failedIDs = append(failedIDs, c.ID)
+ failures = append(failures, ClusterPrepareFailure{
+ ClusterID: c.ID,
+ Step: "marshal_payload",
+ Reason: err.Error(),
+ })
+ clusterLog.Error().Err(err).Msg("[cluster_prepare] json marshal failed")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ continue
+ }
+
+ runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
+ err = pushAssetsToBastion(runCtx, db, c, sshConfig, keyPayloads, payloadJSON)
+ cancel()
+
+ if err != nil {
+ fail++
+ failedIDs = append(failedIDs, c.ID)
+ failures = append(failures, ClusterPrepareFailure{
+ ClusterID: c.ID,
+ Step: "ssh_push",
+ Reason: err.Error(),
+ })
+ clusterLog.Error().Err(err).Msg("[cluster_prepare] failed to push assets to bastion")
+ _ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
+ continue
+ }
+
+ if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
+ fail++
+ failedIDs = append(failedIDs, c.ID)
+ failures = append(failures, ClusterPrepareFailure{
+ ClusterID: c.ID,
+ Step: "set_pending",
+ Reason: err.Error(),
+ })
+ clusterLog.Error().Err(err).Msg("[cluster_prepare] failed to mark cluster pending")
+ continue
+ }
+
+ ok++
+ clusterLog.Info().Msg("[cluster_prepare] cluster marked pending")
+ }
+
+ res := ClusterPrepareResult{
+ Status: "ok",
+ Processed: proc,
+ MarkedPending: ok,
+ Failed: fail,
+ ElapsedMs: int(time.Since(start).Milliseconds()),
+ FailedIDs: failedIDs,
+ Failures: failures,
+ }
+
+ log.Info().
+ Int("processed", proc).
+ Int("pending", ok).
+ Int("failed", fail).
+ Msg("[cluster_prepare] reconcile tick ok")
+
+ next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
+ _, _ = jobs.Enqueue(
+ ctx,
+ uuid.NewString(),
+ "prepare_cluster",
+ args,
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ )
+ return res, nil
+ }
+}
+
+// ---------- helpers ----------
+
+func validateClusterForPrepare(c *models.Cluster) error {
+ if c.BastionServer == nil || c.BastionServerID == nil || *c.BastionServerID == uuid.Nil {
+ return fmt.Errorf("missing bastion server")
+ }
+ if c.BastionServer.Status != "ready" {
+ return fmt.Errorf("bastion server not ready (status=%s)", c.BastionServer.Status)
+ }
+
+ // CaptainDomain is a value type; presence is via *ID
+ if c.CaptainDomainID == nil || *c.CaptainDomainID == uuid.Nil {
+ return fmt.Errorf("missing captain domain for cluster")
+ }
+
+ // ControlPlaneRecordSet is a pointer; presence is via *ID + non-nil struct
+ if c.ControlPlaneRecordSetID == nil || *c.ControlPlaneRecordSetID == uuid.Nil || c.ControlPlaneRecordSet == nil {
+ return fmt.Errorf("missing control_plane_record_set for cluster")
+ }
+
+ if len(c.NodePools) == 0 {
+ return fmt.Errorf("cluster has no node pools")
+ }
+
+ hasServer := false
+ for i := range c.NodePools {
+ if len(c.NodePools[i].Servers) > 0 {
+ hasServer = true
+ break
+ }
+ }
+ if !hasServer {
+ return fmt.Errorf("cluster has no servers attached to node pools")
+ }
+
+ return nil
+}
+
+func flattenClusterServers(c *models.Cluster) []*models.Server {
+ var out []*models.Server
+ for i := range c.NodePools {
+ for j := range c.NodePools[i].Servers {
+ s := &c.NodePools[i].Servers[j]
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+type keyPayload struct {
+ FileName string
+ PrivateKeyB64 string
+}
+
+// build ssh-config for all servers + decrypt keys.
+// ssh-config is intended to live on the bastion and connect via *private* IPs.
+func buildSSHAssetsForCluster(db *gorm.DB, c *models.Cluster, servers []*models.Server) (map[uuid.UUID]keyPayload, string, error) {
+ var sb strings.Builder
+ keys := make(map[uuid.UUID]keyPayload)
+
+ for _, s := range servers {
+ // Defensive checks
+ if strings.TrimSpace(s.PrivateIPAddress) == "" {
+ return nil, "", fmt.Errorf("server %s missing private ip", s.ID)
+ }
+ if s.SshKeyID == uuid.Nil {
+ return nil, "", fmt.Errorf("server %s missing ssh key relation", s.ID)
+ }
+
+ // de-dupe keys: many servers may share the same ssh key
+ if _, ok := keys[s.SshKeyID]; !ok {
+ priv, err := utils.DecryptForOrg(
+ s.OrganizationID,
+ s.SshKey.EncryptedPrivateKey,
+ s.SshKey.PrivateIV,
+ s.SshKey.PrivateTag,
+ db,
+ )
+ if err != nil {
+ return nil, "", fmt.Errorf("decrypt key for server %s: %w", s.ID, err)
+ }
+
+ fname := fmt.Sprintf("%s.pem", s.SshKeyID.String())
+ keys[s.SshKeyID] = keyPayload{
+ FileName: fname,
+ PrivateKeyB64: base64.StdEncoding.EncodeToString([]byte(priv)),
+ }
+ }
+
+ // ssh config entry per server
+ keyFile := keys[s.SshKeyID].FileName
+
+ hostAlias := s.Hostname
+ if hostAlias == "" {
+ hostAlias = s.ID.String()
+ }
+
+ sb.WriteString(fmt.Sprintf("Host %s\n", hostAlias))
+ sb.WriteString(fmt.Sprintf(" HostName %s\n", s.PrivateIPAddress))
+ sb.WriteString(fmt.Sprintf(" User %s\n", s.SSHUser))
+ sb.WriteString(fmt.Sprintf(" IdentityFile ~/.ssh/autoglue/keys/%s\n", keyFile))
+ sb.WriteString(" IdentitiesOnly yes\n")
+ sb.WriteString(" StrictHostKeyChecking accept-new\n\n")
+ }
+
+ return keys, sb.String(), nil
+}
+
+func pushAssetsToBastion(
+ ctx context.Context,
+ db *gorm.DB,
+ c *models.Cluster,
+ sshConfig string,
+ keyPayloads map[uuid.UUID]keyPayload,
+ payloadJSON []byte,
+) error {
+ bastion := c.BastionServer
+ if bastion == nil {
+ return fmt.Errorf("bastion server is nil")
+ }
+
+ if bastion.PublicIPAddress == nil || strings.TrimSpace(*bastion.PublicIPAddress) == "" {
+ return fmt.Errorf("bastion server missing public ip")
+ }
+
+ privKey, err := utils.DecryptForOrg(
+ bastion.OrganizationID,
+ bastion.SshKey.EncryptedPrivateKey,
+ bastion.SshKey.PrivateIV,
+ bastion.SshKey.PrivateTag,
+ db,
+ )
+ if err != nil {
+ return fmt.Errorf("decrypt bastion key: %w", err)
+ }
+
+ signer, err := ssh.ParsePrivateKey([]byte(privKey))
+ if err != nil {
+ return fmt.Errorf("parse bastion private key: %w", err)
+ }
+
+ hkcb := makeDBHostKeyCallback(db, bastion)
+
+ config := &ssh.ClientConfig{
+ User: bastion.SSHUser,
+ Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
+ HostKeyCallback: hkcb,
+ Timeout: 30 * time.Second,
+ }
+
+ host := net.JoinHostPort(*bastion.PublicIPAddress, "22")
+
+ dialer := &net.Dialer{}
+ conn, err := dialer.DialContext(ctx, "tcp", host)
+ if err != nil {
+ return fmt.Errorf("dial bastion: %w", err)
+ }
+ defer conn.Close()
+
+ cconn, chans, reqs, err := ssh.NewClientConn(conn, host, config)
+ if err != nil {
+ return fmt.Errorf("ssh handshake bastion: %w", err)
+ }
+ client := ssh.NewClient(cconn, chans, reqs)
+ defer client.Close()
+
+ sess, err := client.NewSession()
+ if err != nil {
+ return fmt.Errorf("ssh session: %w", err)
+ }
+ defer sess.Close()
+
+ // build one shot script to:
+ // - mkdir ~/.ssh/autoglue/keys
+ // - write cluster-specific ssh-config
+ // - write all private keys
+ // - write payload.json
+ clusterDir := fmt.Sprintf("$HOME/autoglue/clusters/%s", c.ID.String())
+ configPath := fmt.Sprintf("$HOME/.ssh/autoglue/cluster-%s.config", c.ID.String())
+
+ var script bytes.Buffer
+
+ script.WriteString("set -euo pipefail\n")
+ script.WriteString("mkdir -p \"$HOME/.ssh/autoglue/keys\"\n")
+ script.WriteString("mkdir -p " + clusterDir + "\n")
+ script.WriteString("chmod 700 \"$HOME/.ssh\" || true\n")
+
+ // ssh-config
+ script.WriteString("cat > " + configPath + " <<'EOF_CFG'\n")
+ script.WriteString(sshConfig)
+ script.WriteString("EOF_CFG\n")
+ script.WriteString("chmod 600 " + configPath + "\n")
+
+ // keys
+ for id, kp := range keyPayloads {
+ tag := "KEY_" + id.String()
+ target := fmt.Sprintf("$HOME/.ssh/autoglue/keys/%s", kp.FileName)
+
+ script.WriteString("cat <<'" + tag + "' | base64 -d > " + target + "\n")
+ script.WriteString(kp.PrivateKeyB64 + "\n")
+ script.WriteString(tag + "\n")
+ script.WriteString("chmod 600 " + target + "\n")
+ }
+
+ // payload.json
+ payloadPath := clusterDir + "/payload.json"
+ script.WriteString("cat > " + payloadPath + " <<'EOF_PAYLOAD'\n")
+ script.Write(payloadJSON)
+ script.WriteString("\nEOF_PAYLOAD\n")
+ script.WriteString("chmod 600 " + payloadPath + "\n")
+
+ // If you later want to always include cluster configs automatically, you can
+ // optionally manage ~/.ssh/config here (kept simple for now).
+
+ sess.Stdin = strings.NewReader(script.String())
+ out, runErr := sess.CombinedOutput("bash -s")
+
+ if runErr != nil {
+ return wrapSSHError(runErr, string(out))
+ }
+ return nil
+}
+
+func setClusterStatus(db *gorm.DB, id uuid.UUID, status, lastError string) error {
+ updates := map[string]any{
+ "status": status,
+ "updated_at": time.Now(),
+ }
+ if lastError != "" {
+ updates["last_error"] = lastError
+ }
+ return db.Model(&models.Cluster{}).
+ Where("id = ?", id).
+ Updates(updates).Error
+}
+
+// runMakeOnBastion runs `make ` from the cluster's directory on the bastion.
+func runMakeOnBastion(
+ ctx context.Context,
+ db *gorm.DB,
+ c *models.Cluster,
+ target string,
+) (string, error) {
+ logger := log.With().
+ Str("cluster_id", c.ID.String()).
+ Str("cluster_name", c.Name).
+ Logger()
+
+ bastion := c.BastionServer
+ if bastion == nil {
+ return "", fmt.Errorf("bastion server is nil")
+ }
+
+ if bastion.PublicIPAddress == nil || strings.TrimSpace(*bastion.PublicIPAddress) == "" {
+ return "", fmt.Errorf("bastion server missing public ip")
+ }
+
+ privKey, err := utils.DecryptForOrg(
+ bastion.OrganizationID,
+ bastion.SshKey.EncryptedPrivateKey,
+ bastion.SshKey.PrivateIV,
+ bastion.SshKey.PrivateTag,
+ db,
+ )
+ if err != nil {
+ return "", fmt.Errorf("decrypt bastion key: %w", err)
+ }
+
+ signer, err := ssh.ParsePrivateKey([]byte(privKey))
+ if err != nil {
+ return "", fmt.Errorf("parse bastion private key: %w", err)
+ }
+
+ hkcb := makeDBHostKeyCallback(db, bastion)
+
+ config := &ssh.ClientConfig{
+ User: bastion.SSHUser,
+ Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
+ HostKeyCallback: hkcb,
+ Timeout: 30 * time.Second,
+ }
+
+ host := net.JoinHostPort(*bastion.PublicIPAddress, "22")
+
+ dialer := &net.Dialer{}
+ conn, err := dialer.DialContext(ctx, "tcp", host)
+ if err != nil {
+ return "", fmt.Errorf("dial bastion: %w", err)
+ }
+ defer conn.Close()
+
+ cconn, chans, reqs, err := ssh.NewClientConn(conn, host, config)
+ if err != nil {
+ return "", fmt.Errorf("ssh handshake bastion: %w", err)
+ }
+ client := ssh.NewClient(cconn, chans, reqs)
+ defer client.Close()
+
+ sess, err := client.NewSession()
+ if err != nil {
+ return "", fmt.Errorf("ssh session: %w", err)
+ }
+ defer sess.Close()
+
+ clusterDir := fmt.Sprintf("$HOME/autoglue/clusters/%s", c.ID.String())
+ sshDir := fmt.Sprintf("$HOME/.ssh")
+
+ cmd := fmt.Sprintf("cd %s && docker run -v %s:/root/.ssh -v ./payload.json:/opt/gluekube/platform.json %s:%s make %s", clusterDir, sshDir, c.DockerImage, c.DockerTag, target)
+
+ logger.Info().
+ Str("cmd", cmd).
+ Msg("[runMakeOnBastion] executing remote command")
+
+ out, runErr := sess.CombinedOutput(cmd)
+ if runErr != nil {
+ return string(out), wrapSSHError(runErr, string(out))
+ }
+ return string(out), nil
+}
+
+func randomB64URL(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func findOrCreateClusterAutomationKey(
+ db *gorm.DB,
+ orgID uuid.UUID,
+ clusterID uuid.UUID,
+ ttl time.Duration,
+) (orgKey string, orgSecret string, err error) {
+ now := time.Now()
+ name := fmt.Sprintf("cluster-%s-bastion", clusterID.String())
+
+ // 1) Delete any existing ephemeral cluster-bastion key for this org+cluster
+ if err := db.Where(
+ "org_id = ? AND scope = ? AND purpose = ? AND cluster_id = ? AND is_ephemeral = ?",
+ orgID, "org", "cluster_bastion", clusterID, true,
+ ).Delete(&models.APIKey{}).Error; err != nil {
+ return "", "", fmt.Errorf("delete existing cluster key: %w", err)
+ }
+
+ // 2) Mint a fresh keypair
+ keySuffix, err := randomB64URL(16)
+ if err != nil {
+ return "", "", fmt.Errorf("entropy_error: %w", err)
+ }
+ sec, err := randomB64URL(32)
+ if err != nil {
+ return "", "", fmt.Errorf("entropy_error: %w", err)
+ }
+
+ orgKey = "org_" + keySuffix
+ orgSecret = sec
+
+ keyHash := auth.SHA256Hex(orgKey)
+ secretHash, err := auth.HashSecretArgon2id(orgSecret)
+ if err != nil {
+ return "", "", fmt.Errorf("hash_error: %w", err)
+ }
+
+ exp := now.Add(ttl)
+
+ prefix := orgKey
+ if len(prefix) > 12 {
+ prefix = prefix[:12]
+ }
+
+ rec := models.APIKey{
+ OrgID: &orgID,
+ Scope: "org",
+ Purpose: "cluster_bastion",
+ ClusterID: &clusterID,
+ IsEphemeral: true,
+ Name: name,
+ KeyHash: keyHash,
+ SecretHash: &secretHash,
+ ExpiresAt: &exp,
+ Revoked: false,
+ Prefix: &prefix,
+ }
+
+ if err := db.Create(&rec).Error; err != nil {
+ return "", "", fmt.Errorf("db_error: %w", err)
+ }
+
+ return orgKey, orgSecret, nil
+}
+
+
+
+package bg
+
+import (
+ "context"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/dyaksa/archer/job"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type RefreshTokenRow struct {
+ ID string `gorm:"primaryKey"`
+ RevokedAt *time.Time
+ ExpiresAt time.Time
+ UpdatedAt time.Time
+}
+
+func (RefreshTokenRow) TableName() string { return "refresh_tokens" }
+
+type TokensCleanupArgs struct {
+ // kept in case you want to change retention or add dry-run later
+}
+
+func TokensCleanupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
+ return func(ctx context.Context, j job.Job) (any, error) {
+ if err := CleanupRefreshTokens(db); err != nil {
+ return nil, err
+ }
+
+ // schedule tomorrow 03:45
+ next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 45*time.Minute)
+ _, _ = jobs.Enqueue(
+ ctx,
+ uuid.NewString(),
+ "tokens_cleanup",
+ TokensCleanupArgs{},
+ archer.WithScheduleTime(next),
+ archer.WithMaxRetries(1),
+ )
+ return nil, nil
+ }
+}
+
+func CleanupRefreshTokens(db *gorm.DB) error {
+ now := time.Now()
+ return db.
+ Where("revoked_at IS NOT NULL OR expires_at < ?", now).
+ Delete(&RefreshTokenRow{}).Error
+}
+
+
+
+package common
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type AuditFields struct {
+ ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
+ OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
+ 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()"`
+}
+
+
+
+package config
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/joho/godotenv"
+ "github.com/spf13/viper"
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ DbURL string
+ DbURLRO string
+ Port string
+ Host string
+ JWTIssuer string
+ JWTAudience string
+ JWTPrivateEncKey string
+ OAuthRedirectBase string
+ GoogleClientID string
+ GoogleClientSecret string
+ GithubClientID string
+ GithubClientSecret string
+
+ UIDev bool
+ Env string
+ Debug bool
+ Swagger bool
+ SwaggerHost string
+
+ DBStudioEnabled bool
+ DBStudioBind string
+ DBStudioPort string
+ DBStudioUser string
+ DBStudioPass string
+}
+
+var (
+ once sync.Once
+ cached Config
+ loadErr error
+)
+
+func Load() (Config, error) {
+ once.Do(func() {
+ _ = godotenv.Load()
+
+ // Use a private viper to avoid global mutation/races
+ v := viper.New()
+
+ // Defaults
+ v.SetDefault("bind.address", "127.0.0.1")
+ v.SetDefault("bind.port", "8080")
+ v.SetDefault("database.url", "postgres://user:pass@localhost:5432/db?sslmode=disable")
+ v.SetDefault("database.url_ro", "")
+ v.SetDefault("db_studio.enabled", false)
+ v.SetDefault("db_studio.bind", "127.0.0.1")
+ v.SetDefault("db_studio.port", "0") // 0 = random
+ v.SetDefault("db_studio.user", "")
+ v.SetDefault("db_studio.pass", "")
+
+ v.SetDefault("ui.dev", false)
+ v.SetDefault("env", "development")
+ v.SetDefault("debug", false)
+ v.SetDefault("swagger", false)
+ v.SetDefault("swagger.host", "localhost:8080")
+
+ // Env setup and binding
+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+ v.AutomaticEnv()
+
+ keys := []string{
+ "bind.address",
+ "bind.port",
+ "database.url",
+ "database.url_ro",
+ "jwt.issuer",
+ "jwt.audience",
+ "jwt.private.enc.key",
+ "oauth.redirect.base",
+ "google.client.id",
+ "google.client.secret",
+ "github.client.id",
+ "github.client.secret",
+ "ui.dev",
+ "env",
+ "debug",
+ "swagger",
+ "swagger.host",
+ "db_studio.enabled",
+ "db_studio.bind",
+ "db_studio.port",
+ "db_studio.user",
+ "db_studio.pass",
+ }
+ for _, k := range keys {
+ _ = v.BindEnv(k)
+ }
+
+ // Build config
+ cfg := Config{
+ DbURL: v.GetString("database.url"),
+ DbURLRO: v.GetString("database.url_ro"),
+ Port: v.GetString("bind.port"),
+ Host: v.GetString("bind.address"),
+ JWTIssuer: v.GetString("jwt.issuer"),
+ JWTAudience: v.GetString("jwt.audience"),
+ JWTPrivateEncKey: v.GetString("jwt.private.enc.key"),
+ OAuthRedirectBase: v.GetString("oauth.redirect.base"),
+ GoogleClientID: v.GetString("google.client.id"),
+ GoogleClientSecret: v.GetString("google.client.secret"),
+ GithubClientID: v.GetString("github.client.id"),
+ GithubClientSecret: v.GetString("github.client.secret"),
+
+ UIDev: v.GetBool("ui.dev"),
+ Env: v.GetString("env"),
+ Debug: v.GetBool("debug"),
+ Swagger: v.GetBool("swagger"),
+ SwaggerHost: v.GetString("swagger.host"),
+
+ DBStudioEnabled: v.GetBool("db_studio.enabled"),
+ DBStudioBind: v.GetString("db_studio.bind"),
+ DBStudioPort: v.GetString("db_studio.port"),
+ DBStudioUser: v.GetString("db_studio.user"),
+ DBStudioPass: v.GetString("db_studio.pass"),
+ }
+
+ // Validate
+ if err := validateConfig(cfg); err != nil {
+ loadErr = err
+ return
+ }
+
+ cached = cfg
+ })
+ return cached, loadErr
+}
+
+func validateConfig(cfg Config) error {
+ var errs []string
+
+ // Required general settings
+ req := map[string]string{
+ "jwt.issuer": cfg.JWTIssuer,
+ "jwt.audience": cfg.JWTAudience,
+ "jwt.private.enc.key": cfg.JWTPrivateEncKey,
+ "oauth.redirect.base": cfg.OAuthRedirectBase,
+ }
+ for k, v := range req {
+ if strings.TrimSpace(v) == "" {
+ errs = append(errs, fmt.Sprintf("missing required config key %q (env %s)", k, envNameFromKey(k)))
+ }
+ }
+
+ // OAuth provider requirements:
+ googleOK := strings.TrimSpace(cfg.GoogleClientID) != "" && strings.TrimSpace(cfg.GoogleClientSecret) != ""
+ githubOK := strings.TrimSpace(cfg.GithubClientID) != "" && strings.TrimSpace(cfg.GithubClientSecret) != ""
+
+ // If partially configured, report what's missing for each
+ if !googleOK && (cfg.GoogleClientID != "" || cfg.GoogleClientSecret != "") {
+ if cfg.GoogleClientID == "" {
+ errs = append(errs, fmt.Sprintf("google.client.id is missing (env %s) while google.client.secret is set", envNameFromKey("google.client.id")))
+ }
+ if cfg.GoogleClientSecret == "" {
+ errs = append(errs, fmt.Sprintf("google.client.secret is missing (env %s) while google.client.id is set", envNameFromKey("google.client.secret")))
+ }
+ }
+ if !githubOK && (cfg.GithubClientID != "" || cfg.GithubClientSecret != "") {
+ if cfg.GithubClientID == "" {
+ errs = append(errs, fmt.Sprintf("github.client.id is missing (env %s) while github.client.secret is set", envNameFromKey("github.client.id")))
+ }
+ if cfg.GithubClientSecret == "" {
+ errs = append(errs, fmt.Sprintf("github.client.secret is missing (env %s) while github.client.id is set", envNameFromKey("github.client.secret")))
+ }
+ }
+
+ // Enforce minimum: at least one full provider
+ if !googleOK && !githubOK {
+ errs = append(errs, "at least one OAuth provider must be fully configured: either Google (google.client.id + google.client.secret) or GitHub (github.client.id + github.client.secret)")
+ }
+
+ if len(errs) > 0 {
+ return errors.New(strings.Join(errs, "; "))
+ }
+ return nil
+}
+
+func envNameFromKey(key string) string {
+ return strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
+}
+
+func DebugPrintConfig() {
+ cfg, _ := Load()
+ b, err := yaml.Marshal(cfg)
+ if err != nil {
+ fmt.Println("error marshalling config:", err)
+ return
+ }
+ fmt.Println("Loaded configuration:")
+ fmt.Println(string(b))
+}
+
+func IsUIDev() bool {
+ cfg, _ := Load()
+ return cfg.UIDev
+}
+
+func IsDev() bool {
+ cfg, _ := Load()
+ return strings.EqualFold(cfg.Env, "development")
+}
+
+func IsDebug() bool {
+ cfg, _ := Load()
+ return cfg.Debug
+}
+
+func IsSwaggerEnabled() bool {
+ cfg, _ := Load()
+ return cfg.Swagger
+}
+
+
+
+package db
+
+import (
+ "log"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+func Open(dsn string) *gorm.DB {
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
+ if err != nil {
+ log.Fatalf("failed to connect to db: %v", err)
+ }
+ return db
+}
+
+
+
+package db
+
+import (
+ "fmt"
+
+ "gorm.io/gorm"
+)
+
+func Run(db *gorm.DB, models ...any) error {
+ return db.Transaction(func(tx *gorm.DB) error {
+ // 0) Extensions
+ if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS pgcrypto`).Error; err != nil {
+ return fmt.Errorf("enable pgcrypto: %w", err)
+ }
+ if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS citext`).Error; err != nil {
+ return fmt.Errorf("enable citext: %w", err)
+ }
+
+ // 1) AutoMigrate (pass parents before children in caller)
+ if err := tx.AutoMigrate(models...); err != nil {
+ return fmt.Errorf("automigrate: %w", err)
+ }
+ return nil
+ })
+}
+
+
+
+package dto
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type ActionResponse struct {
+ ID uuid.UUID `json:"id" format:"uuid"`
+ Label string `json:"label"`
+ Description string `json:"description"`
+ MakeTarget string `json:"make_target"`
+ CreatedAt time.Time `json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `json:"updated_at" format:"date-time"`
+}
+
+type CreateActionRequest struct {
+ Label string `json:"label"`
+ Description string `json:"description"`
+ MakeTarget string `json:"make_target"`
+}
+
+type UpdateActionRequest struct {
+ Label *string `json:"label,omitempty"`
+ Description *string `json:"description,omitempty"`
+ MakeTarget *string `json:"make_target,omitempty"`
+}
+
+
+
+package dto
+
+import "github.com/glueops/autoglue/internal/common"
+
+type AnnotationResponse struct {
+ common.AuditFields
+ Key string `json:"key"`
+ Value string `json:"value"`
+}
+
+type CreateAnnotationRequest struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+}
+
+type UpdateAnnotationRequest struct {
+ Key *string `json:"key,omitempty"`
+ Value *string `json:"value,omitempty"`
+}
+
+
+
+package dto
+
+// swagger:model AuthStartResponse
+type AuthStartResponse struct {
+ AuthURL string `json:"auth_url" example:"https://accounts.google.com/o/oauth2/v2/auth?client_id=..."`
+}
+
+// swagger:model TokenPair
+type TokenPair struct {
+ AccessToken string `json:"access_token" example:"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ij..."`
+ RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf...."`
+ TokenType string `json:"token_type" example:"Bearer"`
+ ExpiresIn int64 `json:"expires_in" example:"3600"`
+}
+
+// swagger:model RefreshRequest
+type RefreshRequest struct {
+ RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
+}
+
+// swagger:model LogoutRequest
+type LogoutRequest struct {
+ RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
+}
+
+
+
+package dto
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type ClusterRunResponse struct {
+ ID uuid.UUID `json:"id" format:"uuid"`
+ OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
+ ClusterID uuid.UUID `json:"cluster_id" format:"uuid"`
+ Action string `json:"action"`
+ Status string `json:"status"`
+ Error string `json:"error"`
+ CreatedAt time.Time `json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `json:"updated_at" format:"date-time"`
+ FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
+}
+
+
+
+package dto
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type ClusterResponse struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ CaptainDomain *DomainResponse `json:"captain_domain,omitempty"`
+ ControlPlaneRecordSet *RecordSetResponse `json:"control_plane_record_set,omitempty"`
+ ControlPlaneFQDN *string `json:"control_plane_fqdn,omitempty"`
+ AppsLoadBalancer *LoadBalancerResponse `json:"apps_load_balancer,omitempty"`
+ GlueOpsLoadBalancer *LoadBalancerResponse `json:"glueops_load_balancer,omitempty"`
+ BastionServer *ServerResponse `json:"bastion_server,omitempty"`
+ Provider string `json:"cluster_provider"`
+ Region string `json:"region"`
+ Status string `json:"status"`
+ LastError string `json:"last_error"`
+ RandomToken string `json:"random_token"`
+ CertificateKey string `json:"certificate_key"`
+ NodePools []NodePoolResponse `json:"node_pools,omitempty"`
+ DockerImage string `json:"docker_image"`
+ DockerTag string `json:"docker_tag"`
+ Kubeconfig *string `json:"kubeconfig,omitempty"`
+ OrgKey *string `json:"org_key,omitempty"`
+ OrgSecret *string `json:"org_secret,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type CreateClusterRequest struct {
+ Name string `json:"name"`
+ ClusterProvider string `json:"cluster_provider"`
+ Region string `json:"region"`
+ DockerImage string `json:"docker_image"`
+ DockerTag string `json:"docker_tag"`
+}
+
+type UpdateClusterRequest struct {
+ Name *string `json:"name,omitempty"`
+ ClusterProvider *string `json:"cluster_provider,omitempty"`
+ Region *string `json:"region,omitempty"`
+ DockerImage *string `json:"docker_image,omitempty"`
+ DockerTag *string `json:"docker_tag,omitempty"`
+}
+
+type AttachCaptainDomainRequest struct {
+ DomainID uuid.UUID `json:"domain_id"`
+}
+
+type AttachRecordSetRequest struct {
+ RecordSetID uuid.UUID `json:"record_set_id"`
+}
+
+type AttachLoadBalancerRequest struct {
+ LoadBalancerID uuid.UUID `json:"load_balancer_id"`
+}
+
+type AttachBastionRequest struct {
+ ServerID uuid.UUID `json:"server_id"`
+}
+
+type SetKubeconfigRequest struct {
+ Kubeconfig string `json:"kubeconfig"`
+}
+
+type AttachNodePoolRequest struct {
+ NodePoolID uuid.UUID `json:"node_pool_id"`
+}
+
+
+
+package dto
+
+import (
+ "encoding/json"
+
+ "github.com/go-playground/validator/v10"
+)
+
+// RawJSON is a swagger-friendly wrapper for json.RawMessage.
+type RawJSON = json.RawMessage
+
+var Validate = validator.New()
+
+func init() {
+ _ = Validate.RegisterValidation("awsarn", func(fl validator.FieldLevel) bool {
+ v := fl.Field().String()
+ return len(v) > 10 && len(v) < 2048 && len(v) >= 4 && v[:4] == "arn:"
+ })
+}
+
+/*** Shapes for secrets ***/
+
+type AWSCredential struct {
+ AccessKeyID string `json:"access_key_id" validate:"required,alphanum,len=20"`
+ SecretAccessKey string `json:"secret_access_key" validate:"required"`
+ Region string `json:"region" validate:"omitempty"`
+}
+
+type BasicAuth struct {
+ Username string `json:"username" validate:"required"`
+ Password string `json:"password" validate:"required"`
+}
+
+type APIToken struct {
+ Token string `json:"token" validate:"required"`
+}
+
+type OAuth2Credential struct {
+ ClientID string `json:"client_id" validate:"required"`
+ ClientSecret string `json:"client_secret" validate:"required"`
+ RefreshToken string `json:"refresh_token" validate:"required"`
+}
+
+/*** Shapes for scopes ***/
+
+type AWSProviderScope struct{}
+
+type AWSServiceScope struct {
+ Service string `json:"service" validate:"required,oneof=route53 s3 ec2 iam rds dynamodb"`
+}
+
+type AWSResourceScope struct {
+ ARN string `json:"arn" validate:"required,awsarn"`
+}
+
+/*** Registries ***/
+
+type ProviderDef struct {
+ New func() any
+ Validate func(any) error
+}
+
+type ScopeDef struct {
+ New func() any
+ Validate func(any) error
+ Specificity int // 0=provider, 1=service, 2=resource
+}
+
+// Secret shapes per provider/kind/version
+
+var CredentialRegistry = map[string]map[string]map[int]ProviderDef{
+ "aws": {
+ "aws_access_key": {
+ 1: {New: func() any { return &AWSCredential{} }, Validate: func(x any) error { return Validate.Struct(x) }},
+ },
+ },
+ "cloudflare": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
+ "hetzner": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
+ "digitalocean": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
+ "generic": {
+ "basic_auth": {1: {New: func() any { return &BasicAuth{} }, Validate: func(x any) error { return Validate.Struct(x) }}},
+ "oauth2": {1: {New: func() any { return &OAuth2Credential{} }, Validate: func(x any) error { return Validate.Struct(x) }}},
+ },
+}
+
+// Scope shapes per provider/scopeKind/version
+
+var ScopeRegistry = map[string]map[string]map[int]ScopeDef{
+ "aws": {
+ "provider": {1: {New: func() any { return &AWSProviderScope{} }, Validate: func(any) error { return nil }, Specificity: 0}},
+ "service": {1: {New: func() any { return &AWSServiceScope{} }, Validate: func(x any) error { return Validate.Struct(x) }, Specificity: 1}},
+ "resource": {1: {New: func() any { return &AWSResourceScope{} }, Validate: func(x any) error { return Validate.Struct(x) }, Specificity: 2}},
+ },
+}
+
+/*** API DTOs used by swagger ***/
+
+// CreateCredentialRequest represents the POST /credentials payload
+type CreateCredentialRequest struct {
+ CredentialProvider string `json:"credential_provider" validate:"required,oneof=aws cloudflare hetzner digitalocean generic"`
+ Kind string `json:"kind" validate:"required"` // aws_access_key, api_token, basic_auth, oauth2
+ SchemaVersion int `json:"schema_version" validate:"required,gte=1"` // secret schema version
+ Name string `json:"name" validate:"omitempty,max=100"` // human label
+ ScopeKind string `json:"scope_kind" validate:"required,oneof=credential_provider service resource"`
+ ScopeVersion int `json:"scope_version" validate:"required,gte=1"` // scope schema version
+ Scope RawJSON `json:"scope" validate:"required" swaggertype:"object"` // {"service":"route53"} or {"arn":"..."}
+ AccountID string `json:"account_id,omitempty" validate:"omitempty,max=32"`
+ Region string `json:"region,omitempty" validate:"omitempty,max=32"`
+ Secret RawJSON `json:"secret" validate:"required" swaggertype:"object"` // encrypted later
+}
+
+// UpdateCredentialRequest represents PATCH /credentials/{id}
+type UpdateCredentialRequest struct {
+ Name *string `json:"name,omitempty"`
+ AccountID *string `json:"account_id,omitempty"`
+ Region *string `json:"region,omitempty"`
+ ScopeKind *string `json:"scope_kind,omitempty"`
+ ScopeVersion *int `json:"scope_version,omitempty"`
+ Scope *RawJSON `json:"scope,omitempty" swaggertype:"object"`
+ Secret *RawJSON `json:"secret,omitempty" swaggertype:"object"` // set if rotating
+
+}
+
+// CredentialOut is what we return (no secrets)
+type CredentialOut struct {
+ ID string `json:"id"`
+ CredentialProvider string `json:"credential_provider"`
+ Kind string `json:"kind"`
+ SchemaVersion int `json:"schema_version"`
+ Name string `json:"name"`
+ ScopeKind string `json:"scope_kind"`
+ ScopeVersion int `json:"scope_version"`
+ Scope RawJSON `json:"scope" swaggertype:"object"`
+ AccountID string `json:"account_id,omitempty"`
+ Region string `json:"region,omitempty"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+
+
+package dto
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/go-playground/validator/v10"
+)
+
+var dnsValidate = validator.New()
+
+func init() {
+ _ = dnsValidate.RegisterValidation("fqdn", func(fl validator.FieldLevel) bool {
+ s := strings.TrimSpace(fl.Field().String())
+ if s == "" || len(s) > 253 {
+ return false
+ }
+ // Minimal: lower-cased, no trailing dot in our API (normalize server-side)
+ // You can add stricter checks later.
+ return !strings.HasPrefix(s, ".") && !strings.Contains(s, "..")
+ })
+ _ = dnsValidate.RegisterValidation("rrtype", func(fl validator.FieldLevel) bool {
+ switch strings.ToUpper(fl.Field().String()) {
+ case "A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA":
+ return true
+ default:
+ return false
+ }
+ })
+}
+
+// ---- Domains ----
+
+type CreateDomainRequest struct {
+ DomainName string `json:"domain_name" validate:"required,fqdn"`
+ CredentialID string `json:"credential_id" validate:"required,uuid4"`
+ ZoneID string `json:"zone_id,omitempty" validate:"omitempty,max=128"`
+}
+
+type UpdateDomainRequest struct {
+ CredentialID *string `json:"credential_id,omitempty" validate:"omitempty,uuid4"`
+ ZoneID *string `json:"zone_id,omitempty" validate:"omitempty,max=128"`
+ Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending provisioning ready failed"`
+ DomainName *string `json:"domain_name,omitempty" validate:"omitempty,fqdn"`
+}
+
+type DomainResponse struct {
+ ID string `json:"id"`
+ OrganizationID string `json:"organization_id"`
+ DomainName string `json:"domain_name"`
+ ZoneID string `json:"zone_id"`
+ Status string `json:"status"`
+ LastError string `json:"last_error"`
+ CredentialID string `json:"credential_id"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// ---- Record Sets ----
+
+type AliasTarget struct {
+ HostedZoneID string `json:"hosted_zone_id" validate:"required"`
+ DNSName string `json:"dns_name" validate:"required"`
+ EvaluateTargetHealth bool `json:"evaluate_target_health"`
+}
+
+type CreateRecordSetRequest struct {
+ // Name relative to domain ("endpoint") OR FQDN ("endpoint.example.com").
+ // Server normalizes to relative.
+ Name string `json:"name" validate:"required,max=253"`
+ Type string `json:"type" validate:"required,rrtype"`
+ TTL *int `json:"ttl,omitempty" validate:"omitempty,gte=1,lte=86400"`
+ Values []string `json:"values" validate:"omitempty,dive,min=1,max=1024"`
+}
+
+type UpdateRecordSetRequest struct {
+ // Any change flips status back to pending (worker will UPSERT)
+ Name *string `json:"name,omitempty" validate:"omitempty,max=253"`
+ Type *string `json:"type,omitempty" validate:"omitempty,rrtype"`
+ TTL *int `json:"ttl,omitempty" validate:"omitempty,gte=1,lte=86400"`
+ Values *[]string `json:"values,omitempty" validate:"omitempty,dive,min=1,max=1024"`
+ Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending provisioning ready failed"`
+}
+
+type RecordSetResponse struct {
+ ID string `json:"id"`
+ DomainID string `json:"domain_id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ TTL *int `json:"ttl,omitempty"`
+ Values json.RawMessage `json:"values" swaggertype:"object"` // []string JSON
+ Fingerprint string `json:"fingerprint"`
+ Status string `json:"status"`
+ LastError string `json:"last_error"`
+ Owner string `json:"owner"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// DNSValidate Quick helper to validate DTOs in handlers
+func DNSValidate(i any) error {
+ return dnsValidate.Struct(i)
+}
+
+
+
+package dto
+
+import (
+ "encoding/json"
+ "time"
+)
+
+type JobStatus string
+
+const (
+ StatusQueued JobStatus = "queued"
+ StatusRunning JobStatus = "running"
+ StatusSucceeded JobStatus = "succeeded"
+ StatusFailed JobStatus = "failed"
+ StatusCanceled JobStatus = "canceled"
+ StatusRetrying JobStatus = "retrying"
+ StatusScheduled JobStatus = "scheduled"
+)
+
+// Job represents a background job managed by Archer.
+// swagger:model Job
+type Job struct {
+ ID string `json:"id" example:"01HF7SZK8Z8WG1M3J7S2Z8M2N6"`
+ Type string `json:"type" example:"email.send"`
+ Queue string `json:"queue" example:"default"`
+ Status JobStatus `json:"status" example:"queued" enums:"queued|running|succeeded|failed|canceled|retrying|scheduled"`
+ Attempts int `json:"attempts" example:"0"`
+ MaxAttempts int `json:"max_attempts,omitempty" example:"3"`
+ CreatedAt time.Time `json:"created_at" example:"2025-11-04T09:30:00Z"`
+ UpdatedAt *time.Time `json:"updated_at,omitempty" example:"2025-11-04T09:30:00Z"`
+ LastError *string `json:"last_error,omitempty" example:"error message"`
+ RunAt *time.Time `json:"run_at,omitempty" example:"2025-11-04T09:30:00Z"`
+ Payload any `json:"payload,omitempty"`
+}
+
+// QueueInfo holds queue-level counts.
+// swagger:model QueueInfo
+type QueueInfo struct {
+ Name string `json:"name" example:"default"`
+ Pending int `json:"pending" example:"42"`
+ Running int `json:"running" example:"3"`
+ Failed int `json:"failed" example:"5"`
+ Scheduled int `json:"scheduled" example:"7"`
+}
+
+// PageJob is a concrete paginated response for Job (generics not supported by swag).
+// swagger:model PageJob
+type PageJob struct {
+ Items []Job `json:"items"`
+ Total int `json:"total" example:"120"`
+ Page int `json:"page" example:"1"`
+ PageSize int `json:"page_size" example:"25"`
+}
+
+// EnqueueRequest is the POST body for creating a job.
+// swagger:model EnqueueRequest
+type EnqueueRequest struct {
+ Queue string `json:"queue" example:"default"`
+ Type string `json:"type" example:"email.send"`
+ Payload json.RawMessage `json:"payload" swaggertype:"object"`
+ RunAt *time.Time `json:"run_at" example:"2025-11-05T08:00:00Z"`
+}
+
+
+
+package dto
+
+// JWK represents a single JSON Web Key (public only).
+// swagger:model JWK
+type JWK struct {
+ Kty string `json:"kty" example:"RSA" gorm:"-"`
+ Use string `json:"use,omitempty" example:"sig" gorm:"-"`
+ Kid string `json:"kid,omitempty" example:"7c6f1d0a-7a98-4e6a-9dbf-6b1af4b9f345" gorm:"-"`
+ Alg string `json:"alg,omitempty" example:"RS256" gorm:"-"`
+ N string `json:"n,omitempty" gorm:"-"`
+ E string `json:"e,omitempty" example:"AQAB" gorm:"-"`
+ X string `json:"x,omitempty" gorm:"-"`
+}
+
+// JWKS is a JSON Web Key Set container.
+// swagger:model JWKS
+type JWKS struct {
+ Keys []JWK `json:"keys" gorm:"-"`
+}
+
+
+
+package dto
+
+import (
+ "github.com/glueops/autoglue/internal/common"
+)
+
+type LabelResponse struct {
+ common.AuditFields
+ Key string `json:"key"`
+ Value string `json:"value"`
+}
+
+type CreateLabelRequest struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+}
+
+type UpdateLabelRequest struct {
+ Key *string `json:"key"`
+ Value *string `json:"value"`
+}
+
+
+
+package dto
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type LoadBalancerResponse struct {
+ ID uuid.UUID `json:"id"`
+ OrganizationID uuid.UUID `json:"organization_id"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ PublicIPAddress string `json:"public_ip_address"`
+ PrivateIPAddress string `json:"private_ip_address"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type CreateLoadBalancerRequest struct {
+ Name string `json:"name" example:"glueops"`
+ Kind string `json:"kind" example:"public" enums:"glueops,public"`
+ PublicIPAddress string `json:"public_ip_address" example:"8.8.8.8"`
+ PrivateIPAddress string `json:"private_ip_address" example:"192.168.0.2"`
+}
+
+type UpdateLoadBalancerRequest struct {
+ Name *string `json:"name" example:"glue"`
+ Kind *string `json:"kind" example:"public" enums:"glueops,public"`
+ PublicIPAddress *string `json:"public_ip_address" example:"8.8.8.8"`
+ PrivateIPAddress *string `json:"private_ip_address" example:"192.168.0.2"`
+}
+
+
+
+package dto
+
+import "github.com/glueops/autoglue/internal/common"
+
+type NodeRole string
+
+const (
+ NodeRoleMaster NodeRole = "master"
+ NodeRoleWorker NodeRole = "worker"
+)
+
+type CreateNodePoolRequest struct {
+ Name string `json:"name"`
+ Role NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
+}
+
+type UpdateNodePoolRequest struct {
+ Name *string `json:"name"`
+ Role *NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
+}
+
+type NodePoolResponse struct {
+ common.AuditFields
+ Name string `json:"name"`
+ Role NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
+ Servers []ServerResponse `json:"servers"`
+ Annotations []AnnotationResponse `json:"annotations"`
+ Labels []LabelResponse `json:"labels"`
+ Taints []TaintResponse `json:"taints"`
+}
+
+type AttachServersRequest struct {
+ ServerIDs []string `json:"server_ids"`
+}
+
+type AttachTaintsRequest struct {
+ TaintIDs []string `json:"taint_ids"`
+}
+
+type AttachLabelsRequest struct {
+ LabelIDs []string `json:"label_ids"`
+}
+
+type AttachAnnotationsRequest struct {
+ AnnotationIDs []string `json:"annotation_ids"`
+}
+
+
+
+package dto
+
+import "github.com/google/uuid"
+
+type CreateServerRequest struct {
+ Hostname string `json:"hostname,omitempty"`
+ PublicIPAddress string `json:"public_ip_address,omitempty"`
+ PrivateIPAddress string `json:"private_ip_address"`
+ SSHUser string `json:"ssh_user"`
+ SshKeyID string `json:"ssh_key_id"`
+ Role string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
+ Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
+}
+
+type UpdateServerRequest struct {
+ Hostname *string `json:"hostname,omitempty"`
+ PublicIPAddress *string `json:"public_ip_address,omitempty"`
+ PrivateIPAddress *string `json:"private_ip_address,omitempty"`
+ SSHUser *string `json:"ssh_user,omitempty"`
+ SshKeyID *string `json:"ssh_key_id,omitempty"`
+ Role *string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
+ Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
+}
+
+type ServerResponse struct {
+ ID uuid.UUID `json:"id"`
+ OrganizationID uuid.UUID `json:"organization_id"`
+ Hostname string `json:"hostname"`
+ PublicIPAddress *string `json:"public_ip_address,omitempty"`
+ PrivateIPAddress string `json:"private_ip_address"`
+ SSHUser string `json:"ssh_user"`
+ SshKeyID uuid.UUID `json:"ssh_key_id"`
+ Role string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
+ Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
+ CreatedAt string `json:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+}
+
+
+
+package dto
+
+import (
+ "github.com/glueops/autoglue/internal/common"
+)
+
+type CreateSSHRequest struct {
+ Name string `json:"name"`
+ Comment string `json:"comment,omitempty" example:"deploy@autoglue"`
+ Bits *int `json:"bits,omitempty"` // Only for RSA
+ Type *string `json:"type,omitempty"` // "rsa" (default) or "ed25519"
+}
+
+type SshResponse struct {
+ common.AuditFields
+ Name string `json:"name"`
+ PublicKey string `json:"public_key"`
+ Fingerprint string `json:"fingerprint"`
+ EncryptedPrivateKey string `json:"-"`
+ PrivateIV string `json:"-"`
+ PrivateTag string `json:"-"`
+}
+
+type SshRevealResponse struct {
+ SshResponse
+ PrivateKey string `json:"private_key"`
+}
+
+type SshMaterialJSON struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Fingerprint string `json:"fingerprint"`
+ // Exactly one of the following will be populated for part=public/private.
+ PublicKey *string `json:"public_key,omitempty"` // OpenSSH authorized_key (string)
+ PrivatePEM *string `json:"private_pem,omitempty"` // PKCS#1/PEM (string)
+ // For part=both with mode=json we'll return a base64 zip
+ ZipBase64 *string `json:"zip_base64,omitempty"` // base64-encoded zip
+ // Suggested filenames (SDKs can save to disk without inferring names)
+ Filenames []string `json:"filenames"`
+}
+
+
+
+package dto
+
+import "github.com/google/uuid"
+
+type TaintResponse struct {
+ ID uuid.UUID `json:"id"`
+ OrganizationID uuid.UUID `json:"organization_id"`
+ Key string `json:"key"`
+ Value string `json:"value"`
+ Effect string `json:"effect"`
+ CreatedAt string `json:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+}
+
+type CreateTaintRequest struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+ Effect string `json:"effect"`
+}
+
+type UpdateTaintRequest struct {
+ Key *string `json:"key,omitempty"`
+ Value *string `json:"value,omitempty"`
+ Effect *string `json:"effect,omitempty"`
+}
+
+
+
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ListActions godoc
+//
+// @ID ListActions
+// @Summary List available actions
+// @Description Returns all admin-configured actions.
+// @Tags Actions
+// @Produce json
+// @Success 200 {array} dto.ActionResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 500 {string} string "db error"
+// @Router /admin/actions [get]
+// @Security BearerAuth
+func ListActions(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var rows []models.Action
+ if err := db.Order("label ASC").Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := make([]dto.ActionResponse, 0, len(rows))
+ for _, a := range rows {
+ out = append(out, actionToDTO(a))
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetAction godoc
+//
+// @ID GetAction
+// @Summary Get a single action by ID
+// @Description Returns a single action.
+// @Tags Actions
+// @Produce json
+// @Param actionID path string true "Action ID"
+// @Success 200 {object} dto.ActionResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "db error"
+// @Router /admin/actions/{actionID} [get]
+// @Security BearerAuth
+func GetAction(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
+ return
+ }
+
+ var row models.Action
+ if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
+ }
+}
+
+// CreateAction godoc
+//
+// @ID CreateAction
+// @Summary Create an action
+// @Description Creates a new admin-configured action.
+// @Tags Actions
+// @Accept json
+// @Produce json
+// @Param body body dto.CreateActionRequest true "payload"
+// @Success 201 {object} dto.ActionResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 500 {string} string "db error"
+// @Router /admin/actions [post]
+// @Security BearerAuth
+func CreateAction(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var in dto.CreateActionRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ label := strings.TrimSpace(in.Label)
+ desc := strings.TrimSpace(in.Description)
+ target := strings.TrimSpace(in.MakeTarget)
+
+ if label == "" {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "label is required")
+ return
+ }
+ if desc == "" {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "description is required")
+ return
+ }
+ if target == "" {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target is required")
+ return
+ }
+
+ row := models.Action{
+ Label: label,
+ Description: desc,
+ MakeTarget: target,
+ }
+
+ if err := db.Create(&row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ utils.WriteJSON(w, http.StatusCreated, actionToDTO(row))
+ }
+}
+
+// UpdateAction godoc
+//
+// @ID UpdateAction
+// @Summary Update an action
+// @Description Updates an action. Only provided fields are modified.
+// @Tags Actions
+// @Accept json
+// @Produce json
+// @Param actionID path string true "Action ID"
+// @Param body body dto.UpdateActionRequest true "payload"
+// @Success 200 {object} dto.ActionResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "db error"
+// @Router /admin/actions/{actionID} [patch]
+// @Security BearerAuth
+func UpdateAction(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
+ return
+ }
+
+ var in dto.UpdateActionRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ var row models.Action
+ if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if in.Label != nil {
+ v := strings.TrimSpace(*in.Label)
+ if v == "" {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "label cannot be empty")
+ return
+ }
+ row.Label = v
+ }
+ if in.Description != nil {
+ v := strings.TrimSpace(*in.Description)
+ if v == "" {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "description cannot be empty")
+ return
+ }
+ row.Description = v
+ }
+ if in.MakeTarget != nil {
+ v := strings.TrimSpace(*in.MakeTarget)
+ if v == "" {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target cannot be empty")
+ return
+ }
+ row.MakeTarget = v
+ }
+
+ if err := db.Save(&row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
+ }
+}
+
+// DeleteAction godoc
+//
+// @ID DeleteAction
+// @Summary Delete an action
+// @Description Deletes an action.
+// @Tags Actions
+// @Produce json
+// @Param actionID path string true "Action ID"
+// @Success 204 {string} string "deleted"
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "db error"
+// @Router /admin/actions/{actionID} [delete]
+// @Security BearerAuth
+func DeleteAction(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
+ return
+ }
+
+ tx := db.Where("id = ?", actionID).Delete(&models.Action{})
+ if tx.Error != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ if tx.RowsAffected == 0 {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func actionToDTO(a models.Action) dto.ActionResponse {
+ return dto.ActionResponse{
+ ID: a.ID,
+ Label: a.Label,
+ Description: a.Description,
+ MakeTarget: a.MakeTarget,
+ CreatedAt: a.CreatedAt,
+ UpdatedAt: a.UpdatedAt,
+ }
+}
+
+
+
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/common"
+ "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"
+)
+
+// ListAnnotations godoc
+//
+// @ID ListAnnotations
+// @Summary List annotations (org scoped)
+// @Description Returns annotations for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
+// @Tags Annotations
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param key query string false "Exact key"
+// @Param value query string false "Exact value"
+// @Param q query string false "key contains (case-insensitive)"
+// @Success 200 {array} dto.AnnotationResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list annotations"
+// @Router /annotations [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListAnnotations(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ q := db.Where("organization_id = ?", orgID)
+
+ if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
+ q = q.Where(`key = ?`, key)
+ }
+ if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
+ q = q.Where(`value = ?`, val)
+ }
+ if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
+ q = q.Where(`key ILIKE ?`, "%"+needle+"%")
+ }
+
+ var out []dto.AnnotationResponse
+ if err := q.Model(&models.Annotation{}).Order("created_at DESC").Scan(&out).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if out == nil {
+ out = []dto.AnnotationResponse{}
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetAnnotation godoc
+//
+// @ID GetAnnotation
+// @Summary Get annotation by ID (org scoped)
+// @Description Returns one annotation. Add `include=node_pools` to include node pools.
+// @Tags Annotations
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Annotation ID (UUID)"
+// @Success 200 {object} dto.AnnotationResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /annotations/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetAnnotation(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_request", "bad request")
+ return
+ }
+
+ var out dto.AnnotationResponse
+ if err := db.Model(&models.Annotation{}).Where("id = ? AND organization_id = ?", id, orgID).First(&out).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// CreateAnnotation godoc
+//
+// @ID CreateAnnotation
+// @Summary Create annotation (org scoped)
+// @Description Creates an annotation.
+// @Tags Annotations
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateAnnotationRequest true "Annotation payload"
+// @Success 201 {object} dto.AnnotationResponse
+// @Failure 400 {string} string "invalid json / missing fields"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "create failed"
+// @Router /annotations [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateAnnotation(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 req dto.CreateAnnotationRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ req.Key = strings.TrimSpace(req.Key)
+ req.Value = strings.TrimSpace(req.Value)
+
+ if req.Key == "" || req.Value == "" {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value")
+ return
+ }
+
+ a := models.Annotation{
+ AuditFields: common.AuditFields{OrganizationID: orgID},
+ Key: req.Key,
+ Value: req.Value,
+ }
+
+ if err := db.Create(&a).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := dto.AnnotationResponse{
+ AuditFields: a.AuditFields,
+ Key: a.Key,
+ Value: a.Value,
+ }
+ utils.WriteJSON(w, http.StatusCreated, out)
+ }
+}
+
+// UpdateAnnotation godoc
+//
+// @ID UpdateAnnotation
+// @Summary Update annotation (org scoped)
+// @Description Partially update annotation fields.
+// @Tags Annotations
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Annotation ID (UUID)"
+// @Param body body dto.UpdateAnnotationRequest true "Fields to update"
+// @Success 200 {object} dto.AnnotationResponse
+// @Failure 400 {string} string "invalid id / invalid json"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "update failed"
+// @Router /annotations/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateAnnotation(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_request", "bad request")
+ return
+ }
+
+ var a models.Annotation
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&a).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var req dto.UpdateAnnotationRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ if req.Key != nil {
+ a.Key = strings.TrimSpace(*req.Key)
+ }
+ if req.Value != nil {
+ a.Value = strings.TrimSpace(*req.Value)
+ }
+
+ if err := db.Save(&a).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := dto.AnnotationResponse{
+ AuditFields: a.AuditFields,
+ Key: a.Key,
+ Value: a.Value,
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// DeleteAnnotation godoc
+//
+// @ID DeleteAnnotation
+// @Summary Delete annotation (org scoped)
+// @Description Permanently deletes the annotation.
+// @Tags Annotations
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Annotation ID (UUID)"
+// @Success 204 "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "delete failed"
+// @Router /annotations/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteAnnotation(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_request", "bad request")
+ return
+ }
+
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Annotation{}).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+
+
+package handlers
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "html/template"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/glueops/autoglue/internal/auth"
+ "github.com/glueops/autoglue/internal/config"
+ "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"
+ "golang.org/x/oauth2"
+ "gorm.io/gorm"
+)
+
+type oauthProvider struct {
+ Name string
+ Issuer string
+ Scopes []string
+ ClientID string
+ Secret string
+}
+
+func providerConfig(cfg config.Config, name string) (oauthProvider, bool) {
+ switch strings.ToLower(name) {
+ case "google":
+ return oauthProvider{
+ Name: "google",
+ Issuer: "https://accounts.google.com",
+ Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
+ ClientID: cfg.GoogleClientID,
+ Secret: cfg.GoogleClientSecret,
+ }, true
+ case "github":
+ // GitHub is not a pure OIDC provider; we use OAuth2 + user email API
+ return oauthProvider{
+ Name: "github",
+ Issuer: "github",
+ Scopes: []string{"read:user", "user:email"},
+ ClientID: cfg.GithubClientID, Secret: cfg.GithubClientSecret,
+ }, true
+ }
+ return oauthProvider{}, false
+}
+
+// AuthStart godoc
+//
+// @ID AuthStart
+// @Summary Begin social login
+// @Description Returns provider authorization URL for the frontend to redirect
+// @Tags Auth
+// @Param provider path string true "google|github"
+// @Produce json
+// @Success 200 {object} dto.AuthStartResponse
+// @Router /auth/{provider}/start [post]
+func AuthStart(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ cfg, _ := config.Load()
+ provider := strings.ToLower(chi.URLParam(r, "provider"))
+
+ p, ok := providerConfig(cfg, provider)
+ if !ok || p.ClientID == "" || p.Secret == "" {
+ utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
+ return
+ }
+
+ redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
+
+ // Optional SPA hints to be embedded into state
+ mode := r.URL.Query().Get("mode") // "spa" enables postMessage callback page
+ origin := r.URL.Query().Get("origin") // e.g. http://localhost:5173
+
+ state := uuid.NewString()
+ if mode == "spa" && origin != "" {
+ state = state + "|mode=spa|origin=" + url.QueryEscape(origin)
+ }
+
+ var authURL string
+
+ if p.Issuer == "github" {
+ o := &oauth2.Config{
+ ClientID: p.ClientID,
+ ClientSecret: p.Secret,
+ RedirectURL: redirect,
+ Scopes: p.Scopes,
+ Endpoint: oauth2.Endpoint{
+ AuthURL: "https://github.com/login/oauth/authorize",
+ TokenURL: "https://github.com/login/oauth/access_token",
+ },
+ }
+ authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
+ } else {
+ // Google OIDC
+ ctx := context.Background()
+ prov, err := oidc.NewProvider(ctx, p.Issuer)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "oidc_discovery_failed", err.Error())
+ return
+ }
+ o := &oauth2.Config{
+ ClientID: p.ClientID,
+ ClientSecret: p.Secret,
+ RedirectURL: redirect,
+ Endpoint: prov.Endpoint(),
+ Scopes: p.Scopes,
+ }
+ authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
+ }
+
+ utils.WriteJSON(w, http.StatusOK, dto.AuthStartResponse{AuthURL: authURL})
+ }
+}
+
+// AuthCallback godoc
+//
+// @ID AuthCallback
+// @Summary Handle social login callback
+// @Tags Auth
+// @Param provider path string true "google|github"
+// @Produce json
+// @Success 200 {object} dto.TokenPair
+// @Router /auth/{provider}/callback [get]
+func AuthCallback(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ cfg, _ := config.Load()
+ provider := strings.ToLower(chi.URLParam(r, "provider"))
+
+ p, ok := providerConfig(cfg, provider)
+ if !ok {
+ utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
+ return
+ }
+
+ code := r.URL.Query().Get("code")
+ if code == "" {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_request", "missing code")
+ return
+ }
+ redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
+
+ var email, display, subject string
+
+ if p.Issuer == "github" {
+ // OAuth2 code exchange
+ o := &oauth2.Config{
+ ClientID: p.ClientID,
+ ClientSecret: p.Secret,
+ RedirectURL: redirect,
+ Scopes: p.Scopes,
+ Endpoint: oauth2.Endpoint{
+ AuthURL: "https://github.com/login/oauth/authorize",
+ TokenURL: "https://github.com/login/oauth/access_token",
+ },
+ }
+ tok, err := o.Exchange(r.Context(), code)
+ if err != nil {
+ utils.WriteError(w, http.StatusUnauthorized, "exchange_failed", err.Error())
+ return
+ }
+ // Fetch user primary email
+ req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
+ req.Header.Set("Authorization", "token "+tok.AccessToken)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil || resp.StatusCode != 200 {
+ utils.WriteError(w, http.StatusUnauthorized, "email_fetch_failed", "github user/emails")
+ return
+ }
+ defer resp.Body.Close()
+ var emails []struct {
+ Email string `json:"email"`
+ Primary bool `json:"primary"`
+ Verified bool `json:"verified"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil || len(emails) == 0 {
+ utils.WriteError(w, http.StatusUnauthorized, "email_parse_failed", err.Error())
+ return
+ }
+ email = emails[0].Email
+ for _, e := range emails {
+ if e.Primary {
+ email = e.Email
+ break
+ }
+ }
+ subject = "github:" + email
+ display = strings.Split(email, "@")[0]
+ } else {
+ // Google OIDC
+ oidcProv, err := oidc.NewProvider(r.Context(), p.Issuer)
+ if err != nil {
+ utils.WriteError(w, 500, "oidc_discovery_failed", err.Error())
+ return
+ }
+ o := &oauth2.Config{
+ ClientID: p.ClientID,
+ ClientSecret: p.Secret,
+ RedirectURL: redirect,
+ Endpoint: oidcProv.Endpoint(),
+ Scopes: p.Scopes,
+ }
+ tok, err := o.Exchange(r.Context(), code)
+ if err != nil {
+ utils.WriteError(w, 401, "exchange_failed", err.Error())
+ return
+ }
+
+ verifier := oidcProv.Verifier(&oidc.Config{ClientID: p.ClientID})
+ rawIDToken, ok := tok.Extra("id_token").(string)
+ if !ok {
+ utils.WriteError(w, 401, "no_id_token", "")
+ return
+ }
+ idt, err := verifier.Verify(r.Context(), rawIDToken)
+ if err != nil {
+ utils.WriteError(w, 401, "id_token_invalid", err.Error())
+ return
+ }
+
+ var claims struct {
+ Email string `json:"email"`
+ EmailVerified bool `json:"email_verified"`
+ Name string `json:"name"`
+ Sub string `json:"sub"`
+ }
+ if err := idt.Claims(&claims); err != nil {
+ utils.WriteError(w, 401, "claims_parse_error", err.Error())
+ return
+ }
+ email = strings.ToLower(claims.Email)
+ display = claims.Name
+ subject = "google:" + claims.Sub
+ }
+
+ // Upsert Account + User; domain auto-join (member)
+ user, err := upsertAccountAndUser(db, p.Name, subject, email, display)
+ if err != nil {
+ utils.WriteError(w, 500, "account_upsert_failed", err.Error())
+ return
+ }
+
+ // Org auto-join: Organization.Domain == email domain
+ _ = ensureAutoMembership(db, user.ID, email)
+
+ // Issue tokens
+ accessTTL := 1 * time.Hour
+ refreshTTL := 30 * 24 * time.Hour
+
+ cfgLoaded, _ := config.Load()
+ access, err := auth.IssueAccessToken(auth.IssueOpts{
+ Subject: user.ID.String(),
+ Issuer: cfgLoaded.JWTIssuer,
+ Audience: cfgLoaded.JWTAudience,
+ TTL: accessTTL,
+ Claims: map[string]any{
+ "email": email,
+ "name": display,
+ },
+ })
+ if err != nil {
+ utils.WriteError(w, 500, "issue_access_failed", err.Error())
+ return
+ }
+
+ rp, err := auth.IssueRefreshToken(db, user.ID, refreshTTL, nil)
+ if err != nil {
+ utils.WriteError(w, 500, "issue_refresh_failed", err.Error())
+ return
+ }
+
+ secure := true
+ if u, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(u) {
+ secure = false
+ }
+ if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
+ secure = strings.EqualFold(xf, "https")
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "ag_jwt",
+ Value: "Bearer " + access,
+ Path: "/",
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: secure,
+ MaxAge: int((time.Hour * 8).Seconds()),
+ })
+
+ // If the state indicates SPA popup mode, postMessage tokens to the opener and close
+ state := r.URL.Query().Get("state")
+ if strings.Contains(state, "mode=spa") {
+ origin := canonicalOrigin(cfg.OAuthRedirectBase)
+ if origin == "" {
+ origin = cfg.OAuthRedirectBase
+ }
+ payload := dto.TokenPair{
+ AccessToken: access,
+ RefreshToken: rp.Plain,
+ TokenType: "Bearer",
+ ExpiresIn: int64(accessTTL.Seconds()),
+ }
+ writePostMessageHTML(w, origin, payload)
+ return
+ }
+
+ // Default JSON response
+ utils.WriteJSON(w, http.StatusOK, dto.TokenPair{
+ AccessToken: access,
+ RefreshToken: rp.Plain,
+ TokenType: "Bearer",
+ ExpiresIn: int64(accessTTL.Seconds()),
+ })
+ }
+}
+
+// Refresh godoc
+//
+// @ID Refresh
+// @Summary Rotate refresh token
+// @Tags Auth
+// @Accept json
+// @Produce json
+// @Param body body dto.RefreshRequest true "Refresh token"
+// @Success 200 {object} dto.TokenPair
+// @Router /auth/refresh [post]
+func Refresh(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ cfg, _ := config.Load()
+ var req dto.RefreshRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, 400, "invalid_json", err.Error())
+ return
+ }
+ rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
+ if err != nil {
+ utils.WriteError(w, 401, "invalid_refresh", "")
+ return
+ }
+
+ var u models.User
+ if err := db.First(&u, "id = ? AND is_disabled = false", rec.UserID).Error; err != nil {
+ utils.WriteError(w, 401, "user_disabled", "")
+ return
+ }
+
+ // rotate
+ newPair, err := auth.RotateRefreshToken(db, rec, 30*24*time.Hour)
+ if err != nil {
+ utils.WriteError(w, 500, "rotate_failed", err.Error())
+ return
+ }
+
+ // new access
+ access, err := auth.IssueAccessToken(auth.IssueOpts{
+ Subject: u.ID.String(),
+ Issuer: cfg.JWTIssuer,
+ Audience: cfg.JWTAudience,
+ TTL: 1 * time.Hour,
+ })
+ if err != nil {
+ utils.WriteError(w, 500, "issue_access_failed", err.Error())
+ return
+ }
+
+ secure := true
+ if uParsed, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(uParsed) {
+ secure = false
+ }
+ if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
+ secure = strings.EqualFold(xf, "https")
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "ag_jwt",
+ Value: "Bearer " + access,
+ Path: "/",
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: secure,
+ MaxAge: int((time.Hour * 8).Seconds()),
+ })
+
+ utils.WriteJSON(w, 200, dto.TokenPair{
+ AccessToken: access,
+ RefreshToken: newPair.Plain,
+ TokenType: "Bearer",
+ ExpiresIn: 3600,
+ })
+ }
+}
+
+// Logout godoc
+//
+// @ID Logout
+// @Summary Revoke refresh token family (logout everywhere)
+// @Tags Auth
+// @Accept json
+// @Produce json
+// @Param body body dto.LogoutRequest true "Refresh token"
+// @Success 204 "No Content"
+// @Router /auth/logout [post]
+func Logout(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ cfg, _ := config.Load()
+ var req dto.LogoutRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, 400, "invalid_json", err.Error())
+ return
+ }
+ rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
+ if err != nil {
+ w.WriteHeader(204) // already invalid/revoked
+ goto clearCookie
+ }
+ if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
+ utils.WriteError(w, 500, "revoke_failed", err.Error())
+ return
+ }
+
+ clearCookie:
+ secure := true
+ if uParsed, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(uParsed) {
+ secure = false
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "ag_jwt",
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ MaxAge: -1,
+ Expires: time.Unix(0, 0),
+ SameSite: http.SameSiteLaxMode,
+ Secure: secure,
+ })
+
+ w.WriteHeader(204)
+ }
+}
+
+// Helpers
+
+func upsertAccountAndUser(db *gorm.DB, provider, subject, email, display string) (*models.User, error) {
+ email = strings.ToLower(email)
+ var acc models.Account
+ if err := db.Where("provider = ? AND subject = ?", provider, subject).First(&acc).Error; err == nil {
+ var u models.User
+ if err := db.First(&u, "id = ?", acc.UserID).Error; err != nil {
+ return nil, err
+ }
+ return &u, nil
+ }
+ // Link by email if exists
+ var ue models.UserEmail
+ if err := db.Where("LOWER(email) = ?", email).First(&ue).Error; err == nil {
+ acc = models.Account{
+ UserID: ue.UserID,
+ Provider: provider,
+ Subject: subject,
+ Email: &email,
+ EmailVerified: true,
+ }
+ if err := db.Create(&acc).Error; err != nil {
+ return nil, err
+ }
+ var u models.User
+ if err := db.First(&u, "id = ?", ue.UserID).Error; err != nil {
+ return nil, err
+ }
+ return &u, nil
+ }
+ // Create user
+ u := models.User{DisplayName: &display, PrimaryEmail: &email}
+ if err := db.Create(&u).Error; err != nil {
+ return nil, err
+ }
+ ue = models.UserEmail{UserID: u.ID, Email: email, IsVerified: true, IsPrimary: true}
+ _ = db.Create(&ue).Error
+ acc = models.Account{UserID: u.ID, Provider: provider, Subject: subject, Email: &email, EmailVerified: true}
+ _ = db.Create(&acc).Error
+ return &u, nil
+}
+
+func ensureAutoMembership(db *gorm.DB, userID uuid.UUID, email string) error {
+ parts := strings.SplitN(strings.ToLower(email), "@", 2)
+ if len(parts) != 2 {
+ return nil
+ }
+ domain := parts[1]
+ var org models.Organization
+ if err := db.Where("LOWER(domain) = ?", domain).First(&org).Error; err != nil {
+ return nil
+ }
+ // if already member, done
+ var c int64
+ db.Model(&models.Membership{}).
+ Where("user_id = ? AND organization_id = ?", userID, org.ID).
+ Count(&c)
+ if c > 0 {
+ return nil
+ }
+ return db.Create(&models.Membership{
+ UserID: userID, OrganizationID: org.ID, Role: "member",
+ }).Error
+}
+
+// postMessage HTML template
+var postMessageTpl = template.Must(template.New("postmsg").Parse(`
+
+
+
+
+`))
+
+type postMessageData struct {
+ Origin string
+ PayloadB64 string
+}
+
+// writePostMessageHTML sends a tiny HTML page that posts tokens to the SPA and closes the window.
+func writePostMessageHTML(w http.ResponseWriter, origin string, payload dto.TokenPair) {
+ b, _ := json.Marshal(payload)
+
+ data := postMessageData{
+ Origin: origin,
+ PayloadB64: base64.StdEncoding.EncodeToString(b),
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusOK)
+ _ = postMessageTpl.Execute(w, data)
+}
+
+// canonicalOrigin returns scheme://host[:port] for a given URL, or "" if invalid.
+func canonicalOrigin(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return ""
+ }
+
+ // Normalize: no path/query/fragment β just the origin.
+ return (&url.URL{
+ Scheme: u.Scheme,
+ Host: u.Host,
+ }).String()
+}
+
+func isLocalDev(u *url.URL) bool {
+ host := strings.ToLower(u.Hostname())
+ return u.Scheme == "http" &&
+ (host == "localhost" || host == "127.0.0.1")
+}
+
+
+
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/bg"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ListClusterRuns godoc
+//
+// @ID ListClusterRuns
+// @Summary List cluster runs (org scoped)
+// @Description Returns runs for a cluster within the organization in X-Org-ID.
+// @Tags ClusterRuns
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 200 {array} dto.ClusterRunResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/runs [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListClusterRuns(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ // Ensure cluster exists + org scoped
+ if err := db.Select("id").
+ Where("id = ? AND organization_id = ?", clusterID, orgID).
+ First(&models.Cluster{}).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var rows []models.ClusterRun
+ if err := db.
+ Where("organization_id = ? AND cluster_id = ?", orgID, clusterID).
+ Order("created_at DESC").
+ Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := make([]dto.ClusterRunResponse, 0, len(rows))
+ for _, cr := range rows {
+ out = append(out, clusterRunToDTO(cr))
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetClusterRun godoc
+//
+// @ID GetClusterRun
+// @Summary Get a cluster run (org scoped)
+// @Description Returns a single run for a cluster within the organization in X-Org-ID.
+// @Tags ClusterRuns
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param runID path string true "Run ID"
+// @Success 200 {object} dto.ClusterRunResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/runs/{runID} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetClusterRun(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ runID, err := uuid.Parse(chi.URLParam(r, "runID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_run_id", "invalid run id")
+ return
+ }
+
+ var row models.ClusterRun
+ if err := db.
+ Where("id = ? AND organization_id = ? AND cluster_id = ?", runID, orgID, clusterID).
+ First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "run not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterRunToDTO(row))
+ }
+}
+
+// RunClusterAction godoc
+//
+// @ID RunClusterAction
+// @Summary Run an admin-configured action on a cluster (org scoped)
+// @Description Creates a ClusterRun record for the cluster/action. Execution is handled asynchronously by workers.
+// @Tags ClusterRuns
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param actionID path string true "Action ID"
+// @Success 201 {object} dto.ClusterRunResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster or action not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/actions/{actionID}/runs [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func RunClusterAction(db *gorm.DB, jobs *bg.Jobs) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
+ return
+ }
+
+ // cluster must exist + org scoped
+ var cluster models.Cluster
+ if err := db.Select("id", "organization_id").
+ Where("id = ? AND organization_id = ?", clusterID, orgID).
+ First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ // action is global/admin-configured (not org scoped)
+ var action models.Action
+ if err := db.Where("id = ?", actionID).First(&action).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "action_not_found", "action not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ run := models.ClusterRun{
+ OrganizationID: orgID,
+ ClusterID: clusterID,
+ Action: action.MakeTarget, // this is what you actually execute
+ Status: models.ClusterRunStatusQueued,
+ Error: "",
+ FinishedAt: time.Time{},
+ }
+
+ if err := db.Create(&run).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ args := bg.ClusterActionArgs{
+ OrgID: orgID,
+ ClusterID: clusterID,
+ Action: action.MakeTarget,
+ MakeTarget: action.MakeTarget,
+ }
+ // Enqueue with run.ID as the job ID so the worker can look it up.
+ _, enqueueErr := jobs.Enqueue(
+ r.Context(),
+ run.ID.String(),
+ "cluster_action",
+ args,
+ archer.WithMaxRetries(0),
+ )
+
+ if enqueueErr != nil {
+ _ = db.Model(&models.ClusterRun{}).
+ Where("id = ?", run.ID).
+ Updates(map[string]any{
+ "status": models.ClusterRunStatusFailed,
+ "error": "failed to enqueue job: " + enqueueErr.Error(),
+ "finished_at": time.Now().UTC(),
+ }).Error
+
+ utils.WriteError(w, http.StatusInternalServerError, "job_error", "failed to enqueue cluster action")
+ return
+ }
+ utils.WriteJSON(w, http.StatusCreated, clusterRunToDTO(run))
+
+ }
+}
+
+func clusterRunToDTO(cr models.ClusterRun) dto.ClusterRunResponse {
+ var finished *time.Time
+ if !cr.FinishedAt.IsZero() {
+ t := cr.FinishedAt
+ finished = &t
+ }
+ return dto.ClusterRunResponse{
+ ID: cr.ID,
+ OrganizationID: cr.OrganizationID,
+ ClusterID: cr.ClusterID,
+ Action: cr.Action,
+ Status: cr.Status,
+ Error: cr.Error,
+ CreatedAt: cr.CreatedAt,
+ UpdatedAt: cr.UpdatedAt,
+ FinishedAt: finished,
+ }
+}
+
+
+
+package handlers
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/common"
+ "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"
+)
+
+// ListClusters godoc
+//
+// @ID ListClusters
+// @Summary List clusters (org scoped)
+// @Description Returns clusters for the organization in X-Org-ID. Filter by `q` (name contains).
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param q query string false "Name contains (case-insensitive)"
+// @Success 200 {array} dto.ClusterResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list clusters"
+// @Router /clusters [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListClusters(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ q := db.Where("organization_id = ?", orgID)
+ if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
+ q = q.Where(`name ILIKE ?`, "%"+needle+"%")
+ }
+
+ var rows []models.Cluster
+ if err := q.
+ Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := make([]dto.ClusterResponse, 0, len(rows))
+ for _, row := range rows {
+ cr := clusterToDTO(row)
+
+ if row.EncryptedKubeconfig != "" && row.KubeIV != "" && row.KubeTag != "" {
+ kubeconfig, err := utils.DecryptForOrg(orgID, row.EncryptedKubeconfig, row.KubeIV, row.KubeTag, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "kubeconfig_decrypt_failed", "failed to decrypt kubeconfig")
+ return
+ }
+ cr.Kubeconfig = &kubeconfig
+ }
+ out = append(out, cr)
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetCluster godoc
+//
+// @ID GetCluster
+// @Summary Get a single cluster by ID (org scoped)
+// @Description Returns a cluster with all related resources (domain, record set, load balancers, bastion, node pools).
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetCluster(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.
+ Where("id = ? AND organization_id = ?", clusterID, orgID).
+ Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster).Error; err != nil {
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ resp := clusterToDTO(cluster)
+
+ if cluster.EncryptedKubeconfig != "" && cluster.KubeIV != "" && cluster.KubeTag != "" {
+ kubeconfig, err := utils.DecryptForOrg(orgID, cluster.EncryptedKubeconfig, cluster.KubeIV, cluster.KubeTag, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "kubeconfig_decrypt_failed", "failed to decrypt kubeconfig")
+ return
+ }
+ resp.Kubeconfig = &kubeconfig
+ }
+
+ utils.WriteJSON(w, http.StatusOK, resp)
+ }
+}
+
+// CreateCluster godoc
+//
+// @ID CreateCluster
+// @Summary Create cluster (org scoped)
+// @Description Creates a cluster. Status is managed by the system and starts as `pre_pending` for validation.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateClusterRequest true "payload"
+// @Success 201 {object} dto.ClusterResponse
+// @Failure 400 {string} string "invalid json"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "create failed"
+// @Router /clusters [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateCluster(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.CreateClusterRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ certificateKey, err := GenerateSecureHex(32)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to generate certificate key")
+ return
+ }
+
+ randomToken, err := GenerateFormattedToken()
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to generate random token")
+ return
+ }
+
+ c := models.Cluster{
+ OrganizationID: orgID,
+ Name: in.Name,
+ Provider: in.ClusterProvider,
+ Region: in.Region,
+ Status: models.ClusterStatusPrePending,
+ LastError: "",
+ CertificateKey: certificateKey,
+ RandomToken: randomToken,
+ DockerImage: in.DockerImage,
+ DockerTag: in.DockerTag,
+ }
+
+ if err := db.Create(&c).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusCreated, clusterToDTO(c))
+ }
+}
+
+// UpdateCluster godoc
+//
+// @ID UpdateCluster
+// @Summary Update basic cluster details (org scoped)
+// @Description Updates the cluster name, provider, and/or region. Status is managed by the system.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param body body dto.UpdateClusterRequest true "payload"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateCluster(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var in dto.UpdateClusterRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ // Apply only provided fields
+ if in.Name != nil {
+ cluster.Name = *in.Name
+ }
+ if in.ClusterProvider != nil {
+ cluster.Provider = *in.ClusterProvider
+ }
+ if in.Region != nil {
+ cluster.Region = *in.Region
+ }
+
+ if in.DockerImage != nil {
+ cluster.DockerImage = *in.DockerImage
+ }
+
+ if in.DockerTag != nil {
+ cluster.DockerTag = *in.DockerTag
+ }
+
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ // Any change to the cluster config may require re-validation.
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ // Preload for a rich response
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// DeleteCluster godoc
+//
+// @ID DeleteCluster
+// @Summary Delete a cluster (org scoped)
+// @Description Deletes the cluster. Related resources are cleaned up via DB constraints (e.g. CASCADE).
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 204 {string} string "deleted"
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteCluster(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ tx := db.Where("id = ? AND organization_id = ?", clusterID, orgID).Delete(&models.Cluster{})
+ if tx.Error != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ if tx.RowsAffected == 0 {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// AttachCaptainDomain godoc
+//
+// @ID AttachCaptainDomain
+// @Summary Attach a captain domain to a cluster
+// @Description Sets captain_domain_id on the cluster. Validation of shape happens asynchronously.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param body body dto.AttachCaptainDomainRequest true "payload"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster or domain not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/captain-domain [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachCaptainDomain(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var in dto.AttachCaptainDomainRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ // Ensure domain exists and belongs to the org
+ var domain models.Domain
+ if err := db.Where("id = ? AND organization_id = ?", in.DomainID, orgID).First(&domain).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "domain_not_found", "domain not found for organization")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.CaptainDomainID = &domain.ID
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if err := markClusterNeedsValidation(db, cluster.ID); err != nil {
+ // Don't fail the request, just log if you have logging.
+ }
+
+ // Preload domain for response
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// DetachCaptainDomain godoc
+//
+// @ID DetachCaptainDomain
+// @Summary Detach the captain domain from a cluster
+// @Description Clears captain_domain_id on the cluster. This will likely cause the cluster to become incomplete.
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/captain-domain [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachCaptainDomain(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.CaptainDomainID = nil
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// AttachControlPlaneRecordSet godoc
+//
+// @ID AttachControlPlaneRecordSet
+// @Summary Attach a control plane record set to a cluster
+// @Description Sets control_plane_record_set_id on the cluster.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param body body dto.AttachRecordSetRequest true "payload"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster or record set not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/control-plane-record-set [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var in dto.AttachRecordSetRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ // record sets are indirectly org-scoped via their domain
+ var rs models.RecordSet
+ if err := db.
+ Joins("JOIN domains d ON d.id = record_sets.domain_id").
+ Where("record_sets.id = ? AND d.organization_id = ?", in.RecordSetID, orgID).
+ First(&rs).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "recordset_not_found", "record set not found for organization")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.ControlPlaneRecordSetID = &rs.ID
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// DetachControlPlaneRecordSet godoc
+//
+// @ID DetachControlPlaneRecordSet
+// @Summary Detach the control plane record set from a cluster
+// @Description Clears control_plane_record_set_id on the cluster.
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/control-plane-record-set [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.ControlPlaneRecordSetID = nil
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// AttachAppsLoadBalancer godoc
+//
+// @ID AttachAppsLoadBalancer
+// @Summary Attach an apps load balancer to a cluster
+// @Description Sets apps_load_balancer_id on the cluster.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param body body dto.AttachLoadBalancerRequest true "payload"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster or load balancer not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/apps-load-balancer [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var in dto.AttachLoadBalancerRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var lb models.LoadBalancer
+ if err := db.Where("id = ? AND organization_id = ?", in.LoadBalancerID, orgID).First(&lb).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "lb_not_found", "load balancer not found for organization")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.AppsLoadBalancerID = &lb.ID
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// DetachAppsLoadBalancer godoc
+//
+// @ID DetachAppsLoadBalancer
+// @Summary Detach the apps load balancer from a cluster
+// @Description Clears apps_load_balancer_id on the cluster.
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/apps-load-balancer [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.AppsLoadBalancerID = nil
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// AttachGlueOpsLoadBalancer godoc
+//
+// @ID AttachGlueOpsLoadBalancer
+// @Summary Attach a GlueOps/control-plane load balancer to a cluster
+// @Description Sets glueops_load_balancer_id on the cluster.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param body body dto.AttachLoadBalancerRequest true "payload"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster or load balancer not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/glueops-load-balancer [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var in dto.AttachLoadBalancerRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var lb models.LoadBalancer
+ if err := db.Where("id = ? AND organization_id = ?", in.LoadBalancerID, orgID).First(&lb).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "lb_not_found", "load balancer not found for organization")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.GlueOpsLoadBalancerID = &lb.ID
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// DetachGlueOpsLoadBalancer godoc
+//
+// @ID DetachGlueOpsLoadBalancer
+// @Summary Detach the GlueOps/control-plane load balancer from a cluster
+// @Description Clears glueops_load_balancer_id on the cluster.
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/glueops-load-balancer [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.GlueOpsLoadBalancerID = nil
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// AttachBastionServer godoc
+//
+// @ID AttachBastionServer
+// @Summary Attach a bastion server to a cluster
+// @Description Sets bastion_server_id on the cluster.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param body body dto.AttachBastionRequest true "payload"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster or server not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/bastion [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachBastionServer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var in dto.AttachBastionRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var server models.Server
+ if err := db.Where("id = ? AND organization_id = ?", in.ServerID, orgID).First(&server).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found for organization")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.BastionServerID = &server.ID
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// DetachBastionServer godoc
+//
+// @ID DetachBastionServer
+// @Summary Detach the bastion server from a cluster
+// @Description Clears bastion_server_id on the cluster.
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/bastion [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachBastionServer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.BastionServerID = nil
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// SetClusterKubeconfig godoc
+//
+// @ID SetClusterKubeconfig
+// @Summary Set (or replace) the kubeconfig for a cluster
+// @Description Stores the kubeconfig encrypted per organization. The kubeconfig is never returned in responses.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param body body dto.SetKubeconfigRequest true "payload"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/kubeconfig [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func SetClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var in dto.SetKubeconfigRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ ct, iv, tag, err := utils.EncryptForOrg(orgID, []byte(in.Kubeconfig), db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "encryption_error", "failed to encrypt kubeconfig")
+ return
+ }
+
+ cluster.EncryptedKubeconfig = ct
+ cluster.KubeIV = iv
+ cluster.KubeTag = tag
+
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// ClearClusterKubeconfig godoc
+//
+// @ID ClearClusterKubeconfig
+// @Summary Clear the kubeconfig for a cluster
+// @Description Removes the encrypted kubeconfig, IV, and tag from the cluster record.
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/kubeconfig [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ClearClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ cluster.EncryptedKubeconfig = ""
+ cluster.KubeIV = ""
+ cluster.KubeTag = ""
+
+ if err := db.Save(&cluster).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// AttachNodePool godoc
+//
+// @ID AttachNodePool
+// @Summary Attach a node pool to a cluster
+// @Description Adds an entry in the cluster_node_pools join table.
+// @Tags Clusters
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param body body dto.AttachNodePoolRequest true "payload"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster or node pool not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/node-pools [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachNodePool(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ var in dto.AttachNodePoolRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ // Load cluster (org scoped)
+ var cluster models.Cluster
+ if err := db.
+ Where("id = ? AND organization_id = ?", clusterID, orgID).
+ First(&cluster).Error; err != nil {
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ // Load node pool (org scoped)
+ var np models.NodePool
+ if err := db.
+ Where("id = ? AND organization_id = ?", in.NodePoolID, orgID).
+ First(&np).Error; err != nil {
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "nodepool_not_found", "node pool not found for organization")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ // Create association in join table
+ if err := db.Model(&cluster).Association("NodePools").Append(&np); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to attach node pool")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ // Reload for rich response
+ if err := db.
+ Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// DetachNodePool godoc
+//
+// @ID DetachNodePool
+// @Summary Detach a node pool from a cluster
+// @Description Removes an entry from the cluster_node_pools join table.
+// @Tags Clusters
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param clusterID path string true "Cluster ID"
+// @Param nodePoolID path string true "Node Pool ID"
+// @Success 200 {object} dto.ClusterResponse
+// @Failure 400 {string} string "bad request"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "cluster or node pool not found"
+// @Failure 500 {string} string "db error"
+// @Router /clusters/{clusterID}/node-pools/{nodePoolID} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachNodePool(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
+ return
+ }
+
+ nodePoolID, err := uuid.Parse(chi.URLParam(r, "nodePoolID"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_nodepool_id", "invalid node pool id")
+ return
+ }
+
+ var cluster models.Cluster
+ if err := db.
+ Where("id = ? AND organization_id = ?", clusterID, orgID).
+ First(&cluster).Error; err != nil {
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.
+ Where("id = ? AND organization_id = ?", nodePoolID, orgID).
+ First(&np).Error; err != nil {
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "nodepool_not_found", "node pool not found for organization")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if err := db.Model(&cluster).Association("NodePools").Delete(&np); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to detach node pool")
+ return
+ }
+
+ _ = markClusterNeedsValidation(db, cluster.ID)
+
+ if err := db.
+ Preload("CaptainDomain").
+ Preload("ControlPlaneRecordSet").
+ Preload("AppsLoadBalancer").
+ Preload("GlueOpsLoadBalancer").
+ Preload("BastionServer").
+ Preload("NodePools").
+ Preload("NodePools.Labels").
+ Preload("NodePools.Annotations").
+ Preload("NodePools.Taints").
+ Preload("NodePools.Servers").
+ First(&cluster, "id = ?", cluster.ID).Error; err != nil {
+
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
+ }
+}
+
+// -- Helpers
+
+func clusterToDTO(c models.Cluster) dto.ClusterResponse {
+ var bastion *dto.ServerResponse
+ if c.BastionServer != nil {
+ b := serverToDTO(*c.BastionServer)
+ bastion = &b
+ }
+
+ var captainDomain *dto.DomainResponse
+ if c.CaptainDomainID != nil && c.CaptainDomain.ID != uuid.Nil {
+ dr := domainToDTO(c.CaptainDomain)
+ captainDomain = &dr
+ }
+
+ var controlPlane *dto.RecordSetResponse
+ if c.ControlPlaneRecordSet != nil {
+ rr := recordSetToDTO(*c.ControlPlaneRecordSet)
+ controlPlane = &rr
+ }
+
+ var cfqdn *string
+ if captainDomain != nil && controlPlane != nil {
+ fq := fmt.Sprintf("%s.%s", controlPlane.Name, captainDomain.DomainName)
+ cfqdn = &fq
+ }
+
+ var appsLB *dto.LoadBalancerResponse
+ if c.AppsLoadBalancer != nil {
+ lr := loadBalancerToDTO(*c.AppsLoadBalancer)
+ appsLB = &lr
+ }
+
+ var glueOpsLB *dto.LoadBalancerResponse
+ if c.GlueOpsLoadBalancer != nil {
+ lr := loadBalancerToDTO(*c.GlueOpsLoadBalancer)
+ glueOpsLB = &lr
+ }
+
+ nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
+ for _, np := range c.NodePools {
+ nps = append(nps, nodePoolToDTO(np))
+ }
+
+ return dto.ClusterResponse{
+ ID: c.ID,
+ Name: c.Name,
+ CaptainDomain: captainDomain,
+ ControlPlaneRecordSet: controlPlane,
+ ControlPlaneFQDN: cfqdn,
+ AppsLoadBalancer: appsLB,
+ GlueOpsLoadBalancer: glueOpsLB,
+ BastionServer: bastion,
+ Provider: c.Provider,
+ Region: c.Region,
+ Status: c.Status,
+ LastError: c.LastError,
+ RandomToken: c.RandomToken,
+ CertificateKey: c.CertificateKey,
+ NodePools: nps,
+ DockerImage: c.DockerImage,
+ DockerTag: c.DockerTag,
+ CreatedAt: c.CreatedAt,
+ UpdatedAt: c.UpdatedAt,
+ }
+}
+
+func nodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
+ labels := make([]dto.LabelResponse, 0, len(np.Labels))
+ for _, l := range np.Labels {
+ labels = append(labels, dto.LabelResponse{
+ Key: l.Key,
+ Value: l.Value,
+ })
+ }
+
+ annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
+ for _, a := range np.Annotations {
+ annotations = append(annotations, dto.AnnotationResponse{
+ Key: a.Key,
+ Value: a.Value,
+ })
+ }
+
+ taints := make([]dto.TaintResponse, 0, len(np.Taints))
+ for _, t := range np.Taints {
+ taints = append(taints, dto.TaintResponse{
+ Key: t.Key,
+ Value: t.Value,
+ Effect: t.Effect,
+ })
+ }
+
+ servers := make([]dto.ServerResponse, 0, len(np.Servers))
+ for _, s := range np.Servers {
+ servers = append(servers, serverToDTO(s))
+ }
+
+ return dto.NodePoolResponse{
+ AuditFields: common.AuditFields{
+ ID: np.ID,
+ OrganizationID: np.OrganizationID,
+ CreatedAt: np.CreatedAt,
+ UpdatedAt: np.UpdatedAt,
+ },
+ Name: np.Name,
+ Role: dto.NodeRole(np.Role),
+ Labels: labels,
+ Annotations: annotations,
+ Taints: taints,
+ Servers: servers,
+ }
+}
+
+func serverToDTO(s models.Server) dto.ServerResponse {
+ return dto.ServerResponse{
+ ID: s.ID,
+ Hostname: s.Hostname,
+ PrivateIPAddress: s.PrivateIPAddress,
+ PublicIPAddress: s.PublicIPAddress,
+ Role: s.Role,
+ Status: s.Status,
+ SSHUser: s.SSHUser,
+ SshKeyID: s.SshKeyID,
+ CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+func domainToDTO(d models.Domain) dto.DomainResponse {
+ return dto.DomainResponse{
+ ID: d.ID.String(),
+ OrganizationID: d.OrganizationID.String(),
+ DomainName: d.DomainName,
+ ZoneID: d.ZoneID,
+ Status: d.Status,
+ LastError: d.LastError,
+ CredentialID: d.CredentialID.String(),
+ CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+func recordSetToDTO(rs models.RecordSet) dto.RecordSetResponse {
+ return dto.RecordSetResponse{
+ ID: rs.ID.String(),
+ DomainID: rs.DomainID.String(),
+ Name: rs.Name,
+ Type: rs.Type,
+ TTL: rs.TTL,
+ Values: []byte(rs.Values),
+ Fingerprint: rs.Fingerprint,
+ Status: rs.Status,
+ Owner: rs.Owner,
+ LastError: rs.LastError,
+ CreatedAt: rs.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: rs.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+func loadBalancerToDTO(lb models.LoadBalancer) dto.LoadBalancerResponse {
+ return dto.LoadBalancerResponse{
+ ID: lb.ID,
+ OrganizationID: lb.OrganizationID,
+ Name: lb.Name,
+ Kind: lb.Kind,
+ PublicIPAddress: lb.PublicIPAddress,
+ PrivateIPAddress: lb.PrivateIPAddress,
+ CreatedAt: lb.CreatedAt,
+ UpdatedAt: lb.UpdatedAt,
+ }
+}
+
+func GenerateSecureHex(n int) (string, error) {
+ bytes := make([]byte, n)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", fmt.Errorf("failed to generate random bytes: %w", err)
+ }
+ return hex.EncodeToString(bytes), nil
+}
+
+func GenerateFormattedToken() (string, error) {
+ part1, err := GenerateSecureHex(3)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate token part 1: %w", err)
+ }
+ part2, err := GenerateSecureHex(8)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate token part 2: %w", err)
+ }
+ return fmt.Sprintf("%s.%s", part1, part2), nil
+}
+
+func markClusterNeedsValidation(db *gorm.DB, clusterID uuid.UUID) error {
+ return db.Model(&models.Cluster{}).Where("id = ?", clusterID).Updates(map[string]any{
+ "status": models.ClusterStatusPrePending,
+ "last_error": "",
+ }).Error
+}
+
+
+
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "sort"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+ "gorm.io/datatypes"
+ "gorm.io/gorm"
+)
+
+// ListCredentials godoc
+//
+// @ID ListCredentials
+// @Summary List credentials (metadata only)
+// @Description Returns credential metadata for the current org. Secrets are never returned.
+// @Tags Credentials
+// @Produce json
+// @Param X-Org-ID header string false "Organization ID (UUID)"
+// @Param credential_provider query string false "Filter by provider (e.g., aws)"
+// @Param kind query string false "Filter by kind (e.g., aws_access_key)"
+// @Param scope_kind query string false "Filter by scope kind (credential_provider/service/resource)"
+// @Success 200 {array} dto.CredentialOut
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "internal server error"
+// @Router /credentials [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListCredentials(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ q := db.Where("organization_id = ?", orgID)
+ if v := r.URL.Query().Get("credential_provider"); v != "" {
+ q = q.Where("provider = ?", v)
+ }
+ if v := r.URL.Query().Get("kind"); v != "" {
+ q = q.Where("kind = ?", v)
+ }
+ if v := r.URL.Query().Get("scope_kind"); v != "" {
+ q = q.Where("scope_kind = ?", v)
+ }
+
+ var rows []models.Credential
+ if err := q.Order("updated_at DESC").Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ out := make([]dto.CredentialOut, 0, len(rows))
+ for i := range rows {
+ out = append(out, credOut(&rows[i]))
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetCredential godoc
+//
+// @ID GetCredential
+// @Summary Get credential by ID (metadata only)
+// @Tags Credentials
+// @Produce json
+// @Param X-Org-ID header string false "Organization ID (UUID)"
+// @Param id path string true "Credential ID (UUID)"
+// @Success 200 {object} dto.CredentialOut
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "internal server error"
+// @Router /credentials/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetCredential(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ idStr := chi.URLParam(r, "id")
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+
+ var row models.Credential
+ if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, credOut(&row))
+ }
+}
+
+// CreateCredential godoc
+//
+// @ID CreateCredential
+// @Summary Create a credential (encrypts secret)
+// @Tags Credentials
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization ID (UUID)"
+// @Param body body dto.CreateCredentialRequest true "Credential payload"
+// @Success 201 {object} dto.CredentialOut
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "internal server error"
+// @Router /credentials [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateCredential(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ var in dto.CreateCredentialRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ if err := dto.Validate.Struct(in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
+ return
+ }
+
+ cred, err := SaveCredentialWithScope(
+ r.Context(), db, orgID,
+ in.CredentialProvider, in.Kind, in.SchemaVersion,
+ in.ScopeKind, in.ScopeVersion, json.RawMessage(in.Scope), json.RawMessage(in.Secret),
+ in.Name, in.AccountID, in.Region,
+ )
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "save_failed", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusCreated, credOut(cred))
+ }
+}
+
+// UpdateCredential godoc
+//
+// @ID UpdateCredential
+// @Summary Update credential metadata and/or rotate secret
+// @Tags Credentials
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization ID (UUID)"
+// @Param id path string true "Credential ID (UUID)"
+// @Param body body dto.UpdateCredentialRequest true "Fields to update"
+// @Success 200 {object} dto.CredentialOut
+// @Failure 403 {string} string "X-Org-ID required"
+// @Failure 404 {string} string "not found"
+// @Router /credentials/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateCredential(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+
+ var row models.Credential
+ if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ var in dto.UpdateCredentialRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ // Update metadata
+ if in.Name != nil {
+ row.Name = *in.Name
+ }
+ if in.AccountID != nil {
+ row.AccountID = *in.AccountID
+ }
+ if in.Region != nil {
+ row.Region = *in.Region
+ }
+
+ // Update scope (re-validate + fingerprint)
+ if in.ScopeKind != nil || in.Scope != nil || in.ScopeVersion != nil {
+ newKind := row.ScopeKind
+ if in.ScopeKind != nil {
+ newKind = *in.ScopeKind
+ }
+ newVersion := row.ScopeVersion
+ if in.ScopeVersion != nil {
+ newVersion = *in.ScopeVersion
+ }
+ if in.Scope == nil {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "scope must be provided when changing scope kind/version")
+ return
+ }
+ prScopes := dto.ScopeRegistry[row.Provider]
+ kScopes := prScopes[newKind]
+ sdef := kScopes[newVersion]
+ dst := sdef.New()
+ if err := json.Unmarshal(*in.Scope, dst); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_scope_json", err.Error())
+ return
+ }
+ if err := sdef.Validate(dst); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_scope", err.Error())
+ return
+ }
+ canonScope, err := canonicalJSON(dst)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "canon_error", err.Error())
+ return
+ }
+ row.Scope = canonScope
+ row.ScopeKind = newKind
+ row.ScopeVersion = newVersion
+ row.ScopeFingerprint = sha256Hex(canonScope)
+ }
+
+ // Rotate secret
+ if in.Secret != nil {
+ // validate against current Provider/Kind/SchemaVersion
+ def := dto.CredentialRegistry[row.Provider][row.Kind][row.SchemaVersion]
+ dst := def.New()
+ if err := json.Unmarshal(*in.Secret, dst); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_secret_json", err.Error())
+ return
+ }
+ if err := def.Validate(dst); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_secret", err.Error())
+ return
+ }
+ canonSecret, err := canonicalJSON(dst)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "canon_error", err.Error())
+ return
+ }
+ cipher, iv, tag, err := utils.EncryptForOrg(orgID, canonSecret, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "encrypt_error", err.Error())
+ return
+ }
+ row.EncryptedData = cipher
+ row.IV = iv
+ row.Tag = tag
+ }
+
+ if err := db.Save(&row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, credOut(&row))
+ }
+}
+
+// DeleteCredential godoc
+//
+// @ID DeleteCredential
+// @Summary Delete credential
+// @Tags Credentials
+// @Produce json
+// @Param X-Org-ID header string false "Organization ID (UUID)"
+// @Param id path string true "Credential ID (UUID)"
+// @Success 204
+// @Failure 404 {string} string "not found"
+// @Router /credentials/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteCredential(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+ res := db.Where("organization_id = ? AND id = ?", orgID, id).Delete(&models.Credential{})
+ if res.Error != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
+ return
+ }
+ if res.RowsAffected == 0 {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// RevealCredential godoc
+//
+// @ID RevealCredential
+// @Summary Reveal decrypted secret (one-time read)
+// @Tags Credentials
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization ID (UUID)"
+// @Param id path string true "Credential ID (UUID)"
+// @Success 200 {object} map[string]any
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /credentials/{id}/reveal [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func RevealCredential(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+
+ var row models.Credential
+ if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ plain, err := utils.DecryptForOrg(orgID, row.EncryptedData, row.IV, row.Tag, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "decrypt_error", err.Error())
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, plain)
+ }
+}
+
+// -- Helpers
+
+func canonicalJSON(v any) ([]byte, error) {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return nil, err
+ }
+ var m any
+ if err := json.Unmarshal(b, &m); err != nil {
+ return nil, err
+ }
+ return marshalSorted(m)
+}
+
+func marshalSorted(v any) ([]byte, error) {
+ switch vv := v.(type) {
+ case map[string]any:
+ keys := make([]string, 0, len(vv))
+ for k := range vv {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ buf := bytes.NewBufferString("{")
+ for i, k := range keys {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ kb, _ := json.Marshal(k)
+ buf.Write(kb)
+ buf.WriteByte(':')
+ b, err := marshalSorted(vv[k])
+ if err != nil {
+ return nil, err
+ }
+ buf.Write(b)
+ }
+ buf.WriteByte('}')
+ return buf.Bytes(), nil
+ case []any:
+ buf := bytes.NewBufferString("[")
+ for i, e := range vv {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ b, err := marshalSorted(e)
+ if err != nil {
+ return nil, err
+ }
+ buf.Write(b)
+ }
+ buf.WriteByte(']')
+ return buf.Bytes(), nil
+ default:
+ return json.Marshal(v)
+ }
+}
+
+func sha256Hex(b []byte) string {
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
+
+// SaveCredentialWithScope validates secret+scope, encrypts, fingerprints, and stores.
+func SaveCredentialWithScope(
+ ctx context.Context,
+ db *gorm.DB,
+ orgID uuid.UUID,
+ provider, kind string,
+ schemaVersion int,
+ scopeKind string,
+ scopeVersion int,
+ rawScope json.RawMessage,
+ rawSecret json.RawMessage,
+ name, accountID, region string,
+) (*models.Credential, error) {
+ // 1) secret shape
+ pv, ok := dto.CredentialRegistry[provider]
+ if !ok {
+ return nil, fmt.Errorf("unknown provider %q", provider)
+ }
+ kv, ok := pv[kind]
+ if !ok {
+ return nil, fmt.Errorf("unknown kind %q for provider %q", kind, provider)
+ }
+ def, ok := kv[schemaVersion]
+ if !ok {
+ return nil, fmt.Errorf("unsupported schema version %d for %s/%s", schemaVersion, provider, kind)
+ }
+
+ secretDst := def.New()
+ if err := json.Unmarshal(rawSecret, secretDst); err != nil {
+ return nil, fmt.Errorf("payload is not valid JSON for %s/%s: %w", provider, kind, err)
+ }
+ if err := def.Validate(secretDst); err != nil {
+ return nil, fmt.Errorf("invalid %s/%s: %w", provider, kind, err)
+ }
+
+ // 2) scope shape
+ prScopes, ok := dto.ScopeRegistry[provider]
+ if !ok {
+ return nil, fmt.Errorf("no scopes registered for provider %q", provider)
+ }
+ kScopes, ok := prScopes[scopeKind]
+ if !ok {
+ return nil, fmt.Errorf("invalid scope_kind %q for provider %q", scopeKind, provider)
+ }
+ sdef, ok := kScopes[scopeVersion]
+ if !ok {
+ return nil, fmt.Errorf("unsupported scope version %d for %s/%s", scopeVersion, provider, scopeKind)
+ }
+
+ scopeDst := sdef.New()
+ if err := json.Unmarshal(rawScope, scopeDst); err != nil {
+ return nil, fmt.Errorf("invalid scope JSON: %w", err)
+ }
+ if err := sdef.Validate(scopeDst); err != nil {
+ return nil, fmt.Errorf("invalid scope: %w", err)
+ }
+
+ // 3) canonicalize scope (also what we persist in plaintext)
+ canonScope, err := canonicalJSON(scopeDst)
+ if err != nil {
+ return nil, err
+ }
+ fp := sha256Hex(canonScope) // or HMAC if you have a server-side key
+
+ // 4) canonicalize + encrypt secret
+ canonSecret, err := canonicalJSON(secretDst)
+ if err != nil {
+ return nil, err
+ }
+ cipher, iv, tag, err := utils.EncryptForOrg(orgID, canonSecret, db)
+ if err != nil {
+ return nil, fmt.Errorf("encrypt: %w", err)
+ }
+
+ cred := &models.Credential{
+ OrganizationID: orgID,
+ Provider: provider,
+ Kind: kind,
+ SchemaVersion: schemaVersion,
+ Name: name,
+ ScopeKind: scopeKind,
+ Scope: datatypes.JSON(canonScope),
+ ScopeVersion: scopeVersion,
+ AccountID: accountID,
+ Region: region,
+ ScopeFingerprint: fp,
+ EncryptedData: cipher,
+ IV: iv,
+ Tag: tag,
+ }
+
+ if err := db.WithContext(ctx).Create(cred).Error; err != nil {
+ return nil, err
+ }
+ return cred, nil
+}
+
+// credOut converts model β response DTO
+func credOut(c *models.Credential) dto.CredentialOut {
+ return dto.CredentialOut{
+ ID: c.ID.String(),
+ CredentialProvider: c.Provider,
+ Kind: c.Kind,
+ SchemaVersion: c.SchemaVersion,
+ Name: c.Name,
+ ScopeKind: c.ScopeKind,
+ ScopeVersion: c.ScopeVersion,
+ Scope: dto.RawJSON(c.Scope),
+ AccountID: c.AccountID,
+ Region: c.Region,
+ CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+
+
+package handlers
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog/log"
+ "gorm.io/datatypes"
+ "gorm.io/gorm"
+)
+
+// ---------- Helpers ----------
+
+func normLowerNoDot(s string) string {
+ s = strings.TrimSpace(strings.ToLower(s))
+ return strings.TrimSuffix(s, ".")
+}
+
+func fqdn(domain string, rel string) string {
+ d := normLowerNoDot(domain)
+ r := normLowerNoDot(rel)
+ if r == "" || r == "@" {
+ return d
+ }
+ return r + "." + d
+}
+
+func canonicalJSONAny(v any) ([]byte, error) {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return nil, err
+ }
+ var anyv any
+ if err := json.Unmarshal(b, &anyv); err != nil {
+ return nil, err
+ }
+ return marshalSortedDNS(anyv)
+}
+
+func marshalSortedDNS(v any) ([]byte, error) {
+ switch vv := v.(type) {
+ case map[string]any:
+ keys := make([]string, 0, len(vv))
+ for k := range vv {
+ keys = append(keys, k)
+ }
+ sortStrings(keys)
+ var buf bytes.Buffer
+ buf.WriteByte('{')
+ for i, k := range keys {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ kb, _ := json.Marshal(k)
+ buf.Write(kb)
+ buf.WriteByte(':')
+ b, err := marshalSortedDNS(vv[k])
+ if err != nil {
+ return nil, err
+ }
+ buf.Write(b)
+ }
+ buf.WriteByte('}')
+ return buf.Bytes(), nil
+ case []any:
+ var buf bytes.Buffer
+ buf.WriteByte('[')
+ for i, e := range vv {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ b, err := marshalSortedDNS(e)
+ if err != nil {
+ return nil, err
+ }
+ buf.Write(b)
+ }
+ buf.WriteByte(']')
+ return buf.Bytes(), nil
+ default:
+ return json.Marshal(v)
+ }
+}
+
+func sortStrings(a []string) {
+ for i := 0; i < len(a); i++ {
+ for j := i + 1; j < len(a); j++ {
+ if a[j] < a[i] {
+ a[i], a[j] = a[j], a[i]
+ }
+ }
+ }
+}
+
+func sha256HexBytes(b []byte) string {
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
+
+/* Fingerprint (provider-agnostic) */
+type desiredRecord struct {
+ ZoneID string `json:"zone_id"`
+ FQDN string `json:"fqdn"`
+ Type string `json:"type"`
+ TTL *int `json:"ttl,omitempty"`
+ Values []string `json:"values,omitempty"`
+}
+
+func computeFingerprint(zoneID, fqdn, typ string, ttl *int, values datatypes.JSON) (string, error) {
+ var vals []string
+ if len(values) > 0 && string(values) != "null" {
+ if err := json.Unmarshal(values, &vals); err != nil {
+ return "", err
+ }
+ sortStrings(vals)
+ }
+ payload := &desiredRecord{
+ ZoneID: zoneID, FQDN: fqdn, Type: strings.ToUpper(typ), TTL: ttl, Values: vals,
+ }
+ can, err := canonicalJSONAny(payload)
+ if err != nil {
+ return "", err
+ }
+ return sha256HexBytes(can), nil
+}
+
+func mustSameOrgDomainWithCredential(db *gorm.DB, orgID uuid.UUID, credID uuid.UUID) error {
+ var cred models.Credential
+ if err := db.Where("id = ? AND organization_id = ?", credID, orgID).First(&cred).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return fmt.Errorf("credential not found or belongs to different org")
+ }
+ return err
+ }
+ if cred.Provider != "aws" || cred.ScopeKind != "service" {
+ return fmt.Errorf("credential must be AWS Route 53 service scoped")
+ }
+ var scope map[string]any
+ if err := json.Unmarshal(cred.Scope, &scope); err != nil {
+ return fmt.Errorf("credential scope invalid json: %w", err)
+ }
+ if strings.ToLower(fmt.Sprint(scope["service"])) != "route53" {
+ return fmt.Errorf("credential scope.service must be route53")
+ }
+ return nil
+}
+
+// ---------- Domain Handlers ----------
+
+// ListDomains godoc
+//
+// @ID ListDomains
+// @Summary List domains (org scoped)
+// @Description Returns domains for X-Org-ID. Filters: `domain_name`, `status`, `q` (contains).
+// @Tags DNS
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param domain_name query string false "Exact domain name (lowercase, no trailing dot)"
+// @Param status query string false "pending|provisioning|ready|failed"
+// @Param q query string false "Domain contains (case-insensitive)"
+// @Success 200 {array} dto.DomainResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "db error"
+// @Router /dns/domains [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListDomains(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ q := db.Model(&models.Domain{}).Where("organization_id = ?", orgID)
+ if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("domain_name"))); v != "" {
+ q = q.Where("LOWER(domain_name) = ?", v)
+ }
+ if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); v != "" {
+ q = q.Where("status = ?", v)
+ }
+ if needle := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))); needle != "" {
+ q = q.Where("LOWER(domain_name) LIKE ?", "%"+needle+"%")
+ }
+
+ var rows []models.Domain
+ if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ out := make([]dto.DomainResponse, 0, len(rows))
+ for i := range rows {
+ out = append(out, domainOut(&rows[i]))
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetDomain godoc
+//
+// @ID GetDomain
+// @Summary Get a domain (org scoped)
+// @Tags DNS
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Domain ID (UUID)"
+// @Success 200 {object} dto.DomainResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /dns/domains/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetDomain(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+ var row models.Domain
+ if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, domainOut(&row))
+ }
+}
+
+// CreateDomain godoc
+//
+// @ID CreateDomain
+// @Summary Create a domain (org scoped)
+// @Description Creates a domain bound to a Route 53 scoped credential. Archer will backfill ZoneID if omitted.
+// @Tags DNS
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateDomainRequest true "Domain payload"
+// @Success 201 {object} dto.DomainResponse
+// @Failure 400 {string} string "validation error"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "db error"
+// @Router /dns/domains [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateDomain(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ var in dto.CreateDomainRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+ if err := dto.DNSValidate(in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
+ return
+ }
+ credID, _ := uuid.Parse(in.CredentialID)
+ if err := mustSameOrgDomainWithCredential(db, orgID, credID); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_credential", err.Error())
+ return
+ }
+
+ row := &models.Domain{
+ OrganizationID: orgID,
+ DomainName: normLowerNoDot(in.DomainName),
+ ZoneID: strings.TrimSpace(in.ZoneID),
+ Status: "pending",
+ LastError: "",
+ CredentialID: credID,
+ }
+ if err := db.Create(row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusCreated, domainOut(row))
+ }
+}
+
+// UpdateDomain godoc
+//
+// @ID UpdateDomain
+// @Summary Update a domain (org scoped)
+// @Tags DNS
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Domain ID (UUID)"
+// @Param body body dto.UpdateDomainRequest true "Fields to update"
+// @Success 200 {object} dto.DomainResponse
+// @Failure 400 {string} string "validation error"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /dns/domains/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateDomain(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+ var row models.Domain
+ if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ var in dto.UpdateDomainRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+ if err := dto.DNSValidate(in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
+ return
+ }
+ if in.DomainName != nil {
+ row.DomainName = normLowerNoDot(*in.DomainName)
+ }
+ if in.CredentialID != nil {
+ credID, _ := uuid.Parse(*in.CredentialID)
+ if err := mustSameOrgDomainWithCredential(db, orgID, credID); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_credential", err.Error())
+ return
+ }
+ row.CredentialID = credID
+ row.Status = "pending"
+ row.LastError = ""
+ }
+ if in.ZoneID != nil {
+ row.ZoneID = strings.TrimSpace(*in.ZoneID)
+ }
+ if in.Status != nil {
+ row.Status = *in.Status
+ if row.Status == "pending" {
+ row.LastError = ""
+ }
+ }
+ if err := db.Save(&row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, domainOut(&row))
+ }
+}
+
+// DeleteDomain godoc
+//
+// @ID DeleteDomain
+// @Summary Delete a domain
+// @Tags DNS
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Domain ID (UUID)"
+// @Success 204
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /dns/domains/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteDomain(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+ res := db.Where("organization_id = ? AND id = ?", orgID, id).Delete(&models.Domain{})
+ if res.Error != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
+ return
+ }
+ if res.RowsAffected == 0 {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// ---------- Record Set Handlers ----------
+
+// ListRecordSets godoc
+//
+// @ID ListRecordSets
+// @Summary List record sets for a domain
+// @Description Filters: `name`, `type`, `status`.
+// @Tags DNS
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param domain_id path string true "Domain ID (UUID)"
+// @Param name query string false "Exact relative name or FQDN (server normalizes)"
+// @Param type query string false "RR type (A, AAAA, CNAME, TXT, MX, NS, SRV, CAA)"
+// @Param status query string false "pending|provisioning|ready|failed"
+// @Success 200 {array} dto.RecordSetResponse
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "domain not found"
+// @Router /dns/domains/{domain_id}/records [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListRecordSets(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ did, err := uuid.Parse(chi.URLParam(r, "domain_id"))
+ if err != nil {
+ log.Info().Msg(err.Error())
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid domain UUID:")
+ return
+ }
+ var domain models.Domain
+ if err := db.Where("organization_id = ? AND id = ?", orgID, did).First(&domain).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ q := db.Model(&models.RecordSet{}).Where("domain_id = ?", did)
+ if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("name"))); v != "" {
+ dn := strings.ToLower(domain.DomainName)
+ rel := v
+ // normalize apex or FQDN into relative
+ if v == dn || v == dn+"." {
+ rel = ""
+ } else {
+ rel = strings.TrimSuffix(v, "."+dn)
+ rel = normLowerNoDot(rel)
+ }
+ q = q.Where("LOWER(name) = ?", rel)
+ }
+ if v := strings.TrimSpace(strings.ToUpper(r.URL.Query().Get("type"))); v != "" {
+ q = q.Where("type = ?", v)
+ }
+ if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); v != "" {
+ q = q.Where("status = ?", v)
+ }
+
+ var rows []models.RecordSet
+ if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ out := make([]dto.RecordSetResponse, 0, len(rows))
+ for i := range rows {
+ out = append(out, recordOut(&rows[i]))
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetRecordSet godoc
+//
+// @ID GetRecordSet
+// @Summary Get a record set (org scoped)
+// @Tags DNS
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Record Set ID (UUID)"
+// @Success 200 {object} dto.RecordSetResponse
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /dns/records/{id} [get]
+func GetRecordSet(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+
+ var row models.RecordSet
+ if err := db.
+ Joins("Domain").
+ Where(`record_sets.id = ? AND "Domain"."organization_id" = ?`, id, orgID).
+ First(&row).Error; err != nil {
+
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, recordOut(&row))
+ }
+}
+
+// CreateRecordSet godoc
+//
+// @ID CreateRecordSet
+// @Summary Create a record set (pending; Archer will UPSERT to Route 53)
+// @Tags DNS
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param domain_id path string true "Domain ID (UUID)"
+// @Param body body dto.CreateRecordSetRequest true "Record set payload"
+// @Success 201 {object} dto.RecordSetResponse
+// @Failure 400 {string} string "validation error"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "domain not found"
+// @Router /dns/domains/{domain_id}/records [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateRecordSet(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ did, err := uuid.Parse(chi.URLParam(r, "domain_id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid domain UUID")
+ return
+ }
+ var domain models.Domain
+ if err := db.Where("organization_id = ? AND id = ?", orgID, did).First(&domain).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ var in dto.CreateRecordSetRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+ if err := dto.DNSValidate(in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
+ return
+ }
+ t := strings.ToUpper(in.Type)
+ if t == "CNAME" && len(in.Values) != 1 {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "CNAME requires exactly one value")
+ return
+ }
+
+ rel := normLowerNoDot(in.Name)
+ fq := fqdn(domain.DomainName, rel)
+
+ // Pre-flight: block duplicate tuple and protect from non-autoglue rows
+ var existing models.RecordSet
+ if err := db.Where("domain_id = ? AND LOWER(name) = ? AND type = ?",
+ domain.ID, strings.ToLower(rel), t).First(&existing).Error; err == nil {
+ if existing.Owner != "" && existing.Owner != "autoglue" {
+ utils.WriteError(w, http.StatusConflict, "ownership_conflict",
+ "record with the same (name,type) exists but is not owned by autoglue")
+ return
+ }
+ utils.WriteError(w, http.StatusConflict, "already_exists",
+ "a record with the same (name,type) already exists; use PATCH to modify")
+ return
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ valuesJSON, _ := json.Marshal(in.Values)
+ fp, err := computeFingerprint(domain.ZoneID, fq, t, in.TTL, datatypes.JSON(valuesJSON))
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "fingerprint_error", err.Error())
+ return
+ }
+
+ row := &models.RecordSet{
+ DomainID: domain.ID,
+ Name: rel,
+ Type: t,
+ TTL: in.TTL,
+ Values: datatypes.JSON(valuesJSON),
+ Fingerprint: fp,
+ Status: "pending",
+ LastError: "",
+ Owner: "autoglue",
+ }
+ if err := db.Create(row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusCreated, recordOut(row))
+ }
+}
+
+// UpdateRecordSet godoc
+//
+// @ID UpdateRecordSet
+// @Summary Update a record set (flips to pending for reconciliation)
+// @Tags DNS
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Record Set ID (UUID)"
+// @Param body body dto.UpdateRecordSetRequest true "Fields to update"
+// @Success 200 {object} dto.RecordSetResponse
+// @Failure 400 {string} string "validation error"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /dns/records/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateRecordSet(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+
+ var row models.RecordSet
+ if err := db.
+ Joins("Domain").
+ Where(`record_sets.id = ? AND "Domain"."organization_id" = ?`, id, orgID).
+ First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ var domain models.Domain
+ if err := db.Where("id = ?", row.DomainID).First(&domain).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ var in dto.UpdateRecordSetRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+ if err := dto.DNSValidate(in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
+ return
+ }
+ if row.Owner != "" && row.Owner != "autoglue" {
+ utils.WriteError(w, http.StatusConflict, "ownership_conflict",
+ "record is not owned by autoglue; refuse to modify")
+ return
+ }
+
+ // Mutations
+ if in.Name != nil {
+ row.Name = normLowerNoDot(*in.Name)
+ }
+ if in.Type != nil {
+ row.Type = strings.ToUpper(*in.Type)
+ }
+ if in.TTL != nil {
+ row.TTL = in.TTL
+ }
+ if in.Values != nil {
+ t := row.Type
+ if in.Type != nil {
+ t = strings.ToUpper(*in.Type)
+ }
+ if t == "CNAME" && len(*in.Values) != 1 {
+ utils.WriteError(w, http.StatusBadRequest, "validation_error", "CNAME requires exactly one value")
+ return
+ }
+ b, _ := json.Marshal(*in.Values)
+ row.Values = datatypes.JSON(b)
+ }
+
+ if in.Status != nil {
+ row.Status = *in.Status
+ } else {
+ row.Status = "pending"
+ row.LastError = ""
+ }
+
+ fq := fqdn(domain.DomainName, row.Name)
+ fp, err := computeFingerprint(domain.ZoneID, fq, row.Type, row.TTL, row.Values)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "fingerprint_error", err.Error())
+ return
+ }
+ row.Fingerprint = fp
+
+ if err := db.Save(&row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, recordOut(&row))
+ }
+}
+
+// DeleteRecordSet godoc
+//
+// @ID DeleteRecordSet
+// @Summary Delete a record set (API removes row; worker can optionally handle external deletion policy)
+// @Tags DNS
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Record Set ID (UUID)"
+// @Success 204
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /dns/records/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteRecordSet(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+ sub := db.Model(&models.RecordSet{}).
+ Select("record_sets.id").
+ Joins("JOIN domains ON domains.id = record_sets.domain_id").
+ Where("record_sets.id = ? AND domains.organization_id = ?", id, orgID)
+
+ res := db.Where("id IN (?)", sub).Delete(&models.RecordSet{})
+ if res.Error != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
+ return
+ }
+ if res.RowsAffected == 0 {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// ---------- Out mappers ----------
+
+func domainOut(m *models.Domain) dto.DomainResponse {
+ return dto.DomainResponse{
+ ID: m.ID.String(),
+ OrganizationID: m.OrganizationID.String(),
+ DomainName: m.DomainName,
+ ZoneID: m.ZoneID,
+ Status: m.Status,
+ LastError: m.LastError,
+ CredentialID: m.CredentialID.String(),
+ CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+func recordOut(r *models.RecordSet) dto.RecordSetResponse {
+ vals := r.Values
+ if len(vals) == 0 {
+ vals = datatypes.JSON("[]")
+ }
+ return dto.RecordSetResponse{
+ ID: r.ID.String(),
+ DomainID: r.DomainID.String(),
+ Name: r.Name,
+ Type: r.Type,
+ TTL: r.TTL,
+ Values: []byte(vals),
+ Fingerprint: r.Fingerprint,
+ Status: r.Status,
+ LastError: r.LastError,
+ Owner: r.Owner,
+ CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+
+
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/utils"
+)
+
+type HealthStatus struct {
+ Status string `json:"status" example:"ok"`
+}
+
+// HealthCheck godoc
+//
+// @Summary Basic health check
+// @Description Returns 200 OK when the service is up
+// @Tags Health
+// @ID HealthCheck // operationId
+// @Produce json
+// @Success 200 {object} HealthStatus
+// @Router /healthz [get]
+func HealthCheck(w http.ResponseWriter, r *http.Request) {
+ utils.WriteJSON(w, http.StatusOK, HealthStatus{Status: "ok"})
+}
+
+
+
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/dyaksa/archer"
+ "github.com/glueops/autoglue/internal/bg"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// AdminListArcherJobs godoc
+//
+// @ID AdminListArcherJobs
+// @Summary List Archer jobs (admin)
+// @Description Paginated background jobs with optional filters. Search `q` may match id, type, error, payload (implementation-dependent).
+// @Tags ArcherAdmin
+// @Produce json
+// @Param status query string false "Filter by status" Enums(queued,running,succeeded,failed,canceled,retrying,scheduled)
+// @Param queue query string false "Filter by queue name / worker name"
+// @Param q query string false "Free-text search"
+// @Param page query int false "Page number" default(1)
+// @Param page_size query int false "Items per page" minimum(1) maximum(100) default(25)
+// @Success 200 {object} dto.PageJob
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "forbidden"
+// @Failure 500 {string} string "internal error"
+// @Router /admin/archer/jobs [get]
+// @Security BearerAuth
+func AdminListArcherJobs(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ status := strings.TrimSpace(r.URL.Query().Get("status"))
+ queue := strings.TrimSpace(r.URL.Query().Get("queue"))
+ q := strings.TrimSpace(r.URL.Query().Get("q"))
+ page := atoiDefault(r.URL.Query().Get("page"), 1)
+ size := clamp(atoiDefault(r.URL.Query().Get("page_size"), 25), 1, 100)
+
+ base := db.Model(&models.Job{})
+ if status != "" {
+ base = base.Where("status = ?", status)
+ }
+ if queue != "" {
+ base = base.Where("queue_name = ?", queue)
+ }
+ if q != "" {
+ like := "%" + q + "%"
+ base = base.Where(
+ db.Where("id ILIKE ?", like).
+ Or("queue_name ILIKE ?", like).
+ Or("COALESCE(last_error,'') ILIKE ?", like).
+ Or("CAST(arguments AS TEXT) ILIKE ?", like),
+ )
+ }
+
+ var total int64
+ if err := base.Count(&total).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ var rows []models.Job
+ offset := (page - 1) * size
+ if err := base.Order("created_at DESC").Limit(size).Offset(offset).Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ items := make([]dto.Job, 0, len(rows))
+ for _, m := range rows {
+ items = append(items, mapModelJobToDTO(m))
+ }
+
+ utils.WriteJSON(w, http.StatusOK, dto.PageJob{
+ Items: items,
+ Total: int(total),
+ Page: page,
+ PageSize: size,
+ })
+ }
+}
+
+// AdminEnqueueArcherJob godoc
+//
+// @ID AdminEnqueueArcherJob
+// @Summary Enqueue a new Archer job (admin)
+// @Description Create a job immediately or schedule it for the future via `run_at`.
+// @Tags ArcherAdmin
+// @Accept json
+// @Produce json
+// @Param body body dto.EnqueueRequest true "Job parameters"
+// @Success 200 {object} dto.Job
+// @Failure 400 {string} string "invalid json or missing fields"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "forbidden"
+// @Failure 500 {string} string "internal error"
+// @Router /admin/archer/jobs [post]
+// @Security BearerAuth
+func AdminEnqueueArcherJob(db *gorm.DB, jobs *bg.Jobs) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var in dto.EnqueueRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid json")
+ return
+ }
+ in.Queue = strings.TrimSpace(in.Queue)
+ in.Type = strings.TrimSpace(in.Type)
+ if in.Queue == "" || in.Type == "" {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "queue and type are required")
+ return
+ }
+
+ // Parse payload into generic 'args' for Archer.
+ var args any
+ if len(in.Payload) > 0 && string(in.Payload) != "null" {
+ if err := json.Unmarshal(in.Payload, &args); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "payload must be valid JSON")
+ return
+ }
+ }
+
+ id := uuid.NewString()
+
+ opts := []archer.FnOptions{
+ archer.WithMaxRetries(0), // adjust or expose in request if needed
+ }
+ if in.RunAt != nil {
+ opts = append(opts, archer.WithScheduleTime(*in.RunAt))
+ }
+
+ // Schedule with Archer (queue == worker name).
+ if _, err := jobs.Enqueue(context.Background(), id, in.Queue, args, opts...); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "enqueue_failed", err.Error())
+ return
+ }
+
+ // Read back the just-created row.
+ var m models.Job
+ if err := db.First(&m, "id = ?", id).Error; err != nil {
+ // Fallback: return a synthesized job if row not visible yet.
+ now := time.Now()
+ out := dto.Job{
+ ID: id,
+ Type: in.Type,
+ Queue: in.Queue,
+ Status: dto.StatusQueued,
+ Attempts: 0,
+ MaxAttempts: 0,
+ CreatedAt: now,
+ UpdatedAt: &now,
+ RunAt: in.RunAt,
+ Payload: args,
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, mapModelJobToDTO(m))
+ }
+}
+
+// AdminRetryArcherJob godoc
+//
+// @ID AdminRetryArcherJob
+// @Summary Retry a failed/canceled Archer job (admin)
+// @Description Marks the job retriable (DB flip). Swap this for an Archer admin call if you expose one.
+// @Tags ArcherAdmin
+// @Accept json
+// @Produce json
+// @Param id path string true "Job ID"
+// @Success 200 {object} dto.Job
+// @Failure 400 {string} string "invalid job or not eligible"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "forbidden"
+// @Failure 404 {string} string "not found"
+// @Router /admin/archer/jobs/{id}/retry [post]
+// @Security BearerAuth
+func AdminRetryArcherJob(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ var m models.Job
+ if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "job not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ // Only allow retry from failed/canceled (adjust as you see fit).
+ if m.Status != string(dto.StatusFailed) && m.Status != string(dto.StatusCanceled) {
+ utils.WriteError(w, http.StatusBadRequest, "not_eligible", "job is not failed/canceled")
+ return
+ }
+
+ // Reset to queued; clear started_at; bump updated_at.
+ now := time.Now()
+ if err := db.Model(&m).Updates(map[string]any{
+ "status": string(dto.StatusQueued),
+ "started_at": nil,
+ "updated_at": now,
+ }).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ // Re-read and return.
+ if err := db.First(&m, "id = ?", id).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, mapModelJobToDTO(m))
+ }
+}
+
+// AdminCancelArcherJob godoc
+//
+// @ID AdminCancelArcherJob
+// @Summary Cancel an Archer job (admin)
+// @Description Set job status to canceled if cancellable. For running jobs, this only affects future picks; wire to Archer if you need active kill.
+// @Tags ArcherAdmin
+// @Accept json
+// @Produce json
+// @Param id path string true "Job ID"
+// @Success 200 {object} dto.Job
+// @Failure 400 {string} string "invalid job or not cancellable"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "forbidden"
+// @Failure 404 {string} string "not found"
+// @Router /admin/archer/jobs/{id}/cancel [post]
+// @Security BearerAuth
+func AdminCancelArcherJob(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ var m models.Job
+ if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "job not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ // If already finished, bail.
+ switch m.Status {
+ case string(dto.StatusSucceeded), string(dto.StatusCanceled):
+ utils.WriteError(w, http.StatusBadRequest, "not_cancellable", "job already finished")
+ return
+ }
+
+ now := time.Now()
+ if err := db.Model(&m).Updates(map[string]any{
+ "status": string(dto.StatusCanceled),
+ "updated_at": now,
+ }).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ if err := db.First(&m, "id = ?", id).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, mapModelJobToDTO(m))
+ }
+}
+
+// AdminListArcherQueues godoc
+//
+// @ID AdminListArcherQueues
+// @Summary List Archer queues (admin)
+// @Description Summary metrics per queue (pending, running, failed, scheduled).
+// @Tags ArcherAdmin
+// @Produce json
+// @Success 200 {array} dto.QueueInfo
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "forbidden"
+// @Failure 500 {string} string "internal error"
+// @Router /admin/archer/queues [get]
+// @Security BearerAuth
+func AdminListArcherQueues(db *gorm.DB) http.HandlerFunc {
+ type row struct {
+ QueueName string
+ Pending int
+ Running int
+ Failed int
+ Scheduled int
+ }
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ var rows []row
+ // Use filtered aggregate; adjust status values if your Archer differs.
+ if err := db.
+ Raw(`
+ SELECT
+ queue_name,
+ COUNT(*) FILTER (WHERE status = 'queued') AS pending,
+ COUNT(*) FILTER (WHERE status = 'running') AS running,
+ COUNT(*) FILTER (WHERE status = 'failed') AS failed,
+ COUNT(*) FILTER (WHERE status = 'scheduled') AS scheduled
+ FROM jobs
+ GROUP BY queue_name
+ ORDER BY queue_name ASC
+ `).Scan(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ out := make([]dto.QueueInfo, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, dto.QueueInfo{
+ Name: r.QueueName,
+ Pending: r.Pending,
+ Running: r.Running,
+ Failed: r.Failed,
+ Scheduled: r.Scheduled,
+ })
+ }
+
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// Helpers
+func atoiDefault(s string, def int) int {
+ if s == "" {
+ return def
+ }
+ if n, err := strconv.Atoi(s); err == nil {
+ return n
+ }
+ return def
+}
+func clamp(n, lo, hi int) int {
+ if n < lo {
+ return lo
+ }
+ if n > hi {
+ return hi
+ }
+ return n
+}
+
+func mapModelJobToDTO(m models.Job) dto.Job {
+ var payload any
+ if len(m.Arguments) > 0 {
+ _ = json.Unmarshal([]byte(m.Arguments), &payload)
+ }
+
+ var updated *time.Time
+ if !m.UpdatedAt.IsZero() {
+ updated = &m.UpdatedAt
+ }
+
+ var runAt *time.Time
+ if !m.ScheduledAt.IsZero() {
+ rt := m.ScheduledAt
+ runAt = &rt
+ }
+
+ return dto.Job{
+ ID: m.ID,
+ // If you distinguish between queue and type elsewhere, set Type accordingly.
+ Type: m.QueueName,
+ Queue: m.QueueName,
+ Status: dto.JobStatus(m.Status),
+ Attempts: m.RetryCount,
+ MaxAttempts: m.MaxRetry,
+ CreatedAt: m.CreatedAt,
+ UpdatedAt: updated,
+ LastError: m.LastError,
+ RunAt: runAt,
+ Payload: payload,
+ }
+}
+
+
+
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/auth"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/utils"
+)
+
+type jwk struct {
+ Kty string `json:"kty"`
+ Use string `json:"use,omitempty"`
+ Kid string `json:"kid,omitempty"`
+ Alg string `json:"alg,omitempty"`
+ N string `json:"n,omitempty"` // RSA modulus (base64url)
+ E string `json:"e,omitempty"` // RSA exponent (base64url)
+ X string `json:"x,omitempty"` // Ed25519 public key (base64url)
+}
+
+type jwks struct {
+ Keys []jwk `json:"keys"`
+}
+
+// JWKSHandler godoc
+//
+// @ID getJWKS
+// @Summary Get JWKS
+// @Description Returns the JSON Web Key Set for token verification
+// @Tags Auth
+// @Produce json
+// @Success 200 {object} dto.JWKS
+// @Router /.well-known/jwks.json [get]
+func JWKSHandler(w http.ResponseWriter, _ *http.Request) {
+ out := dto.JWKS{Keys: make([]dto.JWK, 0)}
+
+ auth.KcCopy(func(pub map[string]interface{}) {
+ for kid, pk := range pub {
+ meta := auth.MetaFor(kid)
+ params, kty := auth.PubToJWK(kid, meta.Alg, pk)
+ if kty == "" {
+ continue
+ }
+ j := dto.JWK{
+ Kty: kty,
+ Use: "sig",
+ Kid: kid,
+ Alg: meta.Alg,
+ N: params["n"],
+ E: params["e"],
+ X: params["x"],
+ }
+ out.Keys = append(out.Keys, j)
+ }
+ })
+ utils.WriteJSON(w, http.StatusOK, out)
+}
+
+
+
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/common"
+ "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"
+)
+
+// ListLabels godoc
+//
+// @ID ListLabels
+// @Summary List node labels (org scoped)
+// @Description Returns node labels for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node groups.
+// @Tags Labels
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param key query string false "Exact key"
+// @Param value query string false "Exact value"
+// @Param q query string false "Key contains (case-insensitive)"
+// @Success 200 {array} dto.LabelResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list node taints"
+// @Router /labels [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListLabels(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ q := db.Where("organization_id = ?", orgID)
+
+ if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
+ q = q.Where(`key = ?`, key)
+ }
+ if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
+ q = q.Where(`value = ?`, val)
+ }
+ if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
+ q = q.Where(`key ILIKE ?`, "%"+needle+"%")
+ }
+ var out []dto.LabelResponse
+ if err := q.Model(&models.Label{}).Order("created_at DESC").Scan(&out).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ if out == nil {
+ out = []dto.LabelResponse{}
+ }
+
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetLabel godoc
+//
+// @ID GetLabel
+// @Summary Get label by ID (org scoped)
+// @Description Returns one label.
+// @Tags Labels
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Label ID (UUID)"
+// @Success 200 {object} dto.LabelResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /labels/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetLabel(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
+ return
+ }
+
+ var out dto.LabelResponse
+ if err := db.Model(&models.Label{}).Where("id = ? AND organization_id = ?", id, orgID).Limit(1).Scan(&out).Error; err != nil {
+ if out.ID == uuid.Nil {
+ utils.WriteError(w, http.StatusNotFound, "label_not_found", "label not found")
+ return
+ }
+ }
+
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// CreateLabel godoc
+//
+// @ID CreateLabel
+// @Summary Create label (org scoped)
+// @Description Creates a label.
+// @Tags Labels
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateLabelRequest true "Label payload"
+// @Success 201 {object} dto.LabelResponse
+// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "create failed"
+// @Router /labels [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateLabel(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 req dto.CreateLabelRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ req.Key = strings.TrimSpace(req.Key)
+ req.Value = strings.TrimSpace(req.Value)
+
+ if req.Key == "" || req.Value == "" {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value")
+ return
+ }
+
+ l := models.Label{
+ AuditFields: common.AuditFields{OrganizationID: orgID},
+ Key: req.Key,
+ Value: req.Value,
+ }
+ if err := db.Create(&l).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := dto.LabelResponse{
+ AuditFields: l.AuditFields,
+ Key: l.Key,
+ Value: l.Value,
+ }
+ utils.WriteJSON(w, http.StatusCreated, out)
+ }
+}
+
+// UpdateLabel godoc
+// UpdateLabel godoc
+//
+// @ID UpdateLabel
+// @Summary Update label (org scoped)
+// @Description Partially update label fields.
+// @Tags Labels
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Label ID (UUID)"
+// @Param body body dto.UpdateLabelRequest true "Fields to update"
+// @Success 200 {object} dto.LabelResponse
+// @Failure 400 {string} string "invalid id / invalid json"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "update failed"
+// @Router /labels/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateLabel(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
+ return
+ }
+
+ var l models.Label
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&l).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "label_not_found", "label not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var req dto.UpdateLabelRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ if req.Key != nil {
+ l.Key = strings.TrimSpace(*req.Key)
+ }
+ if req.Value != nil {
+ l.Value = strings.TrimSpace(*req.Value)
+ }
+
+ if err := db.Save(&l).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := dto.LabelResponse{
+ AuditFields: l.AuditFields,
+ Key: l.Key,
+ Value: l.Value,
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// DeleteLabel godoc
+//
+// @ID DeleteLabel
+// @Summary Delete label (org scoped)
+// @Description Permanently deletes the label.
+// @Tags Labels
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Label ID (UUID)"
+// @Success 204 "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "delete failed"
+// @Router /labels/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteLabel(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
+ return
+ }
+
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Label{}).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+
+
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ListLoadBalancers godoc
+//
+// @ID ListLoadBalancers
+// @Summary List load balancers (org scoped)
+// @Description Returns load balancers for the organization in X-Org-ID.
+// @Tags LoadBalancers
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Success 200 {array} dto.LoadBalancerResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list clusters"
+// @Router /load-balancers [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListLoadBalancers(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ var rows []models.LoadBalancer
+ if err := db.Where("organization_id = ?", orgID).Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ out := make([]dto.LoadBalancerResponse, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, loadBalancerOut(&row))
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetLoadBalancer godoc
+//
+// @ID GetLoadBalancers
+// @Summary Get a load balancer (org scoped)
+// @Description Returns load balancer for the organization in X-Org-ID.
+// @Tags LoadBalancers
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "LoadBalancer ID (UUID)"
+// @Success 200 {array} dto.LoadBalancerResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list clusters"
+// @Router /load-balancers/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetLoadBalancer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+
+ var row models.LoadBalancer
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ out := loadBalancerOut(&row)
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// CreateLoadBalancer godoc
+//
+// @ID CreateLoadBalancer
+// @Summary Create a load balancer
+// @Tags LoadBalancers
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateLoadBalancerRequest true "Record set payload"
+// @Success 201 {object} dto.LoadBalancerResponse
+// @Failure 400 {string} string "validation error"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "domain not found"
+// @Router /load-balancers [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateLoadBalancer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ var in dto.CreateLoadBalancerRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+
+ if strings.ToLower(in.Kind) != "glueops" && strings.ToLower(in.Kind) != "public" {
+ fmt.Println(in.Kind)
+ utils.WriteError(w, http.StatusBadRequest, "bad_kind", "invalid kind only 'glueops' or 'public'")
+ return
+ }
+
+ row := &models.LoadBalancer{
+ OrganizationID: orgID,
+ Name: in.Name,
+ Kind: strings.ToLower(in.Kind),
+ PublicIPAddress: in.PublicIPAddress,
+ PrivateIPAddress: in.PrivateIPAddress,
+ }
+ if err := db.Create(row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusCreated, loadBalancerOut(row))
+ }
+}
+
+// UpdateLoadBalancer godoc
+//
+// @ID UpdateLoadBalancer
+// @Summary Update a load balancer (org scoped)
+// @Tags LoadBalancers
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Load Balancer ID (UUID)"
+// @Param body body dto.UpdateLoadBalancerRequest true "Fields to update"
+// @Success 200 {object} dto.LoadBalancerResponse
+// @Failure 400 {string} string "validation error"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /load-balancers/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateLoadBalancer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+
+ row := &models.LoadBalancer{}
+ if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ var in dto.UpdateLoadBalancerRequest
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
+ return
+ }
+ if in.Name != nil {
+ row.Name = *in.Name
+ }
+ if in.Kind != nil {
+ fmt.Println(*in.Kind)
+ if strings.ToLower(*in.Kind) != "glueops" && strings.ToLower(*in.Kind) != "public" {
+ utils.WriteError(w, http.StatusBadRequest, "bad_kind", "invalid kind only 'glueops' or 'public'")
+ return
+ }
+ row.Kind = strings.ToLower(*in.Kind)
+ }
+ if in.PublicIPAddress != nil {
+ row.PublicIPAddress = *in.PublicIPAddress
+ }
+ if in.PrivateIPAddress != nil {
+ row.PrivateIPAddress = *in.PrivateIPAddress
+ }
+ if err := db.Save(row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, loadBalancerOut(row))
+
+ }
+}
+
+// DeleteLoadBalancer godoc
+//
+// @ID DeleteLoadBalancer
+// @Summary Delete a load balancer
+// @Tags LoadBalancers
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Load Balancer ID (UUID)"
+// @Success 204
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Router /load-balancers/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteLoadBalancer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
+ return
+ }
+
+ row := &models.LoadBalancer{}
+ if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+
+ if err := db.Delete(row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// ---------- Out mappers ----------
+
+func loadBalancerOut(m *models.LoadBalancer) dto.LoadBalancerResponse {
+ return dto.LoadBalancerResponse{
+ ID: m.ID,
+ OrganizationID: m.OrganizationID,
+ Name: m.Name,
+ Kind: m.Kind,
+ PublicIPAddress: m.PublicIPAddress,
+ PrivateIPAddress: m.PrivateIPAddress,
+ CreatedAt: m.CreatedAt.UTC(),
+ UpdatedAt: m.UpdatedAt.UTC(),
+ }
+}
+
+
+
+package handlers
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/auth"
+ "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"
+)
+
+type userAPIKeyOut struct {
+ ID uuid.UUID `json:"id" format:"uuid"`
+ Name *string `json:"name,omitempty"`
+ Scope string `json:"scope"` // "user"
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
+ Plain *string `json:"plain,omitempty"` // Shown only on create:
+}
+
+// ListUserAPIKeys godoc
+//
+// @ID ListUserAPIKeys
+// @Summary List my API keys
+// @Tags MeAPIKeys
+// @Produce json
+// @Success 200 {array} userAPIKeyOut
+// @Router /me/api-keys [get]
+// @Security BearerAuth
+// @Security ApiKeyAuth
+func ListUserAPIKeys(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := httpmiddleware.UserFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
+ return
+ }
+ var rows []models.APIKey
+ if err := db.
+ Where("scope = ? AND user_id = ?", "user", u.ID).
+ Order("created_at desc").
+ Find(&rows).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+ out := make([]userAPIKeyOut, 0, len(rows))
+ for _, k := range rows {
+ out = append(out, toUserKeyOut(k, nil))
+ }
+ utils.WriteJSON(w, 200, out)
+ }
+}
+
+type createUserKeyRequest struct {
+ Name string `json:"name,omitempty"`
+ ExpiresInHours *int `json:"expires_in_hours,omitempty"` // optional TTL
+}
+
+// CreateUserAPIKey godoc
+//
+// @ID CreateUserAPIKey
+// @Summary Create a new user API key
+// @Description Returns the plaintext key once. Store it securely on the client side.
+// @Tags MeAPIKeys
+// @Accept json
+// @Produce json
+// @Param body body createUserKeyRequest true "Key options"
+// @Success 201 {object} userAPIKeyOut
+// @Router /me/api-keys [post]
+// @Security BearerAuth
+// @Security ApiKeyAuth
+func CreateUserAPIKey(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := httpmiddleware.UserFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
+ return
+ }
+ var req createUserKeyRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, 400, "invalid_json", err.Error())
+ return
+ }
+
+ plain, err := generateUserAPIKey()
+ if err != nil {
+ utils.WriteError(w, 500, "gen_failed", err.Error())
+ return
+ }
+ hash := auth.SHA256Hex(plain)
+
+ var exp *time.Time
+ if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
+ t := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
+ exp = &t
+ }
+
+ rec := models.APIKey{
+ Scope: "user",
+ UserID: &u.ID,
+ KeyHash: hash,
+ Name: req.Name, // if field exists
+ ExpiresAt: exp,
+ // SecretHash: nil (not used for user keys)
+ }
+ if err := db.Create(&rec).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, http.StatusCreated, toUserKeyOut(rec, &plain))
+ }
+}
+
+// DeleteUserAPIKey godoc
+//
+// @ID DeleteUserAPIKey
+// @Summary Delete a user API key
+// @Tags MeAPIKeys
+// @Produce json
+// @Param id path string true "Key ID (UUID)"
+// @Success 204 "No Content"
+// @Router /me/api-keys/{id} [delete]
+// @Security BearerAuth
+func DeleteUserAPIKey(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := httpmiddleware.UserFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, 400, "invalid_id", "must be uuid")
+ return
+ }
+ tx := db.Where("id = ? AND scope = ? AND user_id = ?", id, "user", u.ID).
+ Delete(&models.APIKey{})
+ if tx.Error != nil {
+ utils.WriteError(w, 500, "db_error", tx.Error.Error())
+ return
+ }
+ if tx.RowsAffected == 0 {
+ utils.WriteError(w, 404, "not_found", "key not found")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func toUserKeyOut(k models.APIKey, plain *string) userAPIKeyOut {
+ return userAPIKeyOut{
+ ID: k.ID,
+ Name: &k.Name, // if your model has it; else remove
+ Scope: k.Scope,
+ CreatedAt: k.CreatedAt,
+ ExpiresAt: k.ExpiresAt,
+ LastUsedAt: k.LastUsedAt, // if present; else remove
+ Plain: plain,
+ }
+}
+
+func generateUserAPIKey() (string, error) {
+ // 24 random bytes β base64url (no padding), with "u_" prefix
+ b := make([]byte, 24)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ s := base64.RawURLEncoding.EncodeToString(b)
+ return "u_" + s, nil
+}
+
+
+
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "gorm.io/gorm"
+)
+
+type meResponse struct {
+ models.User `json:",inline"`
+ Emails []models.UserEmail `json:"emails"`
+ Organizations []models.Organization `json:"organizations"`
+}
+
+// GetMe godoc
+//
+// @ID GetMe
+// @Summary Get current user profile
+// @Tags Me
+// @Produce json
+// @Success 200 {object} meResponse
+// @Router /me [get]
+// @Security BearerAuth
+// @Security ApiKeyAuth
+func GetMe(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := httpmiddleware.UserFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
+ return
+ }
+
+ var user models.User
+ if err := db.First(&user, "id = ? AND is_disabled = false", u.ID).Error; err != nil {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user not found/disabled")
+ return
+ }
+
+ var emails []models.UserEmail
+ _ = db.Preload("User").Where("user_id = ?", user.ID).Order("is_primary desc, created_at asc").Find(&emails).Error
+
+ var orgs []models.Organization
+ {
+ var rows []models.Membership
+ _ = db.Where("user_id = ?", user.ID).Find(&rows).Error
+ if len(rows) > 0 {
+ var ids []interface{}
+ for _, m := range rows {
+ ids = append(ids, m.OrganizationID)
+ }
+ _ = db.Find(&orgs, "id IN ?", ids).Error
+ }
+ }
+
+ utils.WriteJSON(w, http.StatusOK, meResponse{
+ User: user,
+ Emails: emails,
+ Organizations: orgs,
+ })
+ }
+}
+
+type updateMeRequest struct {
+ DisplayName *string `json:"display_name,omitempty"`
+ // You can add more editable fields here (timezone, avatar, etc)
+}
+
+// UpdateMe godoc
+//
+// @ID UpdateMe
+// @Summary Update current user profile
+// @Tags Me
+// @Accept json
+// @Produce json
+// @Param body body updateMeRequest true "Patch profile"
+// @Success 200 {object} models.User
+// @Router /me [patch]
+// @Security BearerAuth
+// @Security ApiKeyAuth
+func UpdateMe(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := httpmiddleware.UserFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
+ return
+ }
+
+ var req updateMeRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_json", err.Error())
+ }
+
+ updates := map[string]interface{}{}
+
+ if req.DisplayName != nil {
+ updates["display_name"] = req.DisplayName
+ }
+
+ if len(updates) == 0 {
+ var user models.User
+ if err := db.First(&user, "id = ?", u.ID).Error; err != nil {
+ utils.WriteError(w, 404, "not_found", "user")
+ return
+ }
+ utils.WriteJSON(w, 200, user)
+ return
+ }
+
+ if err := db.Model(&models.User{}).Where("id = ?", u.ID).Updates(updates).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+
+ var out models.User
+ _ = db.First(&out, "id = ?", u.ID).Error
+ utils.WriteJSON(w, 200, out)
+ }
+}
+
+
+
+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
+}
+
+
+
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/common"
+ "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"
+)
+
+// -- Node Pools Core
+
+// ListNodePools godoc
+//
+// @ID ListNodePools
+// @Summary List node pools (org scoped)
+// @Description Returns node pools for the organization in X-Org-ID.
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param q query string false "Name contains (case-insensitive)"
+// @Success 200 {array} dto.NodePoolResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list node pools"
+// @Router /node-pools [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListNodePools(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ q := db.Where("organization_id = ?", orgID)
+ if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
+ q = q.Where("name LIKE ?", "%"+needle+"%")
+ }
+
+ var pools []models.NodePool
+ if err := q.
+ Preload("Servers").
+ Preload("Labels").
+ Preload("Taints").
+ Preload("Annotations").
+ Order("created_at DESC").
+ Find(&pools).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := make([]dto.NodePoolResponse, 0, len(pools))
+ for _, p := range pools {
+ npr := dto.NodePoolResponse{
+ AuditFields: p.AuditFields,
+ Name: p.Name,
+ Role: dto.NodeRole(p.Role),
+ Servers: make([]dto.ServerResponse, 0, len(p.Servers)),
+ Labels: make([]dto.LabelResponse, 0, len(p.Labels)),
+ Taints: make([]dto.TaintResponse, 0, len(p.Taints)),
+ Annotations: make([]dto.AnnotationResponse, 0, len(p.Annotations)),
+ }
+ //Servers
+ for _, s := range p.Servers {
+ outSrv := dto.ServerResponse{
+ ID: s.ID,
+ Hostname: s.Hostname,
+ PublicIPAddress: s.PublicIPAddress,
+ PrivateIPAddress: s.PrivateIPAddress,
+ OrganizationID: s.OrganizationID,
+ SshKeyID: s.SshKeyID,
+ SSHUser: s.SSHUser,
+ Role: s.Role,
+ Status: s.Status,
+ CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
+ // add more fields as needed
+ }
+ npr.Servers = append(npr.Servers, outSrv)
+ }
+ //Labels
+ for _, l := range p.Labels {
+ outL := dto.LabelResponse{
+ AuditFields: common.AuditFields{
+ ID: l.ID,
+ OrganizationID: l.OrganizationID,
+ CreatedAt: l.CreatedAt,
+ UpdatedAt: l.UpdatedAt,
+ },
+ Key: l.Key,
+ Value: l.Value,
+ }
+ npr.Labels = append(npr.Labels, outL)
+ }
+ // Taints
+ for _, t := range p.Taints {
+ outT := dto.TaintResponse{
+ ID: t.ID,
+ OrganizationID: t.OrganizationID,
+ CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
+ Key: t.Key,
+ Value: t.Value,
+ Effect: t.Effect,
+ }
+ npr.Taints = append(npr.Taints, outT)
+ }
+ // Annotations
+ for _, a := range p.Annotations {
+ outA := dto.AnnotationResponse{
+ AuditFields: common.AuditFields{
+ ID: a.ID,
+ OrganizationID: a.OrganizationID,
+ CreatedAt: a.CreatedAt,
+ UpdatedAt: a.UpdatedAt,
+ },
+ Key: a.Key,
+ Value: a.Value,
+ }
+ npr.Annotations = append(npr.Annotations, outA)
+ }
+
+ out = append(out, npr)
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetNodePool godoc
+//
+// @ID GetNodePool
+// @Summary Get node pool by ID (org scoped)
+// @Description Returns one node pool. Add `include=servers` to include servers.
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Success 200 {object} dto.NodePoolResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /node-pools/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetNodePool(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
+ return
+ }
+
+ var out dto.NodePoolResponse
+ if err := db.Model(&models.NodePool{}).Preload("Servers").Where("id = ? AND organization_id = ?", id, orgID).First(&out, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// CreateNodePool godoc
+//
+// @ID CreateNodePool
+// @Summary Create node pool (org scoped)
+// @Description Creates a node pool. Optionally attach initial servers.
+// @Tags NodePools
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateNodePoolRequest true "NodePool payload"
+// @Success 201 {object} dto.NodePoolResponse
+// @Failure 400 {string} string "invalid json / missing fields / invalid server_ids"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "create failed"
+// @Router /node-pools [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateNodePool(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 req dto.CreateNodePoolRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ req.Name = strings.TrimSpace(req.Name)
+ req.Role = dto.NodeRole(strings.TrimSpace(string(req.Role)))
+
+ if req.Name == "" || req.Role == "" {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing name/role")
+ return
+ }
+
+ n := models.NodePool{
+ AuditFields: common.AuditFields{
+ OrganizationID: orgID,
+ },
+ Name: req.Name,
+ Role: string(req.Role),
+ }
+
+ if err := db.Create(&n).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := dto.NodePoolResponse{
+ AuditFields: n.AuditFields,
+ Name: n.Name,
+ Role: dto.NodeRole(n.Role),
+ }
+ utils.WriteJSON(w, http.StatusCreated, out)
+ }
+}
+
+// UpdateNodePool godoc
+//
+// @ID UpdateNodePool
+// @Summary Update node pool (org scoped)
+// @Description Partially update node pool fields.
+// @Tags NodePools
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Param body body dto.UpdateNodePoolRequest true "Fields to update"
+// @Success 200 {object} dto.NodePoolResponse
+// @Failure 400 {string} string "invalid id / invalid json"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "update failed"
+// @Router /node-pools/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateNodePool(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
+ return
+ }
+
+ var n models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&n).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var req dto.UpdateNodePoolRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ if req.Name != nil {
+ n.Name = strings.TrimSpace(*req.Name)
+ }
+ if req.Role != nil {
+ v := dto.NodeRole(strings.TrimSpace(string(*req.Role)))
+ n.Role = string(v)
+ }
+
+ if err := db.Save(&n).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ out := dto.NodePoolResponse{
+ AuditFields: n.AuditFields,
+ Name: n.Name,
+ Role: dto.NodeRole(n.Role),
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// DeleteNodePool godoc
+//
+// @ID DeleteNodePool
+// @Summary Delete node pool (org scoped)
+// @Description Permanently deletes the node pool.
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Success 204 "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "delete failed"
+// @Router /node-pools/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteNodePool(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
+ return
+ }
+
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.NodePool{}).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// -- Node Pools Servers
+
+// ListNodePoolServers godoc
+//
+// @ID ListNodePoolServers
+// @Summary List servers attached to a node pool (org scoped)
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Success 200 {array} dto.ServerResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /node-pools/{id}/servers [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListNodePoolServers(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Servers").First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := make([]dto.ServerResponse, 0, len(np.Servers))
+ for _, server := range np.Servers {
+ out = append(out, dto.ServerResponse{
+ ID: server.ID,
+ OrganizationID: server.OrganizationID,
+ Hostname: server.Hostname,
+ PrivateIPAddress: server.PrivateIPAddress,
+ PublicIPAddress: server.PublicIPAddress,
+ Role: server.Role,
+ SshKeyID: server.SshKeyID,
+ SSHUser: server.SSHUser,
+ Status: server.Status,
+ CreatedAt: server.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: server.UpdatedAt.UTC().Format(time.RFC3339),
+ })
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// AttachNodePoolServers godoc
+//
+// @ID AttachNodePoolServers
+// @Summary Attach servers to a node pool (org scoped)
+// @Tags NodePools
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Param body body dto.AttachServersRequest true "Server IDs to attach"
+// @Success 204 {string} string "No Content"
+// @Failure 400 {string} string "invalid id / invalid server_ids"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "attach failed"
+// @Router /node-pools/{id}/servers [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachNodePoolServers(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var req dto.AttachServersRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ ids, err := parseUUIDs(req.ServerIDs)
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid server_ids")
+ return
+ }
+
+ if len(ids) == 0 {
+ // nothing to attach
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach")
+ return
+ }
+
+ // validate IDs belong to org
+ if err := ensureServersBelongToOrg(orgID, ids, db); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid server_ids for this organization")
+ return
+ }
+
+ // fetch only the requested servers
+ var servers []models.Server
+ if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&servers).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error")
+ return
+ }
+
+ if len(servers) == 0 {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ if err := db.Model(&np).Association("Servers").Append(&servers); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed")
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// DetachNodePoolServer godoc
+//
+// @ID DetachNodePoolServer
+// @Summary Detach one server from a node pool (org scoped)
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Param serverId path string true "Server ID (UUID)"
+// @Success 204 {string} string "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "detach failed"
+// @Router /node-pools/{id}/servers/{serverId} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachNodePoolServer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+ serverId, err := uuid.Parse(chi.URLParam(r, "serverId"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "server id required")
+ return
+ }
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var s models.Server
+ if err := db.Where("id = ? AND organization_id = ?", serverId, orgID).First(&s).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if err := db.Model(&np).Association("Servers").Delete(&s); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "detach error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// -- Node Pools Taints
+
+// ListNodePoolTaints godoc
+//
+// @ID ListNodePoolTaints
+// @Summary List taints attached to a node pool (org scoped)
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Success 200 {array} dto.TaintResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /node-pools/{id}/taints [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListNodePoolTaints(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Taints").First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := make([]dto.TaintResponse, 0, len(np.Taints))
+ for _, t := range np.Taints {
+ out = append(out, dto.TaintResponse{
+ ID: t.ID,
+ Key: t.Key,
+ Value: t.Value,
+ Effect: t.Effect,
+ })
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// AttachNodePoolTaints godoc
+//
+// @ID AttachNodePoolTaints
+// @Summary Attach taints to a node pool (org scoped)
+// @Tags NodePools
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Param body body dto.AttachTaintsRequest true "Taint IDs to attach"
+// @Success 204 {string} string "No Content"
+// @Failure 400 {string} string "invalid id / invalid taint_ids"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "attach failed"
+// @Router /node-pools/{id}/taints [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachNodePoolTaints(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var req dto.AttachTaintsRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ ids, err := parseUUIDs(req.TaintIDs)
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid taint_ids")
+ return
+ }
+
+ if len(ids) == 0 {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach")
+ return
+ }
+
+ // validate IDs belong to org
+ if err := ensureTaintsBelongToOrg(orgID, ids, db); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid taint_ids for this organization")
+ return
+ }
+
+ var taints []models.Taint
+ if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&taints).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error")
+ return
+ }
+
+ if len(taints) == 0 {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ if err := db.Model(&np).Association("Taints").Append(&taints); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// DetachNodePoolTaint godoc
+//
+// @ID DetachNodePoolTaint
+// @Summary Detach one taint from a node pool (org scoped)
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Param taintId path string true "Taint ID (UUID)"
+// @Success 204 {string} string "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "detach failed"
+// @Router /node-pools/{id}/taints/{taintId} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachNodePoolTaint(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+ taintId, err := uuid.Parse(chi.URLParam(r, "taintId"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "taintId_required", "taint id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var t models.Taint
+ if err := db.Where("id = ? AND organization_id = ?", taintId, orgID).First(&t).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "taint_not_found", "taint not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if err := db.Model(&np).Association("Taints").Delete(&t); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// -- Node Pools Labels
+
+// ListNodePoolLabels godoc
+//
+// @ID ListNodePoolLabels
+// @Summary List labels attached to a node pool (org scoped)
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Label Pool ID (UUID)"
+// @Success 200 {array} dto.LabelResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /node-pools/{id}/labels [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListNodePoolLabels(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Labels").First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := make([]dto.LabelResponse, 0, len(np.Taints))
+ for _, label := range np.Labels {
+ out = append(out, dto.LabelResponse{
+ AuditFields: common.AuditFields{
+ ID: label.ID,
+ OrganizationID: label.OrganizationID,
+ CreatedAt: label.CreatedAt,
+ UpdatedAt: label.UpdatedAt,
+ },
+ Key: label.Key,
+ Value: label.Value,
+ })
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// AttachNodePoolLabels godoc
+//
+// @ID AttachNodePoolLabels
+// @Summary Attach labels to a node pool (org scoped)
+// @Tags NodePools
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Param body body dto.AttachLabelsRequest true "Label IDs to attach"
+// @Success 204 {string} string "No Content"
+// @Failure 400 {string} string "invalid id / invalid server_ids"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "attach failed"
+// @Router /node-pools/{id}/labels [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachNodePoolLabels(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var req dto.AttachLabelsRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ ids, err := parseUUIDs(req.LabelIDs)
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid label_ids")
+ return
+ }
+
+ if len(ids) == 0 {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach")
+ return
+ }
+
+ if err := ensureLabelsBelongToOrg(orgID, ids, db); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid label_ids for this organization")
+ }
+
+ var labels []models.Label
+ if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&labels).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error")
+ return
+ }
+
+ if len(labels) == 0 {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ if err := db.Model(&np).Association("Labels").Append(&labels); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// DetachNodePoolLabel godoc
+//
+// @ID DetachNodePoolLabel
+// @Summary Detach one label from a node pool (org scoped)
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Param labelId path string true "Label ID (UUID)"
+// @Success 204 {string} string "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "detach failed"
+// @Router /node-pools/{id}/labels/{labelId} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachNodePoolLabel(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+ labelId, err := uuid.Parse(chi.URLParam(r, "labelId"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "labelId required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var l models.Label
+ if err := db.Where("id = ? AND organization_id = ?", labelId, orgID).First(&l).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "label_not_found", "label not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if err := db.Model(&np).Association("Labels").Delete(&l); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "detach error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// -- Node Pools Annotations
+
+// ListNodePoolAnnotations godoc
+//
+// @ID ListNodePoolAnnotations
+// @Summary List annotations attached to a node pool (org scoped)
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Success 200 {array} dto.AnnotationResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /node-pools/{id}/annotations [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListNodePoolAnnotations(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Labels").First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := make([]dto.AnnotationResponse, 0, len(np.Annotations))
+ for _, ann := range np.Annotations {
+ out = append(out, dto.AnnotationResponse{
+ AuditFields: common.AuditFields{
+ ID: ann.ID,
+ OrganizationID: ann.OrganizationID,
+ CreatedAt: ann.CreatedAt,
+ UpdatedAt: ann.UpdatedAt,
+ },
+ Key: ann.Key,
+ Value: ann.Value,
+ })
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// AttachNodePoolAnnotations godoc
+//
+// @ID AttachNodePoolAnnotations
+// @Summary Attach annotation to a node pool (org scoped)
+// @Tags NodePools
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Group ID (UUID)"
+// @Param body body dto.AttachAnnotationsRequest true "Annotation IDs to attach"
+// @Success 204 {string} string "No Content"
+// @Failure 400 {string} string "invalid id / invalid server_ids"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "attach failed"
+// @Router /node-pools/{id}/annotations [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func AttachNodePoolAnnotations(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var req dto.AttachAnnotationsRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ ids, err := parseUUIDs(req.AnnotationIDs)
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid annotation ids")
+ return
+ }
+
+ if len(ids) == 0 {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach")
+ return
+ }
+
+ if err := ensureAnnotaionsBelongToOrg(orgID, ids, db); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid annotation ids for this organization")
+ return
+ }
+
+ var ann []models.Annotation
+ if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&ann).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if len(ann) == 0 {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ if err := db.Model(&np).Association("Annotations").Append(&ann); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// DetachNodePoolAnnotation godoc
+//
+// @ID DetachNodePoolAnnotation
+// @Summary Detach one annotation from a node pool (org scoped)
+// @Tags NodePools
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Pool ID (UUID)"
+// @Param annotationId path string true "Annotation ID (UUID)"
+// @Success 204 {string} string "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "detach failed"
+// @Router /node-pools/{id}/annotations/{annotationId} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DetachNodePoolAnnotation(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
+ return
+ }
+ annotationId, err := uuid.Parse(chi.URLParam(r, "annotationId"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool annotation id required")
+ return
+ }
+
+ var np models.NodePool
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var ann []models.Annotation
+ if err := db.Where("id = ? AND organization_id = ?", annotationId, orgID).First(&ann).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "annotation_not_found", "annotation not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if err := db.Model(&np).Association("Annotations").Delete(&ann); err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// -- Helpers
+func parseUUIDs(ids []string) ([]uuid.UUID, error) {
+ out := make([]uuid.UUID, 0, len(ids))
+ for _, id := range ids {
+ u, err := uuid.Parse(id)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, u)
+ }
+ return out, nil
+}
+
+func ensureServersBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error {
+ var count int64
+ if err := db.Model(&models.Server{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil {
+ return err
+ }
+ if count != int64(len(ids)) {
+ return errors.New("some servers do not belong to this org")
+ }
+ return nil
+}
+
+func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error {
+ var count int64
+ if err := db.Model(&models.Taint{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil {
+ return err
+ }
+ if count != int64(len(ids)) {
+ return errors.New("some taints do not belong to this org")
+ }
+ return nil
+}
+
+func ensureLabelsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error {
+ var count int64
+ if err := db.Model(&models.Label{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil {
+ return err
+ }
+ if count != int64(len(ids)) {
+ return errors.New("some labels do not belong to this org")
+ }
+ return nil
+}
+
+func ensureAnnotaionsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error {
+ var count int64
+ if err := db.Model(&models.Annotation{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil {
+ return err
+ }
+ if count != int64(len(ids)) {
+ return errors.New("some annotations do not belong to this org")
+ }
+ return nil
+}
+
+
+
+package handlers
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/auth"
+ "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"
+)
+
+// ---------- Helpers ----------
+
+func mustUser(r *http.Request) (*models.User, bool) {
+ return httpmiddleware.UserFrom(r.Context())
+}
+
+func isOrgRole(db *gorm.DB, userID, orgID uuid.UUID, want ...string) (bool, string) {
+ var m models.Membership
+ if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err != nil {
+ return false, ""
+ }
+ got := strings.ToLower(m.Role)
+ for _, w := range want {
+ if got == strings.ToLower(w) {
+ return true, got
+ }
+ }
+ return false, got
+}
+
+func mustMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
+ ok, _ := isOrgRole(db, userID, orgID, "owner", "admin", "member")
+ return ok
+}
+
+func randomB64URL(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// ---------- Orgs: list/create/get/update/delete ----------
+
+type orgCreateReq struct {
+ Name string `json:"name" example:"Acme Corp"`
+ Domain *string `json:"domain,omitempty" example:"acme.com"`
+}
+
+// CreateOrg godoc
+//
+// @ID CreateOrg
+// @Summary Create organization
+// @Tags Orgs
+// @Accept json
+// @Produce json
+// @Param body body orgCreateReq true "Org payload"
+// @Success 201 {object} models.Organization
+// @Failure 400 {object} utils.ErrorResponse
+// @Failure 401 {object} utils.ErrorResponse
+// @Failure 409 {object} utils.ErrorResponse
+// @Router /orgs [post]
+// @ID createOrg
+// @Security BearerAuth
+func CreateOrg(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
+ return
+ }
+
+ var req orgCreateReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, 400, "invalid_json", err.Error())
+ return
+ }
+
+ if strings.TrimSpace(req.Name) == "" {
+ utils.WriteError(w, 400, "validation_error", "name is required")
+ return
+ }
+
+ org := models.Organization{Name: req.Name}
+ if req.Domain != nil && strings.TrimSpace(*req.Domain) != "" {
+ org.Domain = req.Domain
+ }
+
+ if err := db.Create(&org).Error; err != nil {
+ utils.WriteError(w, 409, "conflict", err.Error())
+ return
+ }
+
+ // creator is owner
+ _ = db.Create(&models.Membership{
+ UserID: u.ID, OrganizationID: org.ID, Role: "owner",
+ }).Error
+
+ utils.WriteJSON(w, 201, org)
+ }
+}
+
+// ListMyOrgs godoc
+//
+// @ID ListMyOrgs
+// @Summary List organizations I belong to
+// @Tags Orgs
+// @Produce json
+// @Success 200 {array} models.Organization
+// @Failure 401 {object} utils.ErrorResponse
+// @Router /orgs [get]
+// @ID listMyOrgs
+// @Security BearerAuth
+func ListMyOrgs(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
+ return
+ }
+
+ var orgs []models.Organization
+ if err := db.
+ Joins("join memberships m on m.organization_id = organizations.id").
+ Where("m.user_id = ?", u.ID).
+ Order("organizations.created_at desc").
+ Find(&orgs).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+ utils.WriteJSON(w, 200, orgs)
+ }
+}
+
+// GetOrg godoc
+//
+// @ID GetOrg
+// @Summary Get organization
+// @Tags Orgs
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Success 200 {object} models.Organization
+// @Failure 401 {object} utils.ErrorResponse
+// @Failure 404 {object} utils.ErrorResponse
+// @Router /orgs/{id} [get]
+// @ID getOrg
+// @Security BearerAuth
+func GetOrg(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ if !mustMember(db, u.ID, oid) {
+ utils.WriteError(w, 401, "forbidden", "not a member")
+ return
+ }
+ var org models.Organization
+ if err := db.First(&org, "id = ?", oid).Error; err != nil {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ utils.WriteJSON(w, 200, org)
+ }
+}
+
+type orgUpdateReq struct {
+ Name *string `json:"name,omitempty"`
+ Domain *string `json:"domain,omitempty"`
+}
+
+// UpdateOrg godoc
+//
+// @ID UpdateOrg
+// @Summary Update organization (owner/admin)
+// @Tags Orgs
+// @Accept json
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Param body body orgUpdateReq true "Update payload"
+// @Success 200 {object} models.Organization
+// @Failure 401 {object} utils.ErrorResponse
+// @Failure 404 {object} utils.ErrorResponse
+// @Router /orgs/{id} [patch]
+// @ID updateOrg
+// @Security BearerAuth
+func UpdateOrg(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
+ utils.WriteError(w, 401, "forbidden", "admin or owner required")
+ return
+ }
+ var req orgUpdateReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, 400, "invalid_json", err.Error())
+ return
+ }
+ changes := map[string]any{}
+ if req.Name != nil {
+ changes["name"] = strings.TrimSpace(*req.Name)
+ }
+ if req.Domain != nil {
+ if d := strings.TrimSpace(*req.Domain); d == "" {
+ changes["domain"] = nil
+ } else {
+ changes["domain"] = d
+ }
+ }
+ if len(changes) > 0 {
+ if err := db.Model(&models.Organization{}).Where("id = ?", oid).Updates(changes).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+ }
+ var out models.Organization
+ _ = db.First(&out, "id = ?", oid).Error
+ utils.WriteJSON(w, 200, out)
+ }
+}
+
+// DeleteOrg godoc
+//
+// @ID DeleteOrg
+// @Summary Delete organization (owner)
+// @Tags Orgs
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Success 204 "Deleted"
+// @Failure 401 {object} utils.ErrorResponse
+// @Failure 404 {object} utils.ErrorResponse
+// @Router /orgs/{id} [delete]
+// @ID deleteOrg
+// @Security BearerAuth
+func DeleteOrg(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ if ok, _ := isOrgRole(db, u.ID, oid, "owner"); !ok {
+ utils.WriteError(w, 401, "forbidden", "owner required")
+ return
+ }
+ // Optional safety: deny if members >1 or resources exist; here we just delete.
+ res := db.Delete(&models.Organization{}, "id = ?", oid)
+ if res.Error != nil {
+ utils.WriteError(w, 500, "db_error", res.Error.Error())
+ return
+ }
+ if res.RowsAffected == 0 {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ w.WriteHeader(204)
+ }
+}
+
+// ---------- Members: list/add/update/delete ----------
+
+type memberOut struct {
+ UserID uuid.UUID `json:"user_id" format:"uuid"`
+ Email string `json:"email"`
+ Role string `json:"role"` // owner/admin/member
+}
+
+type memberUpsertReq struct {
+ UserID uuid.UUID `json:"user_id" format:"uuid"`
+ Role string `json:"role" example:"member"`
+}
+
+// ListMembers godoc
+//
+// @ID ListMembers
+// @Summary List members in org
+// @Tags Orgs
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Success 200 {array} memberOut
+// @Failure 401 {object} utils.ErrorResponse
+// @Router /orgs/{id}/members [get]
+// @ID listMembers
+// @Security BearerAuth
+func ListMembers(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil || !mustMember(db, u.ID, oid) {
+ utils.WriteError(w, 401, "forbidden", "")
+ return
+ }
+ var ms []models.Membership
+ if err := db.Where("organization_id = ?", oid).Find(&ms).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+
+ // load emails
+ userIDs := make([]uuid.UUID, 0, len(ms))
+ for _, m := range ms {
+ userIDs = append(userIDs, m.UserID)
+ }
+ var emails []models.UserEmail
+ if len(userIDs) > 0 {
+ _ = db.Where("user_id in ?", userIDs).Where("is_primary = true").Find(&emails).Error
+ }
+ emailByUser := map[uuid.UUID]string{}
+ for _, e := range emails {
+ emailByUser[e.UserID] = e.Email
+ }
+
+ out := make([]memberOut, 0, len(ms))
+ for _, m := range ms {
+ out = append(out, memberOut{
+ UserID: m.UserID,
+ Email: emailByUser[m.UserID],
+ Role: m.Role,
+ })
+ }
+ utils.WriteJSON(w, 200, out)
+ }
+}
+
+// AddOrUpdateMember godoc
+//
+// @ID AddOrUpdateMember
+// @Summary Add or update a member (owner/admin)
+// @Tags Orgs
+// @Accept json
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Param body body memberUpsertReq true "User & role"
+// @Success 200 {object} memberOut
+// @Failure 401 {object} utils.ErrorResponse
+// @Router /orgs/{id}/members [post]
+// @ID addOrUpdateMember
+// @Security BearerAuth
+func AddOrUpdateMember(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
+ utils.WriteError(w, 401, "forbidden", "admin or owner required")
+ return
+ }
+ var req memberUpsertReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, 400, "invalid_json", err.Error())
+ return
+ }
+ role := strings.ToLower(strings.TrimSpace(req.Role))
+ if role != "owner" && role != "admin" && role != "member" {
+ utils.WriteError(w, 400, "validation_error", "role must be owner|admin|member")
+ return
+ }
+ var m models.Membership
+ tx := db.Where("user_id = ? AND organization_id = ?", req.UserID, oid).First(&m)
+ if tx.Error == nil {
+ // update
+ if err := db.Model(&m).Update("role", role).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+ } else if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
+ m = models.Membership{UserID: req.UserID, OrganizationID: oid, Role: role}
+ if err := db.Create(&m).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+ } else {
+ utils.WriteError(w, 500, "db_error", tx.Error.Error())
+ return
+ }
+
+ // make response
+ var ue models.UserEmail
+ _ = db.Where("user_id = ? AND is_primary = true", req.UserID).First(&ue).Error
+ utils.WriteJSON(w, 200, memberOut{
+ UserID: req.UserID, Email: ue.Email, Role: m.Role,
+ })
+ }
+}
+
+// RemoveMember godoc
+//
+// @ID RemoveMember
+// @Summary Remove a member (owner/admin)
+// @Tags Orgs
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Param user_id path string true "User ID (UUID)"
+// @Success 204 "Removed"
+// @Failure 401 {object} utils.ErrorResponse
+// @Router /orgs/{id}/members/{user_id} [delete]
+// @ID removeMember
+// @Security BearerAuth
+func RemoveMember(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
+ utils.WriteError(w, 401, "forbidden", "admin or owner required")
+ return
+ }
+ uid, err := uuid.Parse(chi.URLParam(r, "user_id"))
+ if err != nil {
+ utils.WriteError(w, 400, "invalid_user_id", "")
+ return
+ }
+ res := db.Where("user_id = ? AND organization_id = ?", uid, oid).Delete(&models.Membership{})
+ if res.Error != nil {
+ utils.WriteError(w, 500, "db_error", res.Error.Error())
+ return
+ }
+ w.WriteHeader(204)
+ }
+}
+
+// ---------- Org API Keys (key/secret pair) ----------
+
+type orgKeyCreateReq struct {
+ Name string `json:"name,omitempty" example:"automation-bot"`
+ ExpiresInHours *int `json:"expires_in_hours,omitempty" example:"720"`
+}
+
+type orgKeyCreateResp struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name,omitempty"`
+ Scope string `json:"scope"` // "org"
+ CreatedAt time.Time `json:"created_at"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ OrgKey string `json:"org_key"` // shown once:
+ OrgSecret string `json:"org_secret"` // shown once:
+}
+
+// ListOrgKeys godoc
+//
+// @ID ListOrgKeys
+// @Summary List org-scoped API keys (no secrets)
+// @Tags Orgs
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Success 200 {array} models.APIKey
+// @Failure 401 {object} utils.ErrorResponse
+// @Router /orgs/{id}/api-keys [get]
+// @ID listOrgKeys
+// @Security BearerAuth
+func ListOrgKeys(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil || !mustMember(db, u.ID, oid) {
+ utils.WriteError(w, 401, "forbidden", "")
+ return
+ }
+ var keys []models.APIKey
+ if err := db.Where("org_id = ? AND scope = ?", oid, "org").
+ Order("created_at desc").
+ Find(&keys).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+ // SecretHash must not be exposed; your json tags likely hide it already.
+ utils.WriteJSON(w, 200, keys)
+ }
+}
+
+// CreateOrgKey godoc
+//
+// @ID CreateOrgKey
+// @Summary Create org key/secret pair (owner/admin)
+// @Tags Orgs
+// @Accept json
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Param body body orgKeyCreateReq true "Key name + optional expiry"
+// @Success 201 {object} orgKeyCreateResp
+// @Failure 401 {object} utils.ErrorResponse
+// @Router /orgs/{id}/api-keys [post]
+// @ID createOrgKey
+// @Security BearerAuth
+func CreateOrgKey(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
+ utils.WriteError(w, 401, "forbidden", "admin or owner required")
+ return
+ }
+
+ var req orgKeyCreateReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, 400, "invalid_json", err.Error())
+ return
+ }
+
+ // generate
+ keySuffix, err := randomB64URL(16)
+ if err != nil {
+ utils.WriteError(w, 500, "entropy_error", err.Error())
+ return
+ }
+ sec, err := randomB64URL(32)
+ if err != nil {
+ utils.WriteError(w, 500, "entropy_error", err.Error())
+ return
+ }
+ orgKey := "org_" + keySuffix
+ secretPlain := sec
+
+ keyHash := auth.SHA256Hex(orgKey)
+ secretHash, err := auth.HashSecretArgon2id(secretPlain)
+ if err != nil {
+ utils.WriteError(w, 500, "hash_error", err.Error())
+ return
+ }
+
+ var exp *time.Time
+ if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
+ e := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
+ exp = &e
+ }
+
+ prefix := orgKey
+ if len(prefix) > 12 {
+ prefix = prefix[:12]
+ }
+
+ rec := models.APIKey{
+ OrgID: &oid,
+ Scope: "org",
+ Purpose: "user",
+ IsEphemeral: false,
+ Name: req.Name,
+ KeyHash: keyHash,
+ SecretHash: &secretHash,
+ ExpiresAt: exp,
+ Revoked: false,
+ Prefix: &prefix,
+ }
+ if err := db.Create(&rec).Error; err != nil {
+ utils.WriteError(w, 500, "db_error", err.Error())
+ return
+ }
+
+ utils.WriteJSON(w, 201, orgKeyCreateResp{
+ ID: rec.ID,
+ Name: rec.Name,
+ Scope: rec.Scope,
+ CreatedAt: rec.CreatedAt,
+ ExpiresAt: rec.ExpiresAt,
+ OrgKey: orgKey,
+ OrgSecret: secretPlain,
+ })
+ }
+}
+
+// DeleteOrgKey godoc
+//
+// @ID DeleteOrgKey
+// @Summary Delete org key (owner/admin)
+// @Tags Orgs
+// @Produce json
+// @Param id path string true "Org ID (UUID)"
+// @Param key_id path string true "Key ID (UUID)"
+// @Success 204 "Deleted"
+// @Failure 401 {object} utils.ErrorResponse
+// @Router /orgs/{id}/api-keys/{key_id} [delete]
+// @ID deleteOrgKey
+// @Security BearerAuth
+func DeleteOrgKey(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, ok := mustUser(r)
+ if !ok {
+ utils.WriteError(w, 401, "unauthorized", "")
+ return
+ }
+ oid, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, 404, "not_found", "org not found")
+ return
+ }
+ if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
+ utils.WriteError(w, 401, "forbidden", "admin or owner required")
+ return
+ }
+ kid, err := uuid.Parse(chi.URLParam(r, "key_id"))
+ if err != nil {
+ utils.WriteError(w, 400, "invalid_key_id", "")
+ return
+ }
+ res := db.Where("id = ? AND org_id = ? AND scope = ?", kid, oid, "org").Delete(&models.APIKey{})
+ if res.Error != nil {
+ utils.WriteError(w, 500, "db_error", res.Error.Error())
+ return
+ }
+ if res.RowsAffected == 0 {
+ utils.WriteError(w, 404, "not_found", "key not found")
+ return
+ }
+ w.WriteHeader(204)
+ }
+}
+
+
+
+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")
+ }
+}
+
+
+
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ListServers godoc
+//
+// @ID ListServers
+// @Summary List servers (org scoped)
+// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
+// @Tags Servers
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param status query string false "Filter by status (pending|provisioning|ready|failed)"
+// @Param role query string false "Filter by role"
+// @Success 200 {array} dto.ServerResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list servers"
+// @Router /servers [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListServers(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ q := db.Where("organization_id = ?", orgID)
+
+ if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
+ if !validStatus(s) {
+ utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
+ return
+ }
+ q = q.Where("status = ?", strings.ToLower(s))
+ }
+
+ if role := strings.TrimSpace(r.URL.Query().Get("role")); role != "" {
+ q = q.Where("role = ?", role)
+ }
+
+ var rows []models.Server
+ if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list servers")
+ return
+ }
+
+ out := make([]dto.ServerResponse, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, dto.ServerResponse{
+ ID: row.ID,
+ OrganizationID: row.OrganizationID,
+ Hostname: row.Hostname,
+ PublicIPAddress: row.PublicIPAddress,
+ PrivateIPAddress: row.PrivateIPAddress,
+ SSHUser: row.SSHUser,
+ SshKeyID: row.SshKeyID,
+ Role: row.Role,
+ Status: row.Status,
+ CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
+ })
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetServer godoc
+//
+// @ID GetServer
+// @Summary Get server by ID (org scoped)
+// @Description Returns one server in the given organization.
+// @Tags Servers
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Server ID (UUID)"
+// @Success 200 {object} dto.ServerResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /servers/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetServer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
+ return
+ }
+
+ var row models.Server
+ 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, "server_not_found", "server not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, row)
+ }
+}
+
+// CreateServer godoc
+//
+// @ID CreateServer
+// @Summary Create server (org scoped)
+// @Description Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.
+// @Tags Servers
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateServerRequest true "Server payload"
+// @Success 201 {object} dto.ServerResponse
+// @Failure 400 {string} string "invalid json / missing fields / invalid status / invalid ssh_key_id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "create failed"
+// @Router /servers [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateServer(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 req dto.CreateServerRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ req.Role = strings.ToLower(strings.TrimSpace(req.Role))
+ req.Status = strings.ToLower(strings.TrimSpace(req.Status))
+ pub := strings.TrimSpace(req.PublicIPAddress)
+
+ if req.PrivateIPAddress == "" || req.SSHUser == "" || req.SshKeyID == "" || req.Role == "" {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "private_ip_address, ssh_user, ssh_key_id and role are required")
+ return
+ }
+
+ if req.Status != "" && !validStatus(req.Status) {
+ utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
+ return
+ }
+
+ if req.Role == "bastion" && pub == "" {
+ utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
+ return
+ }
+
+ keyID, err := uuid.Parse(req.SshKeyID)
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
+ return
+ }
+ if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
+ return
+ }
+
+ var publicPtr *string
+ if pub != "" {
+ publicPtr = &pub
+ }
+
+ s := models.Server{
+ OrganizationID: orgID,
+ Hostname: req.Hostname,
+ PublicIPAddress: publicPtr,
+ PrivateIPAddress: req.PrivateIPAddress,
+ SSHUser: req.SSHUser,
+ SshKeyID: keyID,
+ Role: req.Role,
+ Status: "pending",
+ }
+ if req.Status != "" {
+ s.Status = strings.ToLower(req.Status)
+ }
+
+ if err := db.Create(&s).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create server")
+ return
+ }
+ utils.WriteJSON(w, http.StatusCreated, s)
+ }
+}
+
+// UpdateServer godoc
+//
+// @ID UpdateServer
+// @Summary Update server (org scoped)
+// @Description Partially update fields; changing ssh_key_id validates ownership.
+// @Tags Servers
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Server ID (UUID)"
+// @Param body body dto.UpdateServerRequest true "Fields to update"
+// @Success 200 {object} dto.ServerResponse
+// @Failure 400 {string} string "invalid id / invalid json / invalid status / invalid ssh_key_id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "update failed"
+// @Router /servers/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateServer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
+ return
+ }
+
+ var server models.Server
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&server).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
+ return
+ }
+
+ var req dto.UpdateServerRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ next := server
+
+ if req.Hostname != nil {
+ next.Hostname = *req.Hostname
+ }
+ if req.PrivateIPAddress != nil {
+ next.PrivateIPAddress = *req.PrivateIPAddress
+ }
+ if req.PublicIPAddress != nil {
+ next.PublicIPAddress = req.PublicIPAddress
+ }
+ if req.SSHUser != nil {
+ next.SSHUser = *req.SSHUser
+ }
+ if req.Role != nil {
+ next.Role = *req.Role
+ }
+ if req.Status != nil {
+ st := strings.ToLower(strings.TrimSpace(*req.Status))
+ if !validStatus(st) {
+ utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
+ return
+ }
+ next.Status = st
+ }
+ if req.SshKeyID != nil {
+ keyID, err := uuid.Parse(*req.SshKeyID)
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
+ return
+ }
+ if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
+ return
+ }
+ next.SshKeyID = keyID
+ }
+
+ if strings.EqualFold(next.Role, "bastion") &&
+ (next.PublicIPAddress == nil || strings.TrimSpace(*next.PublicIPAddress) == "") {
+ utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
+ return
+ }
+
+ if err := db.Save(&next).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to update server")
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, server)
+ }
+}
+
+// DeleteServer godoc
+//
+// @ID DeleteServer
+// @Summary Delete server (org scoped)
+// @Description Permanently deletes the server.
+// @Tags Servers
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Server ID (UUID)"
+// @Success 204 "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "delete failed"
+// @Router /servers/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteServer(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
+ return
+ }
+
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&models.Server{}).Error; err != nil {
+ utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
+ return
+ }
+
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Server{}).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete server")
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// ResetServerHostKey godoc
+//
+// @ID ResetServerHostKey
+// @Summary Reset SSH host key (org scoped)
+// @Description Clears the stored SSH host key for this server. The next SSH connection will re-learn the host key (trust-on-first-use).
+// @Tags Servers
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Server ID (UUID)"
+// @Success 200 {object} dto.ServerResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "reset failed"
+// @Router /servers/{id}/reset-hostkey [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ResetServerHostKey(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ id, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
+ return
+ }
+
+ var server models.Server
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&server).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
+ return
+ }
+
+ // Clear stored host key so next SSH handshake will TOFU and persist a new one.
+ server.SSHHostKey = ""
+ server.SSHHostKeyAlgo = ""
+
+ if err := db.Save(&server).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to reset host key")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, server)
+ }
+}
+
+// --- Helpers ---
+
+func validStatus(status string) bool {
+ switch strings.ToLower(status) {
+ case "pending", "provisioning", "ready", "failed", "":
+ return true
+ default:
+ return false
+ }
+}
+
+func ensureKeyBelongsToOrg(orgID, keyID uuid.UUID, db *gorm.DB) error {
+ var k models.SshKey
+ if err := db.Where("id = ? AND organization_id = ?", keyID, orgID).First(&k).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("ssh key not found for this organization")
+ }
+ return err
+ }
+ return nil
+}
+
+
+
+package handlers
+
+import (
+ "archive/zip"
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/common"
+ "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"
+ "golang.org/x/crypto/ssh"
+ "gorm.io/gorm"
+)
+
+// ListPublicSshKeys godoc
+//
+// @ID ListPublicSshKeys
+// @Summary List ssh keys (org scoped)
+// @Description Returns ssh keys for the organization in X-Org-ID.
+// @Tags Ssh
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Success 200 {array} dto.SshResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list keys"
+// @Router /ssh [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListPublicSshKeys(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 out []dto.SshResponse
+ if err := db.
+ Model(&models.SshKey{}).
+ Where("organization_id = ?", orgID).
+ // avoid selecting encrypted columns here
+ Select("id", "organization_id", "name", "public_key", "fingerprint", "created_at", "updated_at").
+ Order("created_at DESC").
+ Scan(&out).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list ssh keys")
+ return
+ }
+
+ if out == nil {
+ out = []dto.SshResponse{}
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// CreateSSHKey
+//
+// @ID CreateSSHKey
+// @Summary Create ssh keypair (org scoped)
+// @Description Generates an RSA or ED25519 keypair, saves it, and returns metadata. For RSA you may set bits (2048/3072/4096). Default is 4096. ED25519 ignores bits.
+// @Tags Ssh
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateSSHRequest true "Key generation options"
+// @Success 201 {object} dto.SshResponse
+// @Failure 400 {string} string "invalid json / invalid bits"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "generation/create failed"
+// @Router /ssh [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateSSHKey(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 req dto.CreateSSHRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
+ return
+ }
+
+ keyType := "rsa"
+ if req.Type != nil && strings.TrimSpace(*req.Type) != "" {
+ keyType = strings.ToLower(strings.TrimSpace(*req.Type))
+ }
+
+ if keyType != "rsa" && keyType != "ed25519" {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_type", "invalid type (rsa|ed25519)")
+ return
+ }
+
+ var (
+ privPEM string
+ pubAuth string
+ err error
+ )
+
+ switch keyType {
+ case "rsa":
+ bits := 4096
+ if req.Bits != nil {
+ if !allowedBits(*req.Bits) {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_bits", "invalid bits (allowed: 2048, 3072, 4096)")
+ return
+ }
+ bits = *req.Bits
+ }
+ privPEM, pubAuth, err = GenerateRSAPEMAndAuthorized(bits, strings.TrimSpace(req.Comment))
+
+ case "ed25519":
+ if req.Bits != nil {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_bits_for_type", "bits is only valid for RSA")
+ return
+ }
+ privPEM, pubAuth, err = GenerateEd25519PEMAndAuthorized(strings.TrimSpace(req.Comment))
+ }
+
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "keygen_failure", "key generation failed")
+ return
+ }
+
+ cipher, iv, tag, err := utils.EncryptForOrg(orgID, []byte(privPEM), db)
+ if err != nil {
+ http.Error(w, "encryption failed", http.StatusInternalServerError)
+ return
+ }
+
+ parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "ssh_failure", "ssh public key parsing failed")
+ return
+ }
+
+ fp := ssh.FingerprintSHA256(parsed)
+
+ key := models.SshKey{
+ AuditFields: common.AuditFields{
+ OrganizationID: orgID,
+ },
+ Name: req.Name,
+ PublicKey: pubAuth,
+ EncryptedPrivateKey: cipher,
+ PrivateIV: iv,
+ PrivateTag: tag,
+ Fingerprint: fp,
+ }
+
+ if err := db.Create(&key).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create ssh key")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusCreated, dto.SshResponse{
+ AuditFields: key.AuditFields,
+ Name: key.Name,
+ PublicKey: key.PublicKey,
+ Fingerprint: key.Fingerprint,
+ })
+ }
+}
+
+// GetSSHKey godoc
+//
+// @ID GetSSHKey
+// @Summary Get ssh key by ID (org scoped)
+// @Description Returns public key fields. Append `?reveal=true` to include the private key PEM.
+// @Tags Ssh
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "SSH Key ID (UUID)"
+// @Param reveal query bool false "Reveal private key PEM"
+// @Success 200 {object} dto.SshResponse
+// @Success 200 {object} dto.SshRevealResponse "When reveal=true"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /ssh/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetSSHKey(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, "invalid_ssh_key_id", "invalid SSH Key ID")
+ return
+ }
+
+ reveal := strings.EqualFold(r.URL.Query().Get("reveal"), "true")
+
+ if !reveal {
+ var out dto.SshResponse
+ if err := db.
+ Model(&models.SshKey{}).
+ Where("id = ? AND organization_id = ?", id, orgID).
+ Select("id", "organization_id", "name", "public_key", "fingerprint", "created_at", "updated_at").
+ Limit(1).
+ Scan(&out).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
+ return
+ }
+ if out.ID == uuid.Nil {
+ utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ return
+ }
+
+ var secret dto.SshResponse
+ if err := db.
+ Model(&models.SshKey{}).
+ Where("id = ? AND organization_id = ?", id, orgID).
+ // include the encrypted bits too
+ Select("id", "organization_id", "name", "public_key", "fingerprint",
+ "encrypted_private_key", "private_iv", "private_tag",
+ "created_at", "updated_at").
+ Limit(1).
+ Scan(&secret).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
+ return
+ }
+
+ if secret.ID == uuid.Nil {
+ utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
+ return
+ }
+
+ plain, err := utils.DecryptForOrg(orgID, secret.EncryptedPrivateKey, secret.PrivateIV, secret.PrivateTag, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, dto.SshRevealResponse{
+ SshResponse: dto.SshResponse{
+ AuditFields: secret.AuditFields,
+ Name: secret.Name,
+ PublicKey: secret.PublicKey,
+ Fingerprint: secret.Fingerprint,
+ },
+ PrivateKey: plain,
+ })
+ }
+}
+
+// DeleteSSHKey godoc
+//
+// @ID DeleteSSHKey
+// @Summary Delete ssh keypair (org scoped)
+// @Description Permanently deletes a keypair.
+// @Tags Ssh
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "SSH Key ID (UUID)"
+// @Success 204 "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "delete failed"
+// @Router /ssh/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteSSHKey(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, "invalid_ssh_key_id", "invalid SSH Key ID")
+ return
+ }
+
+ res := db.Where("id = ? AND organization_id = ?", id, orgID).
+ Delete(&models.SshKey{})
+ if res.Error != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete ssh key")
+ return
+ }
+ if res.RowsAffected == 0 {
+ utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// DownloadSSHKey godoc
+//
+// @ID DownloadSSHKey
+// @Summary Download ssh key files by ID (org scoped)
+// @Description Download `part=public|private|both` of the keypair. `both` returns a zip file.
+// @Tags Ssh
+// @Produce json
+// @Param X-Org-ID header string true "Organization UUID"
+// @Param id path string true "SSH Key ID (UUID)"
+// @Param part query string true "Which part to download" Enums(public,private,both)
+// @Success 200 {string} string "file content"
+// @Failure 400 {string} string "invalid id / invalid part"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "download failed"
+// @Router /ssh/{id}/download [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DownloadSSHKey(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, "invalid_ssh_key_id", "invalid SSH Key ID")
+ return
+ }
+
+ var key models.SshKey
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).
+ First(&key).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
+ return
+ }
+
+ part := strings.ToLower(r.URL.Query().Get("part"))
+ if part == "" {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
+ return
+ }
+
+ mode := strings.ToLower(r.URL.Query().Get("mode"))
+ if mode != "" && mode != "json" {
+ utils.WriteError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (json|attachment[default])")
+ return
+ }
+
+ if mode == "json" {
+ resp := dto.SshMaterialJSON{
+ ID: key.ID.String(),
+ Name: key.Name,
+ Fingerprint: key.Fingerprint,
+ }
+ switch part {
+ case "public":
+ pub := key.PublicKey
+ resp.PublicKey = &pub
+ resp.Filenames = []string{fmt.Sprintf("%s.pub", key.ID.String())}
+ utils.WriteJSON(w, http.StatusOK, resp)
+ return
+
+ case "private":
+ plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
+ return
+ }
+ resp.PrivatePEM = &plain
+ resp.Filenames = []string{fmt.Sprintf("%s.pem", key.ID.String())}
+ utils.WriteJSON(w, http.StatusOK, resp)
+ return
+
+ case "both":
+ plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
+ return
+ }
+
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ _ = toZipFile(fmt.Sprintf("%s.pem", key.ID.String()), []byte(plain), zw)
+ _ = toZipFile(fmt.Sprintf("%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
+ _ = zw.Close()
+
+ b64 := utils.EncodeB64(buf.Bytes())
+ resp.ZipBase64 = &b64
+ resp.Filenames = []string{
+ fmt.Sprintf("%s.zip", key.ID.String()),
+ fmt.Sprintf("%s.pem", key.ID.String()),
+ fmt.Sprintf("%s.pub", key.ID.String()),
+ }
+ utils.WriteJSON(w, http.StatusOK, resp)
+ return
+
+ default:
+ utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
+ return
+ }
+ }
+
+ switch part {
+ case "public":
+ filename := fmt.Sprintf("%s.pub", key.ID.String())
+ w.Header().Set("Content-Type", "text/plain")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
+ _, _ = w.Write([]byte(key.PublicKey))
+ return
+
+ case "private":
+ plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
+ return
+ }
+ filename := fmt.Sprintf("%s.pem", key.ID.String())
+ w.Header().Set("Content-Type", "application/x-pem-file")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
+ _, _ = w.Write([]byte(plain))
+ return
+
+ case "both":
+ plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
+ if err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
+ return
+ }
+
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ _ = toZipFile(fmt.Sprintf("%s.pem", key.ID.String()), []byte(plain), zw)
+ _ = toZipFile(fmt.Sprintf("%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
+ _ = zw.Close()
+
+ filename := fmt.Sprintf("ssh_key_%s.zip", key.ID.String())
+ w.Header().Set("Content-Type", "application/zip")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
+ _, _ = w.Write(buf.Bytes())
+ return
+
+ default:
+ utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
+ return
+ }
+ }
+}
+
+// --- Helpers ---
+
+func allowedBits(b int) bool {
+ return b == 2048 || b == 3072 || b == 4096
+}
+
+func GenerateRSA(bits int) (*rsa.PrivateKey, error) {
+ return rsa.GenerateKey(rand.Reader, bits)
+}
+
+func RSAPrivateToPEMAndAuthorized(priv *rsa.PrivateKey, comment string) (privPEM string, authorized string, err error) {
+ der := x509.MarshalPKCS1PrivateKey(priv)
+ block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}
+ var buf bytes.Buffer
+ if err = pem.Encode(&buf, block); err != nil {
+ return "", "", err
+ }
+
+ pub, err := ssh.NewPublicKey(&priv.PublicKey)
+ if err != nil {
+ return "", "", err
+ }
+ auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
+ comment = strings.TrimSpace(comment)
+ if comment != "" {
+ auth += " " + comment
+ }
+ return buf.String(), auth, nil
+}
+
+func GenerateRSAPEMAndAuthorized(bits int, comment string) (string, string, error) {
+ priv, err := GenerateRSA(bits)
+ if err != nil {
+ return "", "", err
+ }
+ return RSAPrivateToPEMAndAuthorized(priv, comment)
+}
+
+func toZipFile(filename string, content []byte, zw *zip.Writer) error {
+ f, err := zw.Create(filename)
+ if err != nil {
+ return err
+ }
+ _, err = f.Write(content)
+ return err
+}
+
+func keyFilenamePrefix(pubAuth string) string {
+ pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
+ if err != nil {
+ return "id_key"
+ }
+ switch pk.Type() {
+ case "ssh-ed25519":
+ return "id_ed25519"
+ case "ssh-rsa":
+ return "id_rsa"
+ default:
+ return "id_key"
+ }
+}
+
+func GenerateEd25519PEMAndAuthorized(comment string) (privPEM string, authorized string, err error) {
+ // Generate ed25519 keypair
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return "", "", err
+ }
+
+ // Private: PKCS#8 PEM
+ der, err := x509.MarshalPKCS8PrivateKey(priv)
+ if err != nil {
+ return "", "", err
+ }
+ block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
+ var buf bytes.Buffer
+ if err := pem.Encode(&buf, block); err != nil {
+ return "", "", err
+ }
+
+ // Public: OpenSSH authorized_key
+ sshPub, err := ssh.NewPublicKey(ed25519.PublicKey(pub))
+ if err != nil {
+ return "", "", err
+ }
+ auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
+ comment = strings.TrimSpace(comment)
+ if comment != "" {
+ auth += " " + comment
+ }
+
+ return buf.String(), auth, nil
+}
+
+
+
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/glueops/autoglue/internal/api/httpmiddleware"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// ListTaints godoc
+//
+// @ID ListTaints
+// @Summary List node pool taints (org scoped)
+// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
+// @Tags Taints
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param key query string false "Exact key"
+// @Param value query string false "Exact value"
+// @Param q query string false "key contains (case-insensitive)"
+// @Success 200 {array} dto.TaintResponse
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "failed to list node taints"
+// @Router /taints [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func ListTaints(db *gorm.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
+ if !ok {
+ utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
+ return
+ }
+
+ q := db.Where("organization_id = ?", orgID)
+
+ if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
+ q = q.Where(`key = ?`, key)
+ }
+ if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
+ q = q.Where(`value = ?`, val)
+ }
+ if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
+ q = q.Where(`key ILIKE ?`, "%"+needle+"%")
+ }
+
+ var out []dto.TaintResponse
+ if err := q.Model(&models.Taint{}).Order("created_at DESC").Find(&out).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// GetTaint godoc
+//
+// @ID GetTaint
+// @Summary Get node taint by ID (org scoped)
+// @Tags Taints
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Taint ID (UUID)"
+// @Success 200 {object} dto.TaintResponse
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "fetch failed"
+// @Router /taints/{id} [get]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func GetTaint(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_request", "bad request")
+ return
+ }
+
+ var out dto.TaintResponse
+ if err := db.Model(&models.Taint{}).Where("id = ? AND organization_id = ?", id, orgID).First(&out).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// CreateTaint godoc
+//
+// @ID CreateTaint
+// @Summary Create node taint (org scoped)
+// @Description Creates a taint.
+// @Tags Taints
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param body body dto.CreateTaintRequest true "Taint payload"
+// @Success 201 {object} dto.TaintResponse
+// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "create failed"
+// @Router /taints [post]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func CreateTaint(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 req dto.CreateTaintRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ req.Key = strings.TrimSpace(req.Key)
+ req.Value = strings.TrimSpace(req.Value)
+ req.Effect = strings.TrimSpace(req.Effect)
+
+ if req.Key == "" || req.Value == "" || req.Effect == "" {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value/effect")
+ return
+ }
+
+ if _, ok := allowedEffects[req.Effect]; !ok {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
+ return
+ }
+
+ t := models.Taint{
+ OrganizationID: orgID,
+ Key: req.Key,
+ Value: req.Value,
+ Effect: req.Effect,
+ }
+ if err := db.Create(&t).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := dto.TaintResponse{
+ ID: t.ID,
+ Key: t.Key,
+ Value: t.Value,
+ Effect: t.Effect,
+ OrganizationID: t.OrganizationID,
+ CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+ utils.WriteJSON(w, http.StatusCreated, out)
+ }
+}
+
+// UpdateTaint godoc
+//
+// @ID UpdateTaint
+// @Summary Update node taint (org scoped)
+// @Description Partially update taint fields.
+// @Tags Taints
+// @Accept json
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Taint ID (UUID)"
+// @Param body body dto.UpdateTaintRequest true "Fields to update"
+// @Success 200 {object} dto.TaintResponse
+// @Failure 400 {string} string "invalid id / invalid json"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 404 {string} string "not found"
+// @Failure 500 {string} string "update failed"
+// @Router /taints/{id} [patch]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func UpdateTaint(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_request", "bad request")
+ return
+ }
+
+ var t models.Taint
+ if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&t).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ var req dto.UpdateTaintRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
+ return
+ }
+
+ next := t
+
+ if req.Key != nil {
+ next.Key = strings.TrimSpace(*req.Key)
+ }
+ if req.Value != nil {
+ next.Value = strings.TrimSpace(*req.Value)
+ }
+ if req.Effect != nil {
+ e := strings.TrimSpace(*req.Effect)
+ if e == "" {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing effect")
+ return
+ }
+ if _, ok := allowedEffects[e]; !ok {
+ utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
+ return
+ }
+ next.Effect = e
+ }
+
+ if err := db.Save(&next).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ out := dto.TaintResponse{
+ ID: next.ID,
+ Key: next.Key,
+ Value: next.Value,
+ Effect: next.Effect,
+ OrganizationID: next.OrganizationID,
+ CreatedAt: next.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: next.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+ utils.WriteJSON(w, http.StatusOK, out)
+ }
+}
+
+// DeleteTaint godoc
+//
+// @ID DeleteTaint
+// @Summary Delete taint (org scoped)
+// @Description Permanently deletes the taint.
+// @Tags Taints
+// @Produce json
+// @Param X-Org-ID header string false "Organization UUID"
+// @Param id path string true "Node Taint ID (UUID)"
+// @Success 204 "No Content"
+// @Failure 400 {string} string "invalid id"
+// @Failure 401 {string} string "Unauthorized"
+// @Failure 403 {string} string "organization required"
+// @Failure 500 {string} string "delete failed"
+// @Router /taints/{id} [delete]
+// @Security BearerAuth
+// @Security OrgKeyAuth
+// @Security OrgSecretAuth
+func DeleteTaint(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_request", "bad request")
+ return
+ }
+
+ var row models.Taint
+ 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", "not_found")
+ return
+ }
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+
+ if err := db.Delete(&row).Error; err != nil {
+ utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+// --- Helpers ---
+var allowedEffects = map[string]struct{}{
+ "NoSchedule": {},
+ "PreferNoSchedule": {},
+ "NoExecute": {},
+}
+
+
+
+package handlers
+
+import (
+ "net/http"
+ "runtime"
+ "runtime/debug"
+ "strconv"
+
+ "github.com/glueops/autoglue/internal/utils"
+ "github.com/glueops/autoglue/internal/version"
+)
+
+type VersionResponse struct {
+ Version string `json:"version" example:"1.4.2"`
+ Commit string `json:"commit" example:"a1b2c3d"`
+ Built string `json:"built" example:"2025-11-08T12:34:56Z"`
+ BuiltBy string `json:"builtBy" example:"ci"`
+ Go string `json:"go" example:"go1.23.3"`
+ GOOS string `json:"goOS" example:"linux"`
+ GOARCH string `json:"goArch" example:"amd64"`
+ VCS string `json:"vcs,omitempty" example:"git"`
+ Revision string `json:"revision,omitempty" example:"a1b2c3d4e5f6abcdef"`
+ CommitTime string `json:"commitTime,omitempty" example:"2025-11-08T12:31:00Z"`
+ Modified *bool `json:"modified,omitempty" example:"false"`
+}
+
+// Version godoc
+//
+// @Summary Service version information
+// @Description Returns build/runtime metadata for the running service.
+// @Tags Meta
+// @ID Version // operationId
+// @Produce json
+// @Success 200 {object} VersionResponse
+// @Router /version [get]
+func Version(w http.ResponseWriter, r *http.Request) {
+ resp := VersionResponse{
+ Version: version.Version,
+ Commit: version.Commit,
+ Built: version.Date,
+ BuiltBy: version.BuiltBy,
+ Go: runtime.Version(),
+ GOOS: runtime.GOOS,
+ GOARCH: runtime.GOARCH,
+ }
+
+ if bi, ok := debug.ReadBuildInfo(); ok {
+ for _, s := range bi.Settings {
+ switch s.Key {
+ case "vcs":
+ resp.VCS = s.Value
+ case "vcs.revision":
+ resp.Revision = s.Value
+ case "vcs.time":
+ resp.CommitTime = s.Value
+ case "vcs.modified":
+ if b, err := strconv.ParseBool(s.Value); err == nil {
+ resp.Modified = &b
+ }
+ }
+ }
+ }
+ utils.WriteJSON(w, http.StatusOK, resp)
+}
+
+
+
+package keys
+
+import (
+ "encoding/base64"
+ "errors"
+ "strings"
+)
+
+func decode32ByteKey(s string) ([]byte, error) {
+ try := func(enc *base64.Encoding, v string) ([]byte, bool) {
+ if b, err := enc.DecodeString(v); err == nil && len(b) == 32 {
+ return b, true
+ }
+ return nil, false
+ }
+
+ // Try raw (no padding) variants first
+ if b, ok := try(base64.RawURLEncoding, s); ok {
+ return b, nil
+ }
+ if b, ok := try(base64.RawStdEncoding, s); ok {
+ return b, nil
+ }
+
+ // Try padded variants (add padding if missing)
+ pad := func(v string) string { return v + strings.Repeat("=", (4-len(v)%4)%4) }
+ if b, ok := try(base64.URLEncoding, pad(s)); ok {
+ return b, nil
+ }
+ if b, ok := try(base64.StdEncoding, pad(s)); ok {
+ return b, nil
+ }
+
+ return nil, errors.New("key must be 32 bytes in base64/base64url")
+}
+
+
+
+package keys
+
+func Decrypt(encKeyB64, enc string) ([]byte, error) {
+ return decryptAESGCM(encKeyB64, enc)
+}
+
+
+
+package keys
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type GenOpts struct {
+ Alg string // "RS256"|"RS384"|"RS512"|"EdDSA"
+ Bits int // RSA bits (2048/3072/4096). ignored for EdDSA
+ KID string // optional; if empty we generate one
+ NBF *time.Time
+ EXP *time.Time
+}
+
+func GenerateAndStore(db *gorm.DB, encKeyB64 string, opts GenOpts) (*models.SigningKey, error) {
+ if opts.KID == "" {
+ opts.KID = uuid.NewString()
+ }
+
+ var pubPEM, privPEM []byte
+ var alg = opts.Alg
+
+ switch alg {
+ case "RS256", "RS384", "RS512":
+ if opts.Bits == 0 {
+ opts.Bits = 3072
+ }
+ priv, err := rsa.GenerateKey(rand.Reader, opts.Bits)
+ if err != nil {
+ return nil, err
+ }
+ privDER := x509.MarshalPKCS1PrivateKey(priv)
+ privPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER})
+
+ pubDER := x509.MarshalPKCS1PublicKey(&priv.PublicKey)
+ pubPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubDER})
+
+ case "EdDSA":
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+ privDER, err := x509.MarshalPKCS8PrivateKey(priv)
+ if err != nil {
+ return nil, err
+ }
+ privPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})
+
+ pubDER, err := x509.MarshalPKIXPublicKey(pub)
+ if err != nil {
+ return nil, err
+ }
+ pubPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
+
+ default:
+ return nil, fmt.Errorf("unsupported alg: %s", alg)
+ }
+
+ privateOut := string(privPEM)
+ if encKeyB64 != "" {
+ enc, err := encryptAESGCM(encKeyB64, privPEM)
+ if err != nil {
+ return nil, err
+ }
+ privateOut = enc
+ }
+
+ rec := models.SigningKey{
+ Kid: opts.KID,
+ Alg: alg,
+ Use: "sig",
+ IsActive: true,
+ PublicPEM: string(pubPEM),
+ PrivatePEM: privateOut,
+ NotBefore: opts.NBF,
+ ExpiresAt: opts.EXP,
+ }
+ if err := db.Create(&rec).Error; err != nil {
+ return nil, err
+ }
+ return &rec, nil
+}
+
+func encryptAESGCM(b64 string, plaintext []byte) (string, error) {
+ key, err := decode32ByteKey(b64)
+ if err != nil {
+ return "", err
+ }
+ if len(key) != 32 {
+ return "", errors.New("JWT_PRIVATE_ENC_KEY must be 32 bytes (base64url)")
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+ nonce := make([]byte, aead.NonceSize())
+ if _, err = rand.Read(nonce); err != nil {
+ return "", err
+ }
+ out := aead.Seal(nonce, nonce, plaintext, nil)
+ return "enc:aesgcm:" + base64.RawStdEncoding.EncodeToString(out), nil
+}
+
+func decryptAESGCM(b64 string, enc string) ([]byte, error) {
+ if !bytes.HasPrefix([]byte(enc), []byte("enc:aesgcm:")) {
+ return nil, errors.New("not encrypted")
+ }
+ key, err := decode32ByteKey(b64)
+ if err != nil {
+ return nil, err
+ }
+ blob, err := base64.RawStdEncoding.DecodeString(enc[len("enc:aesgcm:"):])
+ if err != nil {
+ return nil, err
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ nonceSize := aead.NonceSize()
+ if len(blob) < nonceSize {
+ return nil, errors.New("ciphertext too short")
+ }
+ nonce, ct := blob[:nonceSize], blob[nonceSize:]
+ return aead.Open(nil, nonce, ct, nil)
+}
+
+
+
+package mapper
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/glueops/autoglue/internal/common"
+ "github.com/glueops/autoglue/internal/handlers/dto"
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+)
+
+func ClusterToDTO(c models.Cluster) dto.ClusterResponse {
+ var bastion *dto.ServerResponse
+ if c.BastionServer != nil {
+ b := ServerToDTO(*c.BastionServer)
+ bastion = &b
+ }
+
+ var captainDomain *dto.DomainResponse
+ if c.CaptainDomainID != nil && c.CaptainDomain.ID != uuid.Nil {
+ dr := DomainToDTO(c.CaptainDomain)
+ captainDomain = &dr
+ }
+
+ var controlPlane *dto.RecordSetResponse
+ if c.ControlPlaneRecordSet != nil {
+ rr := RecordSetToDTO(*c.ControlPlaneRecordSet)
+ controlPlane = &rr
+ }
+
+ var cfqdn *string
+ if captainDomain != nil && controlPlane != nil {
+ fq := fmt.Sprintf("%s.%s", controlPlane.Name, captainDomain.DomainName)
+ cfqdn = &fq
+ }
+
+ var appsLB *dto.LoadBalancerResponse
+ if c.AppsLoadBalancer != nil {
+ lr := LoadBalancerToDTO(*c.AppsLoadBalancer)
+ appsLB = &lr
+ }
+
+ var glueOpsLB *dto.LoadBalancerResponse
+ if c.GlueOpsLoadBalancer != nil {
+ lr := LoadBalancerToDTO(*c.GlueOpsLoadBalancer)
+ glueOpsLB = &lr
+ }
+
+ nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
+ for _, np := range c.NodePools {
+ nps = append(nps, NodePoolToDTO(np))
+ }
+
+ return dto.ClusterResponse{
+ ID: c.ID,
+ Name: c.Name,
+ CaptainDomain: captainDomain,
+ ControlPlaneRecordSet: controlPlane,
+ ControlPlaneFQDN: cfqdn,
+ AppsLoadBalancer: appsLB,
+ GlueOpsLoadBalancer: glueOpsLB,
+ BastionServer: bastion,
+ Provider: c.Provider,
+ Region: c.Region,
+ Status: c.Status,
+ LastError: c.LastError,
+ RandomToken: c.RandomToken,
+ CertificateKey: c.CertificateKey,
+ NodePools: nps,
+ DockerImage: c.DockerImage,
+ DockerTag: c.DockerTag,
+ CreatedAt: c.CreatedAt,
+ UpdatedAt: c.UpdatedAt,
+ }
+}
+
+func NodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
+ labels := make([]dto.LabelResponse, 0, len(np.Labels))
+ for _, l := range np.Labels {
+ labels = append(labels, dto.LabelResponse{
+ Key: l.Key,
+ Value: l.Value,
+ })
+ }
+
+ annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
+ for _, a := range np.Annotations {
+ annotations = append(annotations, dto.AnnotationResponse{
+ Key: a.Key,
+ Value: a.Value,
+ })
+ }
+
+ taints := make([]dto.TaintResponse, 0, len(np.Taints))
+ for _, t := range np.Taints {
+ taints = append(taints, dto.TaintResponse{
+ Key: t.Key,
+ Value: t.Value,
+ Effect: t.Effect,
+ })
+ }
+
+ servers := make([]dto.ServerResponse, 0, len(np.Servers))
+ for _, s := range np.Servers {
+ servers = append(servers, ServerToDTO(s))
+ }
+
+ return dto.NodePoolResponse{
+ AuditFields: common.AuditFields{
+ ID: np.ID,
+ OrganizationID: np.OrganizationID,
+ CreatedAt: np.CreatedAt,
+ UpdatedAt: np.UpdatedAt,
+ },
+ Name: np.Name,
+ Role: dto.NodeRole(np.Role),
+ Labels: labels,
+ Annotations: annotations,
+ Taints: taints,
+ Servers: servers,
+ }
+}
+
+func ServerToDTO(s models.Server) dto.ServerResponse {
+ return dto.ServerResponse{
+ ID: s.ID,
+ Hostname: s.Hostname,
+ PrivateIPAddress: s.PrivateIPAddress,
+ PublicIPAddress: s.PublicIPAddress,
+ Role: s.Role,
+ Status: s.Status,
+ SSHUser: s.SSHUser,
+ SshKeyID: s.SshKeyID,
+ CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+func DomainToDTO(d models.Domain) dto.DomainResponse {
+ return dto.DomainResponse{
+ ID: d.ID.String(),
+ OrganizationID: d.OrganizationID.String(),
+ DomainName: d.DomainName,
+ ZoneID: d.ZoneID,
+ Status: d.Status,
+ LastError: d.LastError,
+ CredentialID: d.CredentialID.String(),
+ CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+func RecordSetToDTO(rs models.RecordSet) dto.RecordSetResponse {
+ return dto.RecordSetResponse{
+ ID: rs.ID.String(),
+ DomainID: rs.DomainID.String(),
+ Name: rs.Name,
+ Type: rs.Type,
+ TTL: rs.TTL,
+ Values: []byte(rs.Values),
+ Fingerprint: rs.Fingerprint,
+ Status: rs.Status,
+ Owner: rs.Owner,
+ LastError: rs.LastError,
+ CreatedAt: rs.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: rs.UpdatedAt.UTC().Format(time.RFC3339),
+ }
+}
+
+func LoadBalancerToDTO(lb models.LoadBalancer) dto.LoadBalancerResponse {
+ return dto.LoadBalancerResponse{
+ ID: lb.ID,
+ OrganizationID: lb.OrganizationID,
+ Name: lb.Name,
+ Kind: lb.Kind,
+ PublicIPAddress: lb.PublicIPAddress,
+ PrivateIPAddress: lb.PrivateIPAddress,
+ CreatedAt: lb.CreatedAt,
+ UpdatedAt: lb.UpdatedAt,
+ }
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/datatypes"
+)
+
+type Account struct {
+ // example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
+ UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
+ User User `gorm:"foreignKey:UserID" json:"-"`
+ Provider string `gorm:"not null" json:"provider"`
+ Subject string `gorm:"not null" json:"subject"`
+ Email *string `json:"email,omitempty"`
+ EmailVerified bool `gorm:"not null;default:false" json:"email_verified"`
+ Profile datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'" json:"profile"`
+ SecretHash *string `json:"-"`
+ CreatedAt time.Time `gorm:"type:timestamptz;column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Action struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
+ Label string `gorm:"type:varchar(255);not null;uniqueIndex" json:"label"`
+ Description string `gorm:"type:text;not null" json:"description"`
+ MakeTarget string `gorm:"type:varchar(255);not null;uniqueIndex" json:"make_target"`
+ CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
+ UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "github.com/glueops/autoglue/internal/common"
+)
+
+type Annotation struct {
+ common.AuditFields `gorm:"embedded"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Key string `gorm:"not null" json:"key"`
+ Value string `gorm:"not null" json:"value"`
+ NodePools []NodePool `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type APIKey struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
+ OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
+ Scope string `gorm:"not null;default:''" json:"scope"`
+ Purpose string `json:"purpose"`
+ ClusterID *uuid.UUID `json:"cluster_id,omitempty"`
+ IsEphemeral bool `json:"is_ephemeral"`
+ Name string `gorm:"not null;default:''" json:"name"`
+ KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
+ SecretHash *string `json:"-"`
+ UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
+ Revoked bool `gorm:"not null;default:false" json:"revoked"`
+ Prefix *string `json:"prefix,omitempty"`
+ LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
+ CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
+ UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Backup struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
+ OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_credential,priority:1"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Enabled bool `gorm:"not null;default:false" json:"enabled"`
+ CredentialID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uniq_org_credential,priority:2" json:"credential_id"`
+ Credential Credential `gorm:"foreignKey:CredentialID" json:"credential,omitempty"`
+ CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
+ UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+const (
+ ClusterRunStatusQueued = "queued"
+ ClusterRunStatusRunning = "running"
+ ClusterRunStatusSuccess = "success"
+ ClusterRunStatusFailed = "failed"
+ ClusterRunStatusCanceled = "canceled"
+)
+
+type ClusterRun struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
+ OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
+ ClusterID uuid.UUID `json:"cluster_id" gorm:"type:uuid;index"`
+ Action string `json:"action" gorm:"type:text;not null"`
+ Status string `json:"status" gorm:"type:text;not null"`
+ Error string `json:"error" gorm:"type:text;not null"`
+ CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
+ UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
+ FinishedAt time.Time `json:"finished_at,omitempty" gorm:"type:timestamptz" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+const (
+ ClusterStatusPrePending = "pre_pending" // needs validation
+ ClusterStatusIncomplete = "incomplete" // invalid/missing shape
+ ClusterStatusPending = "pending" // valid shape, waiting for provisioning
+ ClusterStatusProvisioning = "provisioning"
+ ClusterStatusReady = "ready"
+ ClusterStatusFailed = "failed" // provisioning/runtime failure
+ ClusterStatusBootstrapping = "bootstrapping"
+)
+
+type Cluster struct {
+ ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
+ OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Name string `gorm:"not null" json:"name"`
+ Provider string `json:"provider"`
+ Region string `json:"region"`
+ Status string `gorm:"type:varchar(20);not null;default:'pre_pending'" json:"status"`
+ LastError string `gorm:"type:text;not null;default:''" json:"last_error"`
+ CaptainDomainID *uuid.UUID `gorm:"type:uuid" json:"captain_domain_id"`
+ CaptainDomain Domain `gorm:"foreignKey:CaptainDomainID" json:"captain_domain"`
+ ControlPlaneRecordSetID *uuid.UUID `gorm:"type:uuid" json:"control_plane_record_set_id,omitempty"`
+ ControlPlaneRecordSet *RecordSet `gorm:"foreignKey:ControlPlaneRecordSetID" json:"control_plane_record_set,omitempty"`
+ AppsLoadBalancerID *uuid.UUID `gorm:"type:uuid" json:"apps_load_balancer_id,omitempty"`
+ AppsLoadBalancer *LoadBalancer `gorm:"foreignKey:AppsLoadBalancerID" json:"apps_load_balancer,omitempty"`
+ GlueOpsLoadBalancerID *uuid.UUID `gorm:"type:uuid" json:"glueops_load_balancer_id,omitempty"`
+ GlueOpsLoadBalancer *LoadBalancer `gorm:"foreignKey:GlueOpsLoadBalancerID" json:"glueops_load_balancer,omitempty"`
+ BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"`
+ BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"`
+ NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
+ RandomToken string `json:"random_token"`
+ CertificateKey string `json:"certificate_key"`
+ EncryptedKubeconfig string `gorm:"type:text" json:"-"`
+ KubeIV string `json:"-"`
+ KubeTag string `json:"-"`
+ DockerImage string `json:"docker_image"`
+ DockerTag string `json:"docker_tag"`
+ CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
+ UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/datatypes"
+)
+
+type Credential struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
+ OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_provider_scopekind_scope,priority:1" json:"organization_id"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Provider string `gorm:"type:varchar(50);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:2;index:idx_provider_kind"`
+ Kind string `gorm:"type:varchar(50);not null;index:idx_provider_kind;index:idx_kind_scope"`
+ ScopeKind string `gorm:"type:varchar(20);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:3"`
+ Scope datatypes.JSON `gorm:"type:jsonb;not null;default:'{}';index:idx_kind_scope"`
+ ScopeFingerprint string `gorm:"type:char(64);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:4;index"`
+ SchemaVersion int `gorm:"not null;default:1"`
+ Name string `gorm:"type:varchar(100);not null;default:''"`
+ ScopeVersion int `gorm:"not null;default:1"`
+ AccountID string `gorm:"type:varchar(32)"`
+ Region string `gorm:"type:varchar(32)"`
+ EncryptedData string `gorm:"not null"`
+ IV string `gorm:"not null"`
+ Tag string `gorm:"not null"`
+ CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
+ UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/datatypes"
+)
+
+type Domain struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
+ OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_domain,priority:1"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ DomainName string `gorm:"type:varchar(253);not null;uniqueIndex:uniq_org_domain,priority:2"`
+ ZoneID string `gorm:"type:varchar(128);not null;default:''"` // backfilled for R53 (e.g. "/hostedzone/Z123...")
+ Status string `gorm:"type:varchar(20);not null;default:'pending'"` // pending, provisioning, ready, failed
+ LastError string `gorm:"type:text;not null;default:''"`
+ CredentialID uuid.UUID `gorm:"type:uuid;not null" json:"credential_id"`
+ Credential Credential `gorm:"foreignKey:CredentialID" json:"credential,omitempty"`
+ CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
+ UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
+}
+
+type RecordSet struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
+ DomainID uuid.UUID `gorm:"type:uuid;not null;index"`
+ Domain Domain `gorm:"foreignKey:DomainID;constraint:OnDelete:CASCADE"`
+ Name string `gorm:"type:varchar(253);not null"` // e.g. "endpoint" (relative to DomainName)
+ Type string `gorm:"type:varchar(10);not null;index"` // A, AAAA, CNAME, TXT, MX, SRV, NS, CAA...
+ TTL *int `gorm:""` // nil for alias targets (Route 53 ignores TTL for alias)
+ Values datatypes.JSON `gorm:"type:jsonb;not null;default:'[]'"`
+ Fingerprint string `gorm:"type:char(64);not null;index"` // sha256 of canonical(name,type,ttl,values|alias)
+ Status string `gorm:"type:varchar(20);not null;default:'pending'"`
+ Owner string `gorm:"type:varchar(16);not null;default:'unknown'"` // 'autoglue' | 'external' | 'unknown'
+ LastError string `gorm:"type:text;not null;default:''"`
+ _ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:1"` // tag holder
+ _ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:2"`
+ _ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:3"`
+ CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
+ UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "gorm.io/datatypes"
+)
+
+type Job struct {
+ ID string `gorm:"type:varchar;primaryKey" json:"id"` // no default; supply from app
+ QueueName string `gorm:"type:varchar;not null" json:"queue_name"`
+ Status string `gorm:"type:varchar;not null" json:"status"`
+ Arguments datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'"`
+ Result datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'"`
+ LastError *string `gorm:"type:varchar"`
+ RetryCount int `gorm:"not null;default:0"`
+ MaxRetry int `gorm:"not null;default:0"`
+ RetryInterval int `gorm:"not null;default:0"`
+ ScheduledAt time.Time `gorm:"type:timestamptz;default:now();index"`
+ StartedAt *time.Time `gorm:"type:timestamptz;index"`
+ CreatedAt time.Time `gorm:"type:timestamptz;column:created_at;not null;default:now()"`
+ UpdatedAt time.Time `gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
+}
+
+
+
+package models
+
+import (
+ "github.com/glueops/autoglue/internal/common"
+)
+
+type Label struct {
+ common.AuditFields
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Key string `gorm:"not null" json:"key"`
+ Value string `gorm:"not null" json:"value"`
+ NodePools []NodePool `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
+}
+
+
+
+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()"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type MasterKey struct {
+ ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
+ Key string `gorm:"not null"`
+ IsActive bool `gorm:"default:true"`
+ CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Membership struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
+ UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
+ User User `gorm:"foreignKey:UserID" json:"-"`
+ OrganizationID uuid.UUID `gorm:"index;not null" json:"org_id" format:"uuid"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"-"`
+ Role string `gorm:"not null;default:'member'" json:"role"`
+ CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "github.com/glueops/autoglue/internal/common"
+)
+
+type NodePool struct {
+ common.AuditFields
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Name string `gorm:"not null" json:"name"`
+ Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
+ Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
+ Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
+ Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
+ Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
+ //Topology string `gorm:"not null,default:'stacked'" json:"topology,omitempty"` // stacked or external
+ Role string `gorm:"not null,default:'worker'" json:"role,omitempty"` // master, worker, or etcd (etcd only if topology = external
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type OrganizationKey struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
+ OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ MasterKeyID uuid.UUID `gorm:"type:uuid;not null"`
+ MasterKey MasterKey `gorm:"foreignKey:MasterKeyID;constraint:OnDelete:CASCADE" json:"master_key"`
+ EncryptedKey string `gorm:"not null"`
+ IV string `gorm:"not null"`
+ Tag string `gorm:"not null"`
+ CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Organization struct {
+ // example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
+ Name string `gorm:"not null" json:"name"`
+ Domain *string `gorm:"index" json:"domain"`
+ CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type RefreshToken struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ UserID uuid.UUID `gorm:"index;not null" json:"user_id"`
+ FamilyID uuid.UUID `gorm:"type:uuid;index;not null" json:"family_id"`
+ TokenHash string `gorm:"uniqueIndex;not null" json:"-"`
+ ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
+ RevokedAt *time.Time `json:"revoked_at"`
+ CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
+}
+
+
+
+package models
+
+import (
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type Server struct {
+ ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
+ OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Hostname string `json:"hostname"`
+ PublicIPAddress *string `json:"public_ip_address,omitempty"`
+ PrivateIPAddress string `gorm:"not null" json:"private_ip_address"`
+ SSHUser string `gorm:"not null" json:"ssh_user"`
+ SshKeyID uuid.UUID `gorm:"type:uuid;not null" json:"ssh_key_id"`
+ SshKey SshKey `gorm:"foreignKey:SshKeyID" json:"ssh_key"`
+ Role string `gorm:"not null" json:"role" enums:"master,worker,bastion"` // e.g., "master", "worker", "bastion"
+ Status string `gorm:"default:'pending'" json:"status" enums:"pending, provisioning, ready, failed"` // pending, provisioning, ready, failed
+ NodePools []NodePool `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
+ SSHHostKey string `gorm:"column:ssh_host_key"`
+ SSHHostKeyAlgo string `gorm:"column:ssh_host_key_algo"`
+ CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
+}
+
+func (s *Server) BeforeSave(tx *gorm.DB) error {
+ role := strings.ToLower(strings.TrimSpace(s.Role))
+ if role == "bastion" {
+ if s.PublicIPAddress == nil || strings.TrimSpace(*s.PublicIPAddress) == "" {
+ return errors.New("public_ip_address is required for role=bastion")
+ }
+ }
+ return nil
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type SigningKey struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Kid string `gorm:"uniqueIndex;not null" json:"kid"` // key id (header 'kid')
+ Alg string `gorm:"not null" json:"alg"` // RS256|RS384|RS512|EdDSA
+ Use string `gorm:"not null;default:'sig'" json:"use"` // "sig"
+ IsActive bool `gorm:"not null;default:true" json:"is_active"`
+ PublicPEM string `gorm:"type:text;not null" json:"-"`
+ PrivatePEM string `gorm:"type:text;not null" json:"-"`
+ NotBefore *time.Time `json:"-"`
+ ExpiresAt *time.Time `json:"-"`
+ CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
+ UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
+ RotatedFrom *uuid.UUID `json:"-"` // previous key id, if any
+}
+
+
+
+package models
+
+import (
+ "github.com/glueops/autoglue/internal/common"
+)
+
+type SshKey struct {
+ common.AuditFields
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Name string `gorm:"not null" json:"name"`
+ PublicKey string `gorm:"not null"`
+ EncryptedPrivateKey string `gorm:"not null"`
+ PrivateIV string `gorm:"not null"`
+ PrivateTag string `gorm:"not null"`
+ Fingerprint string `gorm:"not null;index" json:"fingerprint"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Taint struct {
+ ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
+ OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
+ Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
+ Key string `gorm:"not null" json:"key"`
+ Value string `gorm:"not null" json:"value"`
+ Effect string `gorm:"not null" json:"effect"`
+ NodePools []NodePool `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
+ CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type UserEmail struct {
+ // example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
+ UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
+ User User `gorm:"foreignKey:UserID" json:"user"`
+ Email string `gorm:"not null" json:"email"`
+ IsVerified bool `gorm:"not null;default:false" json:"is_verified"`
+ IsPrimary bool `gorm:"not null;default:false" json:"is_primary"`
+ CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
+}
+
+
+
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type User struct {
+ // example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
+ DisplayName *string `json:"display_name,omitempty"`
+ PrimaryEmail *string `json:"primary_email,omitempty"`
+ AvatarURL *string `json:"avatar_url,omitempty"`
+ IsDisabled bool `json:"is_disabled"`
+ IsAdmin bool `gorm:"default:false" json:"is_admin"`
+ CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
+}
+
+
+
+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)
+ }
+ }
+}
+
+
+
+package utils
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+)
+
+var (
+ ErrNoActiveMasterKey = errors.New("no active master key found")
+ ErrInvalidOrgID = errors.New("invalid organization ID")
+ ErrCredentialNotFound = errors.New("credential not found")
+ ErrInvalidMasterKeyLen = errors.New("invalid master key length")
+)
+
+func randomBytes(n int) ([]byte, error) {
+ b := make([]byte, n)
+ if _, err := io.ReadFull(rand.Reader, b); err != nil {
+ return nil, fmt.Errorf("rand: %w", err)
+ }
+ return b, nil
+}
+
+func encryptAESGCM(plaintext, key []byte) (cipherNoTag, iv, tag []byte, _ error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("gcm: %w", err)
+ }
+ if gcm.NonceSize() != 12 {
+ return nil, nil, nil, fmt.Errorf("unexpected nonce size: %d", gcm.NonceSize())
+ }
+
+ iv, err = randomBytes(gcm.NonceSize())
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ // Goβs GCM returns ciphertext||tag, with 16-byte tag.
+ cipherWithTag := gcm.Seal(nil, iv, plaintext, nil)
+ if len(cipherWithTag) < 16 {
+ return nil, nil, nil, errors.New("ciphertext too short")
+ }
+ tagLen := 16
+ cipherNoTag = cipherWithTag[:len(cipherWithTag)-tagLen]
+ tag = cipherWithTag[len(cipherWithTag)-tagLen:]
+ return cipherNoTag, iv, tag, nil
+}
+
+func decryptAESGCM(cipherNoTag, key, iv, tag []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("gcm: %w", err)
+ }
+ if gcm.NonceSize() != len(iv) {
+ return nil, fmt.Errorf("bad nonce size: %d", len(iv))
+ }
+ // Reattach tag
+ cipherWithTag := append(append([]byte{}, cipherNoTag...), tag...)
+ plain, err := gcm.Open(nil, iv, cipherWithTag, nil)
+ if err != nil {
+ return nil, fmt.Errorf("gcm open: %w", err)
+ }
+ return plain, nil
+}
+
+func EncodeB64(b []byte) string {
+ return base64.StdEncoding.EncodeToString(b)
+}
+
+func DecodeB64(s string) ([]byte, error) {
+ return base64.StdEncoding.DecodeString(s)
+}
+
+
+
+package utils
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// ErrorResponse is a simple, reusable error payload.
+// swagger:model ErrorResponse
+type ErrorResponse struct {
+ // A machine-readable error code, e.g. "validation_error"
+ // example: validation_error
+ Code string `json:"code"`
+ // Human-readable message
+ // example: slug is required
+ Message string `json:"message"`
+}
+
+func WriteJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func WriteError(w http.ResponseWriter, status int, code, msg string) {
+ WriteJSON(w, status, ErrorResponse{Code: code, Message: msg})
+}
+
+
+
+package utils
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+
+ "github.com/glueops/autoglue/internal/models"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+func getMasterKey(db *gorm.DB) ([]byte, error) {
+ var mk models.MasterKey
+ if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrNoActiveMasterKey
+ }
+ return nil, fmt.Errorf("querying master key: %w", err)
+ }
+
+ keyBytes, err := base64.StdEncoding.DecodeString(mk.Key)
+ if err != nil {
+ return nil, fmt.Errorf("decoding master key: %w", err)
+ }
+ if len(keyBytes) != 32 {
+ return nil, fmt.Errorf("%w: got %d, want 32", ErrInvalidMasterKeyLen, len(keyBytes))
+ }
+ return keyBytes, nil
+}
+
+func getOrCreateTenantKey(orgID string, db *gorm.DB) ([]byte, error) {
+ var orgKey models.OrganizationKey
+ err := db.Where("organization_id = ?", orgID).First(&orgKey).Error
+ if err == nil {
+ encKeyB64 := orgKey.EncryptedKey
+ ivB64 := orgKey.IV
+ tagB64 := orgKey.Tag
+
+ encryptedKey, err := DecodeB64(encKeyB64)
+ if err != nil {
+ return nil, fmt.Errorf("decode enc key: %w", err)
+ }
+
+ iv, err := DecodeB64(ivB64)
+ if err != nil {
+ return nil, fmt.Errorf("decode iv: %w", err)
+ }
+
+ tag, err := DecodeB64(tagB64)
+ if err != nil {
+ return nil, fmt.Errorf("decode tag: %w", err)
+ }
+
+ masterKey, err := getMasterKey(db)
+ if err != nil {
+ return nil, err
+ }
+
+ return decryptAESGCM(encryptedKey, masterKey, iv, tag)
+ }
+
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, err
+ }
+
+ // Create new tenant key and wrap with the current master key
+ orgUUID, err := uuid.Parse(orgID)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidOrgID, err)
+ }
+
+ tenantKey, err := randomBytes(32)
+ if err != nil {
+ return nil, fmt.Errorf("tenant key gen: %w", err)
+ }
+
+ masterKey, err := getMasterKey(db)
+ if err != nil {
+ return nil, err
+ }
+
+ encrypted, iv, tag, err := encryptAESGCM(tenantKey, masterKey)
+ if err != nil {
+ return nil, fmt.Errorf("wrap tenant key: %w", err)
+ }
+
+ var mk models.MasterKey
+ if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrNoActiveMasterKey
+ }
+ return nil, fmt.Errorf("querying master key: %w", err)
+ }
+
+ orgKey = models.OrganizationKey{
+ OrganizationID: orgUUID,
+ MasterKeyID: mk.ID,
+ EncryptedKey: EncodeB64(encrypted),
+ IV: EncodeB64(iv),
+ Tag: EncodeB64(tag),
+ }
+ if err := db.Create(&orgKey).Error; err != nil {
+ return nil, fmt.Errorf("persist org key: %w", err)
+ }
+ return tenantKey, nil
+}
+
+
+
+package utils
+
+import (
+ "fmt"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+func EncryptForOrg(orgID uuid.UUID, plaintext []byte, db *gorm.DB) (cipherB64, ivB64, tagB64 string, err error) {
+ tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
+ if err != nil {
+ return "", "", "", err
+ }
+ ct, iv, tag, err := encryptAESGCM(plaintext, tenantKey)
+ if err != nil {
+ return "", "", "", err
+ }
+ return EncodeB64(ct), EncodeB64(iv), EncodeB64(tag), nil
+}
+
+func DecryptForOrg(orgID uuid.UUID, cipherB64, ivB64, tagB64 string, db *gorm.DB) (string, error) {
+ tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
+ if err != nil {
+ return "", err
+ }
+ ct, err := DecodeB64(cipherB64)
+ if err != nil {
+ return "", fmt.Errorf("decode cipher: %w", err)
+ }
+ iv, err := DecodeB64(ivB64)
+ if err != nil {
+ return "", fmt.Errorf("decode iv: %w", err)
+ }
+ tag, err := DecodeB64(tagB64)
+ if err != nil {
+ return "", fmt.Errorf("decode tag: %w", err)
+ }
+ plain, err := decryptAESGCM(ct, tenantKey, iv, tag)
+ if err != nil {
+ return "", err
+ }
+ return string(plain), nil
+}
+
+
+
+package version
+
+import (
+ "fmt"
+ "runtime"
+ "runtime/debug"
+)
+
+var (
+ Version = "dev"
+ Commit = "none"
+ Date = "unknown"
+ BuiltBy = "local"
+)
+
+func Info() string {
+ v := fmt.Sprintf("Version: %s\nCommit: %s\nBuilt: %s\nBuiltBy: %s\nGo: %s %s/%s",
+ Version, Commit, Date, BuiltBy, runtime.Version(), runtime.GOOS, runtime.GOARCH)
+
+ // Include VCS info from embedded build metadata (if available)
+ if bi, ok := debug.ReadBuildInfo(); ok {
+ for _, s := range bi.Settings {
+ switch s.Key {
+ case "vcs":
+ v += fmt.Sprintf("\nVCS: %s", s.Value)
+ case "vcs.revision":
+ v += fmt.Sprintf("\nRevision: %s", s.Value)
+ case "vcs.time":
+ v += fmt.Sprintf("\nCommitTime: %s", s.Value)
+ case "vcs.modified":
+ v += fmt.Sprintf("\nModified: %s", s.Value)
+ }
+ }
+ }
+ return v
+}
+
+
+
+package web
+
+import (
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+)
+
+func DevProxy(target string) (http.Handler, error) {
+ u, err := url.Parse(target)
+ if err != nil {
+ return nil, err
+ }
+ p := httputil.NewSingleHostReverseProxy(u)
+ return p, nil
+}
+
+
+
+package web
+
+import (
+ "embed"
+ "io"
+ "io/fs"
+ "net/http"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// NOTE: Vite outputs to web/dist with assets in dist/assets.
+// If you add more nested folders in the future, include them here too.
+
+//go:embed dist
+var distFS embed.FS
+
+// spaFileSystem serves embedded dist/ files with SPA fallback to index.html
+type spaFileSystem struct {
+ fs fs.FS
+}
+
+func (s spaFileSystem) Open(name string) (fs.File, error) {
+ // Normalize, strip leading slash
+ if strings.HasPrefix(name, "/") {
+ name = name[1:]
+ }
+ // Try exact file
+ f, err := s.fs.Open(name)
+ if err == nil {
+ return f, nil
+ }
+
+ // If the requested file doesn't exist, fall back to index.html for SPA routes
+ // BUT only if it's not obviously a static asset extension
+ ext := strings.ToLower(filepath.Ext(name))
+ switch ext {
+ case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg",
+ ".svg", ".webp", ".gif", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".br", ".gz":
+ return nil, fs.ErrNotExist
+ }
+
+ return s.fs.Open("index.html")
+}
+
+func newDistFS() (fs.FS, error) {
+ return fs.Sub(distFS, "dist")
+}
+
+// SPAHandler returns an http.Handler that serves the embedded UI (with caching)
+func SPAHandler() (http.Handler, error) {
+ sub, err := newDistFS()
+ if err != nil {
+ return nil, err
+ }
+ spa := spaFileSystem{fs: sub}
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/") ||
+ r.URL.Path == "/api" ||
+ strings.HasPrefix(r.URL.Path, "/swagger") ||
+ strings.HasPrefix(r.URL.Path, "/db-studio") ||
+ strings.HasPrefix(r.URL.Path, "/debug/pprof") {
+ http.NotFound(w, r)
+ return
+ }
+
+ raw := strings.TrimSpace(r.URL.Path)
+ if raw == "" || raw == "/" {
+ raw = "/index.html"
+ }
+
+ clean := path.Clean("/" + raw) // nosemgrep: autoglue.filesystem.no-path-clean
+ filePath := strings.TrimPrefix(clean, "/")
+ if filePath == "" {
+ filePath = "index.html"
+ }
+
+ // Try compressed variants for assets and HTML
+ // NOTE: we only change *Content-Encoding*; Content-Type derives from original ext
+ // Always vary on Accept-Encoding
+ w.Header().Add("Vary", "Accept-Encoding")
+
+ enc := r.Header.Get("Accept-Encoding")
+ if tryServeCompressed(w, r, spa, filePath, enc) {
+ return
+ }
+
+ // Fallback: normal open (or SPA fallback)
+ f, err := spa.Open(filePath)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ defer f.Close()
+
+ if strings.HasSuffix(filePath, ".html") {
+ w.Header().Set("Cache-Control", "no-cache")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ }
+
+ info, _ := f.Stat()
+ modTime := time.Now()
+ if info != nil {
+ modTime = info.ModTime()
+ }
+ http.ServeContent(w, r, filePath, modTime, file{f})
+ }), nil
+}
+
+func tryServeCompressed(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, enc string) bool {
+ wantsBR := strings.Contains(enc, "br")
+ wantsGZ := strings.Contains(enc, "gzip")
+
+ type cand struct {
+ logical string // MIME/type decision uses this (uncompressed name)
+ physical string // actual file we open (with .br/.gz)
+ enc string
+ }
+
+ var cands []cand
+
+ // 1) direct compressed variant of requested path (rare for SPA routes, but cheap to try)
+ if wantsBR {
+ cands = append(cands, cand{logical: filePath, physical: filePath + ".br", enc: "br"})
+ }
+ if wantsGZ {
+ cands = append(cands, cand{logical: filePath, physical: filePath + ".gz", enc: "gzip"})
+ }
+
+ // 2) SPA route: fall back to compressed index.html
+ if filepath.Ext(filePath) == "" {
+ if wantsBR {
+ cands = append(cands, cand{logical: "index.html", physical: "index.html.br", enc: "br"})
+ }
+ if wantsGZ {
+ cands = append(cands, cand{logical: "index.html", physical: "index.html.gz", enc: "gzip"})
+ }
+ }
+
+ for _, c := range cands {
+ f, err := spa.fs.Open(c.physical) // open EXACT path so we don't accidentally get SPA fallback
+ if err != nil {
+ continue
+ }
+ defer f.Close()
+
+ // Cache headers
+ if strings.HasSuffix(c.logical, ".html") {
+ w.Header().Set("Cache-Control", "no-cache")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ }
+
+ if ct := mimeByExt(path.Ext(c.logical)); ct != "" {
+ w.Header().Set("Content-Type", ct)
+ }
+ w.Header().Set("Content-Encoding", c.enc)
+ w.Header().Add("Vary", "Accept-Encoding")
+
+ info, _ := f.Stat()
+ modTime := time.Now()
+ if info != nil {
+ modTime = info.ModTime()
+ }
+
+ // Serve the precompressed bytes
+ http.ServeContent(w, r, c.physical, modTime, file{f})
+ return true
+ }
+ return false
+}
+
+func serveIfExists(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, ext, encoding string) bool {
+ cf := filePath + ext
+ f, err := spa.Open(cf)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+
+ // Set caching headers
+ if strings.HasSuffix(filePath, ".html") {
+ w.Header().Set("Cache-Control", "no-cache")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ }
+ // Preserve original content type by extension of *uncompressed* file
+ if ct := mimeByExt(path.Ext(filePath)); ct != "" {
+ w.Header().Set("Content-Type", ct)
+ }
+ w.Header().Set("Content-Encoding", encoding)
+
+ info, _ := f.Stat()
+ modTime := time.Now()
+ if info != nil {
+ modTime = info.ModTime()
+ }
+
+ // Serve the compressed bytes as an io.ReadSeeker if possible
+ http.ServeContent(w, r, cf, modTime, file{f})
+ return true
+}
+
+func mimeByExt(ext string) string {
+ switch strings.ToLower(ext) {
+ case ".html":
+ return "text/html; charset=utf-8"
+ case ".js":
+ return "application/javascript"
+ case ".css":
+ return "text/css; charset=utf-8"
+ case ".json":
+ return "application/json"
+ case ".svg":
+ return "image/svg+xml"
+ case ".png":
+ return "image/png"
+ case ".jpg", ".jpeg":
+ return "image/jpeg"
+ case ".webp":
+ return "image/webp"
+ case ".ico":
+ return "image/x-icon"
+ case ".woff2":
+ return "font/woff2"
+ case ".woff":
+ return "font/woff"
+ default:
+ return "" // let Go sniff if empty
+ }
+}
+
+// file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent)
+type file struct{ fs.File }
+
+func (f file) Seek(offset int64, whence int) (int64, error) {
+ if s, ok := f.File.(io.Seeker); ok {
+ return s.Seek(offset, whence)
+ }
+ // Fallback: not seekable
+ return 0, fs.ErrInvalid
+}
+
+
+
+
+
+
+
+export const metaApi = {
+ footer: async () => {
+ const res = await fetch("/api/v1/version", { cache: "no-store" })
+ if (!res.ok) throw new Error("failed to fetch version")
+ return (await res.json()) as {
+ built: string
+ builtBy: string
+ commit: string
+ go: string
+ goArch: string
+ goOS: string
+ version: string
+ }
+ },
+}
+
+
+
+// api/with-refresh.ts
+import { authStore, type TokenPair } from "@/auth/store.ts"
+import { API_BASE } from "@/sdkClient.ts"
+
+let inflightRefresh: Promise | null = null
+
+async function doRefresh(): Promise {
+ const tokens = authStore.get()
+ if (!tokens?.refresh_token) return false
+
+ try {
+ const res = await fetch(`${API_BASE}/auth/refresh`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ refresh_token: tokens.refresh_token }),
+ })
+
+ if (!res.ok) return false
+
+ const next = (await res.json()) as TokenPair
+ authStore.set(next)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function refreshOnce(): Promise {
+ if (!inflightRefresh) {
+ inflightRefresh = doRefresh().finally(() => {
+ inflightRefresh = null
+ })
+ }
+ return inflightRefresh
+}
+
+function isUnauthorized(err: any): boolean {
+ return (
+ err?.status === 401 ||
+ err?.cause?.status === 401 ||
+ err?.response?.status === 401 ||
+ (err instanceof Response && err.status === 401)
+ )
+}
+
+export async function withRefresh(fn: () => Promise): Promise {
+ // Optional: attempt a proactive refresh if close to expiry
+ if (authStore.willExpireSoon?.(30)) {
+ await refreshOnce()
+ }
+
+ try {
+ return await fn()
+ } catch (error) {
+ if (!isUnauthorized(error)) throw error
+
+ const ok = await refreshOnce()
+ if (!ok) throw error
+
+ return await fn()
+ }
+}
+
+
+
+const KEY = "autoglue.org"
+
+let cache: string | null = localStorage.getItem(KEY)
+
+export const orgStore = {
+ get(): string | null {
+ return cache
+ },
+ set(id: string) {
+ cache = id
+ localStorage.setItem(KEY, id)
+ window.dispatchEvent(new CustomEvent("autoglue:org-change", { detail: id }))
+ },
+ subscribe(fn: (id: string | null) => void) {
+ const onCustom = (e: Event) => fn((e as CustomEvent).detail ?? null)
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === KEY) {
+ cache = e.newValue
+ fn(cache)
+ }
+ }
+ window.addEventListener("autoglue:org-change", onCustom as EventListener)
+ window.addEventListener("storage", onStorage)
+ return () => {
+ window.removeEventListener("autoglue:org-change", onCustom as EventListener)
+ window.removeEventListener("storage", onStorage)
+ }
+ },
+}
+
+
+
+export type TokenPair = {
+ access_token: string
+ refresh_token: string
+ token_type: string
+ expires_in: number
+}
+
+const KEY = "autoglue.tokens"
+const EVT = "autoglue.auth-change"
+
+let cache: TokenPair | null = read()
+
+function read(): TokenPair | null {
+ try {
+ const raw = localStorage.getItem(KEY)
+ return raw ? (JSON.parse(raw) as TokenPair) : null
+ } catch {
+ return null
+ }
+}
+
+function write(tokens: TokenPair | null) {
+ if (tokens) localStorage.setItem(KEY, JSON.stringify(tokens))
+ else localStorage.removeItem(KEY)
+}
+
+function emit(tokens: TokenPair | null) {
+ // include payload for convenience
+ window.dispatchEvent(new CustomEvent(EVT, { detail: tokens }))
+}
+
+export const authStore = {
+ /** Current tokens (from in-memory cache). */
+ get(): TokenPair | null {
+ return cache
+ },
+
+ /** Set tokens; updates memory, localStorage, broadcasts event. */
+ set(tokens: TokenPair | null) {
+ cache = tokens
+ write(tokens)
+ emit(tokens)
+ },
+
+ /** Fresh read from storage (useful if you suspect out-of-band changes). */
+ reload(): TokenPair | null {
+ cache = read()
+ return cache
+ },
+
+ /** Is there an access token at all? (not checking expiry) */
+ isAuthed(): boolean {
+ return !!cache?.access_token
+ },
+
+ /** Convenience accessor */
+ getAccessToken(): string | null {
+ return cache?.access_token ?? null
+ },
+
+ /** Decode JWT exp and check expiry (no clock skew handling here). */
+ isExpired(nowSec = Math.floor(Date.now() / 1000)): boolean {
+ const exp = decodeExp(cache?.access_token)
+ return exp !== null ? nowSec >= exp : true
+ },
+
+ /** Will expire within `thresholdSec` (default 60s). */
+ willExpireSoon(thresholdSec = 60, nowSec = Math.floor(Date.now() / 1000)): boolean {
+ const exp = decodeExp(cache?.access_token)
+ return exp !== null ? exp - nowSec <= thresholdSec : true
+ },
+
+ logout() {
+ authStore.set(null)
+ },
+
+ /** Subscribe to changes (pairs well with useSyncExternalStore). */
+ subscribe(fn: (tokens: TokenPair | null) => void): () => void {
+ const onCustom = (e: Event) => fn((e as CustomEvent).detail ?? null)
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === KEY) {
+ cache = read()
+ fn(cache)
+ }
+ }
+
+ window.addEventListener(EVT, onCustom as EventListener)
+ window.addEventListener("storage", onStorage)
+ return () => {
+ window.removeEventListener(EVT, onCustom as EventListener)
+ window.removeEventListener("storage", onStorage)
+ }
+ },
+}
+
+// --- helpers ---
+function decodeExp(jwt?: string): number | null {
+ if (!jwt) return null
+ const parts = jwt.split(".")
+ if (parts.length < 2) return null
+ try {
+ const json = JSON.parse(atob(base64urlToBase64(parts[1])))
+ const exp = typeof json?.exp === "number" ? json.exp : null
+ return exp ?? null
+ } catch {
+ return null
+ }
+}
+
+function base64urlToBase64(s: string) {
+ return s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice((2 - ((s.length * 3) % 4)) % 4)
+}
+
+
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Accordion({ ...props }: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+
+
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+function AlertDialog({ ...props }: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogPortal({ ...props }: React.ComponentProps) {
+ return
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
+
+
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
+
+
+
+"use client"
+
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+function AspectRatio({ ...props }: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
+
+
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({ className, ...props }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
+
+
+
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return
+}
+
+export { Badge, badgeVariants }
+
+
+
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
+
+
+
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : "div"
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }
+
+
+
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
+
+
+
+"use client"
+
+import * as React from "react"
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
+ week_number: cn(
+ "text-[0.8rem] select-none text-muted-foreground",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-md",
+ defaultClassNames.day
+ ),
+ range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return
+ }
+
+ if (orientation === "right") {
+ return
+ }
+
+ return
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+
+
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
+
+
+
+import * as React from "react"
+import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) return
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+ )
+}
+
+export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }
+
+
+
+"use client"
+
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps["children"]
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+ {children}
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+