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