From 28068191f4f7e210f4056509f18e2e2055547d85 Mon Sep 17 00:00:00 2001 From: Alanis Date: Wed, 4 Mar 2026 15:07:13 +0000 Subject: [PATCH] feat: Add remote url (#737) * Add remote url to cluster dto from env * Delete ui.zip * Delete Archive.zip * Delete go.sum * rebase go sum --- atlas.hcl | 20 - cmd/serve.go | 6 +- go.sum | 61 +- internal/api/mount_api_routes.go | 5 +- internal/api/mount_cluster_routes.go | 39 +- internal/api/routes.go | 4 +- internal/bg/bg.go | 4 +- internal/bg/cluster_action.go | 3 +- internal/config/config.go | 4 + internal/handlers/clusters.go | 78 +- internal/handlers/dto/clusters.go | 1 + postgres/Dockerfile | 2 +- repomix-output.xml | 38823 +++++++++++++++++++++++++ schema.sql | 435 - ui/src/pages/cluster-page.tsx | 3 + 15 files changed, 38905 insertions(+), 583 deletions(-) delete mode 100644 atlas.hcl create mode 100644 repomix-output.xml delete mode 100644 schema.sql 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