This file is a merged representation of the entire codebase, combined into a single document by Repomix.
This section contains a summary of this file.
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
- File path as an attribute
- Full contents of the file
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Files are sorted by Git change count (files with more changes are at the bottom)
.github/
configs/
labeler.yml
workflows/
docker-publish.yml
release.yml
cmd/
db.go
encryption.go
keys_generate.go
root.go
serve.go
version.go
internal/
api/
httpmiddleware/
auth.go
context.go
platform_admin.go
rbac.go
mount_admin_routes.go
mount_annotation_routes.go
mount_api_routes.go
mount_auth_routes.go
mount_cluster_routes.go
mount_credential_routes.go
mount_db_studio.go
mount_dns_routes.go
mount_label_routes.go
mount_load_balancer_routes.go
mount_me_routes.go
mount_meta_routes.go
mount_node_pool_routes.go
mount_org_routes.go
mount_pprof_routes.go
mount_server_routes.go
mount_ssh_routes.go
mount_swagger_routes.go
mount_taint_routes.go
mw_logger.go
mw_security.go
routes.go
utils.go
app/
runtime.go
auth/
hash.go
issue.go
jwks_export.go
jwt_issue.go
jwt_signer.go
jwt_validate.go
refresh.go
validate_keys.go
bg/
archer_cleanup.go
backup_s3.go
bastion.go
bg.go
cluster_action.go
cluster_bootstrap.go
cluster_setup.go
dns.go
org_key_sweeper.go
prepare_cluster.go
tokens_cleanup.go
common/
audit.go
config/
config.go
db/
db.go
migrate.go
handlers/
dto/
actions.go
annotations.go
auth.go
cluster_runs.go
clusters.go
credentials.go
dns.go
jobs.go
jwks.go
labels.go
load_balancers.go
node_pools.go
servers.go
ssh_keys.go
taints.go
actions.go
annotations.go
auth.go
cluster_runs.go
clusters.go
credentials.go
dns.go
health.go
jobs.go
jwks.go
labels.go
load_balancers.go
me_keys.go
me.go
node_pools_test.go
node_pools.go
orgs.go
servers_test.go
servers.go
ssh_keys.go
taints.go
version.go
keys/
base64util.go
export.go
keys.go
mapper/
cluster.go
models/
account.go
action.go
annotation.go
api_key.go
backup.go
cluster_runs.go
cluster.go
credential.go
domain.go
job.go
label.go
load_balancer.go
master_key.go
membership.go
node_pool.go
organization-key.go
organization.go
refresh_token.go
server.go
signing_key.go
ssh-key.go
taint.go
user_email.go
user.go
testutil/
pgtest/
pgtest.go
utils/
crypto.go
helpers.go
keys.go
org-crypto.go
version/
version.go
web/
devproxy.go
static.go
ui/
public/
vite.svg
src/
api/
actions.ts
annotations.ts
archer_admin.ts
clusters.ts
credentials.ts
dns.ts
footer.ts
labels.ts
loadbalancers.ts
me.ts
node_pools.ts
servers.ts
ssh.ts
taints.ts
with-refresh.ts
auth/
logout.ts
org.ts
store.ts
components/
ui/
accordion.tsx
alert-dialog.tsx
alert.tsx
aspect-ratio.tsx
avatar.tsx
badge.tsx
breadcrumb.tsx
button-group.tsx
button.tsx
calendar.tsx
card.tsx
carousel.tsx
chart.tsx
checkbox.tsx
collapsible.tsx
command.tsx
context-menu.tsx
dialog.tsx
drawer.tsx
dropdown-menu.tsx
empty.tsx
field.tsx
form.tsx
hover-card.tsx
input-group.tsx
input-otp.tsx
input.tsx
item.tsx
kbd.tsx
label.tsx
menubar.tsx
navigation-menu.tsx
pagination.tsx
popover.tsx
progress.tsx
radio-group.tsx
resizable.tsx
scroll-area.tsx
select.tsx
separator.tsx
sheet.tsx
sidebar.tsx
skeleton.tsx
slider.tsx
sonner.tsx
spinner.tsx
switch.tsx
table.tsx
tabs.tsx
textarea.tsx
toggle-group.tsx
toggle.tsx
tooltip.tsx
protected-route.tsx
hooks/
use-auth-actions.ts
use-auth.ts
use-me.ts
use-mobile.ts
layouts/
app-shell.tsx
footer.tsx
nav-config.ts
org-switcher.tsx
theme-switcher.tsx
topbar.tsx
lib/
utils.ts
pages/
org/
api-keys.tsx
members.tsx
settings.tsx
actions-page.tsx
annotation-page.tsx
cluster-page.tsx
credential-page.tsx
dns-page.tsx
docs-page.tsx
jobs-page.tsx
labels-page.tsx
load-balancers-page.tsx
login.tsx
me-page.tsx
node-pools-page.tsx
server-page.tsx
ssh-page.tsx
taints-page.tsx
providers/
index.tsx
theme-provider.tsx
types/
rapidoc.d.ts
App.tsx
index.css
main.tsx
sdkClient.ts
.gitignore
.prettierignore
.prettierrc.json
components.json
eslint.config.js
index.html
package.json
README.md
tsconfig.app.json
tsconfig.json
tsconfig.node.json
tsconfig.tsbuildinfo
vite.config.ts
.dockerignore
.env.example
.gitignore
.semgrep.yml
agents.md
Archive.zip
docker-compose.yml
Dockerfile
go.mod
main.go
Makefile
README.md
ui.zip
This section contains the contents of the repository's files.
####
## This is managed via https://github.com/internal-GlueOps/github-shared-files-sync . Any changes to this file may be overridden by our automation
####
include-in-release-notes:
- changed-files:
- any-glob-to-any-file: '**'
####
## This is managed via https://github.com/internal-GlueOps/github-shared-files-sync . Any changes to this file may be overridden by our automation
####
changelog:
exclude:
labels:
- 'ignore'
# authors:
# - 'glueops-terraform-svc-account'
# - 'glueops-svc-account'
# - 'glueops-renovatebot'
categories:
- title: Breaking Changes π
labels:
- 'major'
- 'breaking-change'
- title: Enhancements π
labels:
- 'minor'
- 'enhancement'
- 'new-feature'
- title: Other π
labels:
- 'auto-update'
- 'patch'
- 'fix'
- 'bugfix'
- 'bug'
- 'hotfix'
- 'dependencies'
- 'include-in-release-notes'
package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"time"
"github.com/glueops/autoglue/internal/config"
"github.com/spf13/cobra"
)
var dbCmd = &cobra.Command{
Use: "db",
Short: "Database utilities",
}
var dbPsqlCmd = &cobra.Command{
Use: "psql",
Short: "Open a psql session to the app database",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
if cfg.DbURL == "" {
return errors.New("database.url is empty")
}
psql := "psql"
if runtime.GOOS == "windows" {
psql = "psql.exe"
}
ctx, cancel := context.WithTimeout(context.Background(), 72*time.Hour)
defer cancel()
psqlCmd := exec.CommandContext(ctx, psql, cfg.DbURL)
psqlCmd.Stdin, psqlCmd.Stdout, psqlCmd.Stderr = os.Stdin, os.Stdout, os.Stderr
fmt.Println("Launching psqlβ¦")
return psqlCmd.Run()
},
}
func init() {
dbCmd.AddCommand(dbPsqlCmd)
rootCmd.AddCommand(dbCmd)
}
package cmd
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"github.com/glueops/autoglue/internal/app"
"github.com/glueops/autoglue/internal/models"
"github.com/spf13/cobra"
)
var rotateMasterCmd = &cobra.Command{
Use: "rotate-master",
Short: "Generate and activate a new master encryption key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
rt := app.NewRuntime()
db := rt.DB
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return fmt.Errorf("generating random key: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := db.Model(&models.MasterKey{}).
Where("is_active = ?", true).
Update("is_active", false).Error; err != nil {
return fmt.Errorf("deactivating previous key: %w", err)
}
if err := db.Create(&models.MasterKey{
Key: encoded,
IsActive: true,
}).Error; err != nil {
return fmt.Errorf("creating new master key: %w", err)
}
fmt.Println("Master key rotated successfully")
return nil
},
}
var createMasterCmd = &cobra.Command{
Use: "create-master",
Short: "Generate and activate a new master encryption key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
rt := app.NewRuntime()
db := rt.DB
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return fmt.Errorf("generating random key: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := db.Create(&models.MasterKey{
Key: encoded,
IsActive: true,
}).Error; err != nil {
return fmt.Errorf("creating master key: %w", err)
}
fmt.Println("Master key created successfully")
return nil
},
}
var encryptCmd = &cobra.Command{
Use: "encrypt",
Short: "Manage autoglue encryption keys",
Long: "Manage autoglue master encryption keys used for securing data.",
}
func init() {
encryptCmd.AddCommand(rotateMasterCmd)
encryptCmd.AddCommand(createMasterCmd)
rootCmd.AddCommand(encryptCmd)
}
package cmd
import (
"fmt"
"time"
"github.com/glueops/autoglue/internal/app"
"github.com/glueops/autoglue/internal/keys"
"github.com/spf13/cobra"
)
var (
alg string
rsaBits int
kidFlag string
nbfStr string
expStr string
)
var keysCmd = &cobra.Command{
Use: "keys",
Short: "Manage JWT signing keys",
}
var keysGenCmd = &cobra.Command{
Use: "generate",
Short: "Generate and store a new signing key",
RunE: func(_ *cobra.Command, _ []string) error {
rt := app.NewRuntime()
var nbfPtr, expPtr *time.Time
if nbfStr != "" {
t, err := time.Parse(time.RFC3339, nbfStr)
if err != nil {
return err
}
nbfPtr = &t
}
if expStr != "" {
t, err := time.Parse(time.RFC3339, expStr)
if err != nil {
return err
}
expPtr = &t
}
rec, err := keys.GenerateAndStore(rt.DB, rt.Cfg.JWTPrivateEncKey, keys.GenOpts{
Alg: alg,
Bits: rsaBits,
KID: kidFlag,
NBF: nbfPtr,
EXP: expPtr,
})
if err != nil {
return err
}
fmt.Printf("created signing key\n")
fmt.Printf(" kid: %s\n", rec.Kid)
fmt.Printf(" alg: %s\n", rec.Alg)
fmt.Printf(" active: %v\n", rec.IsActive)
if rec.NotBefore != nil {
fmt.Printf(" nbf: %s\n", rec.NotBefore.Format(time.RFC3339))
}
if rec.ExpiresAt != nil {
fmt.Printf(" exp: %s\n", rec.ExpiresAt.Format(time.RFC3339))
}
return nil
},
}
func init() {
rootCmd.AddCommand(keysCmd)
keysCmd.AddCommand(keysGenCmd)
keysGenCmd.Flags().StringVarP(&alg, "alg", "a", "EdDSA", "Signing alg: EdDSA|RS256|RS384|RS512")
keysGenCmd.Flags().IntVarP(&rsaBits, "bits", "b", 3072, "RSA key size (when alg is RS*)")
keysGenCmd.Flags().StringVarP(&kidFlag, "kid", "k", "", "Key ID (optional; auto if empty)")
keysGenCmd.Flags().StringVarP(&nbfStr, "nbf", "n", "", "Not Before (RFC3339)")
keysGenCmd.Flags().StringVarP(&expStr, "exp", "e", "", "Expires At (RFC3339)")
}
package cmd
import (
"log"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "autoglue",
Short: "Autoglue Kubernetes Cluster Management",
Long: "autoglue is used to manage the lifecycle of kubernetes clusters on GlueOps supported cloud providers",
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
err := serveCmd.RunE(cmd, args)
if err != nil {
log.Fatal(err)
}
} else {
_ = cmd.Help()
}
},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
func init() {
cobra.OnInitialize()
}
package cmd
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/dyaksa/archer"
"github.com/glueops/autoglue/internal/api"
"github.com/glueops/autoglue/internal/app"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start API server",
RunE: func(_ *cobra.Command, _ []string) error {
rt := app.NewRuntime()
cfg, err := config.Load()
if err != nil {
return err
}
jobs, err := bg.NewJobs(rt.DB, cfg.DbURL)
if err != nil {
log.Fatalf("failed to init background jobs: %v", err)
}
rt.DB.Where("status IN ?", []string{"scheduled", "queued", "pending"}).Delete(&models.Job{})
// Start workers in background ONCE
go func() {
if err := jobs.Start(); err != nil {
log.Fatalf("failed to start background jobs: %v", err)
}
}()
defer jobs.Stop()
// daily cleanups
{
// schedule next 03:30 local time
next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 30*time.Minute)
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"archer_cleanup",
bg.CleanupArgs{RetainDays: 7, Table: "jobs"},
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
if err != nil {
log.Fatalf("failed to enqueue archer cleanup job: %v", err)
}
// schedule next 03:45 local time
next2 := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 45*time.Minute)
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"tokens_cleanup",
bg.TokensCleanupArgs{},
archer.WithScheduleTime(next2),
archer.WithMaxRetries(1),
)
if err != nil {
log.Fatalf("failed to enqueue token cleanup job: %v", err)
}
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"db_backup_s3",
bg.DbBackupArgs{IntervalS: 3600},
archer.WithMaxRetries(1),
archer.WithScheduleTime(time.Now().Add(1*time.Hour)),
)
if err != nil {
log.Fatalf("failed to enqueue backup jobs: %v", err)
}
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"dns_reconcile",
bg.DNSReconcileArgs{MaxDomains: 25, MaxRecords: 100, IntervalS: 10},
archer.WithScheduleTime(time.Now().Add(5*time.Second)),
archer.WithMaxRetries(1),
)
if err != nil {
log.Fatalf("failed to enqueue dns reconcile: %v", err)
}
_, err := jobs.Enqueue(
context.Background(),
uuid.NewString(),
"bootstrap_bastion",
bg.BastionBootstrapArgs{IntervalS: 10},
archer.WithMaxRetries(3),
// while debugging, avoid extra schedule delay:
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
)
if err != nil {
log.Printf("failed to enqueue bootstrap_bastion: %v", err)
}
/*
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"prepare_cluster",
bg.ClusterPrepareArgs{IntervalS: 120},
archer.WithMaxRetries(3),
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
)
if err != nil {
log.Printf("failed to enqueue prepare_cluster: %v", err)
}
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"cluster_setup",
bg.ClusterSetupArgs{
IntervalS: 120,
},
archer.WithMaxRetries(3),
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
)
if err != nil {
log.Printf("failed to enqueue cluster setup: %v", err)
}
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"cluster_bootstrap",
bg.ClusterBootstrapArgs{
IntervalS: 120,
},
archer.WithMaxRetries(3),
archer.WithScheduleTime(time.Now().Add(60*time.Second)),
)
if err != nil {
log.Printf("failed to enqueue cluster bootstrap: %v", err)
}
*/
_, err = jobs.Enqueue(
context.Background(),
uuid.NewString(),
"org_key_sweeper",
bg.OrgKeySweeperArgs{
IntervalS: 3600,
RetentionDays: 10,
},
archer.WithMaxRetries(1),
archer.WithScheduleTime(time.Now()),
)
if err != nil {
log.Printf("failed to enqueue org_key_sweeper: %v", err)
}
}
_ = auth.Refresh(rt.DB, rt.Cfg.JWTPrivateEncKey)
go func() {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for range t.C {
_ = auth.Refresh(rt.DB, rt.Cfg.JWTPrivateEncKey)
}
}()
r := api.NewRouter(rt.DB, jobs, nil)
if cfg.DBStudioEnabled {
dbURL := cfg.DbURLRO
if dbURL == "" {
dbURL = cfg.DbURL
}
studio, err := api.MountDbStudio(
dbURL,
"db-studio",
false,
)
if err != nil {
log.Fatalf("failed to init db studio: %v", err)
} else {
r = api.NewRouter(rt.DB, jobs, studio)
log.Printf("pgweb mounted at /db-studio/")
}
}
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: TimeoutExceptUpgrades(r, 60*time.Second, "request timed out"), // global safety
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
fmt.Printf("π API running on http://%s (ui.dev=%v)\n", addr, cfg.UIDev)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
<-ctx.Done()
fmt.Println("\nβ³ Shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
},
}
func init() {
rootCmd.AddCommand(serveCmd)
}
func TimeoutExceptUpgrades(next http.Handler, d time.Duration, msg string) http.Handler {
timeout := http.TimeoutHandler(next, d, msg)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If this is an upgrade (e.g., websocket), don't wrap.
if isUpgrade(r) {
next.ServeHTTP(w, r)
return
}
timeout.ServeHTTP(w, r)
})
}
func isUpgrade(r *http.Request) bool {
// Connection: Upgrade, Upgrade: websocket
if strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
return true
}
return false
}
package cmd
import (
"fmt"
"github.com/glueops/autoglue/internal/version"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(version.Info())
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
package httpmiddleware
import (
"net/http"
"strings"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AuthMiddleware authenticates either a user principal (JWT, user API key, app key/secret)
// or an org principal (org key/secret). If requireOrg is true, the request must have
// an organization resolved; otherwise org is optional.
//
// Org resolution order for user principals (when requireOrg == true):
// 1. X-Org-ID header (UUID)
// 2. chi URL param {id} (useful under /orgs/{id}/... routers)
// 3. single-membership fallback (exactly one membership)
//
// If none resolves, respond with org_required.
func AuthMiddleware(db *gorm.DB, requireOrg bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var user *models.User
var org *models.Organization
var roles []string
// --- 1) Authenticate principal ---
// Prefer org principal if explicit machine access is provided.
if orgKey := r.Header.Get("X-ORG-KEY"); orgKey != "" {
secret := r.Header.Get("X-ORG-SECRET")
org = auth.ValidateOrgKeyPair(orgKey, secret, db)
if org == nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid org credentials")
return
}
// org principal implies machine role
roles = []string{"org:machine"}
} else {
// User principals
if ah := r.Header.Get("Authorization"); strings.HasPrefix(ah, "Bearer ") {
user = auth.ValidateJWT(ah[7:], db)
} else if apiKey := r.Header.Get("X-API-KEY"); apiKey != "" {
user = auth.ValidateAPIKey(apiKey, db)
} else if appKey := r.Header.Get("X-APP-KEY"); appKey != "" {
secret := r.Header.Get("X-APP-SECRET")
user = auth.ValidateAppKeyPair(appKey, secret, db)
} else if c, err := r.Cookie("ag_jwt"); err == nil {
tok := strings.TrimSpace(c.Value)
if strings.HasPrefix(strings.ToLower(tok), "bearer ") {
tok = tok[7:]
}
if tok != "" {
user = auth.ValidateJWT(tok, db)
}
}
if user == nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
return
}
// --- 2) Resolve organization (user principal) ---
// A) Try X-Org-ID if present
if s := r.Header.Get("X-Org-ID"); s != "" {
oid, err := uuid.Parse(s)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_org_id", "X-Org-ID must be a UUID")
return
}
var o models.Organization
if err := db.First(&o, "id = ?", oid).Error; err != nil {
// Header provided but org not found
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "organization not found")
return
}
// Verify membership
if !userIsMember(db, user.ID, o.ID) {
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "user is not a member of specified org")
return
}
org = &o
}
// B) If still no org and requireOrg==true, try chi URL param {id}
if org == nil && requireOrg {
if sid := chi.URLParam(r, "id"); sid != "" {
if oid, err := uuid.Parse(sid); err == nil {
var o models.Organization
if err := db.First(&o, "id = ?", oid).Error; err == nil && userIsMember(db, user.ID, o.ID) {
org = &o
} else {
utils.WriteError(w, http.StatusUnauthorized, "org_forbidden", "user is not a member of specified org")
return
}
}
}
}
// C) Single-membership fallback (only if requireOrg==true and still nil)
if org == nil && requireOrg {
var ms []models.Membership
if err := db.Where("user_id = ?", user.ID).Find(&ms).Error; err == nil && len(ms) == 1 {
var o models.Organization
if err := db.First(&o, "id = ?", ms[0].OrganizationID).Error; err == nil {
org = &o
}
}
}
// D) Final check
if requireOrg && org == nil {
utils.WriteError(w, http.StatusUnauthorized, "org_required", "specify X-Org-ID or use an endpoint that does not require org")
return
}
// Populate roles if an org was resolved (optional for org-optional endpoints)
if org != nil {
roles = userRolesInOrg(db, user.ID, org.ID)
if len(roles) == 0 {
utils.WriteError(w, http.StatusForbidden, "forbidden", "no roles in organization")
return
}
}
}
// --- 3) Attach to context and proceed ---
ctx := r.Context()
if user != nil {
ctx = WithUser(ctx, user)
}
if org != nil {
ctx = WithOrg(ctx, org)
}
if roles != nil {
ctx = WithRoles(ctx, roles)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func userIsMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
var count int64
db.Model(&models.Membership{}).
Where("user_id = ? AND organization_id = ?", userID, orgID).
Count(&count)
return count > 0
}
func userRolesInOrg(db *gorm.DB, userID, orgID uuid.UUID) []string {
var m models.Membership
if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err == nil {
switch m.Role {
case "owner":
return []string{"role:owner", "role:admin", "role:member"}
case "admin":
return []string{"role:admin", "role:member"}
default:
return []string{"role:member"}
}
}
return nil
}
package httpmiddleware
import (
"context"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
)
type ctxKey string
const (
ctxUserKey ctxKey = "ctx_user"
ctxOrgKey ctxKey = "ctx_org"
ctxRolesKey ctxKey = "ctx_roles" // []string, user roles in current org
)
func WithUser(ctx context.Context, u *models.User) context.Context {
return context.WithValue(ctx, ctxUserKey, u)
}
func WithOrg(ctx context.Context, o *models.Organization) context.Context {
return context.WithValue(ctx, ctxOrgKey, o)
}
func WithRoles(ctx context.Context, roles []string) context.Context {
return context.WithValue(ctx, ctxRolesKey, roles)
}
func UserFrom(ctx context.Context) (*models.User, bool) {
u, ok := ctx.Value(ctxUserKey).(*models.User)
return u, ok && u != nil
}
func OrgFrom(ctx context.Context) (*models.Organization, bool) {
o, ok := ctx.Value(ctxOrgKey).(*models.Organization)
return o, ok && o != nil
}
func OrgIDFrom(ctx context.Context) (uuid.UUID, bool) {
if o, ok := OrgFrom(ctx); ok {
return o.ID, true
}
return uuid.Nil, false
}
func RolesFrom(ctx context.Context) ([]string, bool) {
r, ok := ctx.Value(ctxRolesKey).([]string)
return r, ok && r != nil
}
package httpmiddleware
import (
"net/http"
"github.com/glueops/autoglue/internal/utils"
)
// RequireAuthenticatedUser ensures a user principal is present (i.e. not an org/machine key).
func RequireAuthenticatedUser() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if user, ok := UserFrom(r.Context()); !ok || user == nil {
// No user in context -> probably org/machine principal, or unauthenticated
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user principal required")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequirePlatformAdmin requires a user principal with IsAdmin=true.
// This is platform-wide (non-org) admin and does NOT depend on org roles.
func RequirePlatformAdmin() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFrom(r.Context())
if !ok || user == nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user principal required")
return
}
if !user.IsAdmin {
utils.WriteError(w, http.StatusForbidden, "forbidden", "platform admin required")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireUserAdmin is an alias for RequirePlatformAdmin for readability at call sites.
func RequireUserAdmin() func(http.Handler) http.Handler {
return RequirePlatformAdmin()
}
package httpmiddleware
import (
"net/http"
"github.com/glueops/autoglue/internal/utils"
)
func RequireRole(minRole string) func(http.Handler) http.Handler {
// order: owner > admin > member
rank := map[string]int{
"role:member": 1,
"role:admin": 2,
"role:owner": 3,
"org:machine": 2,
"org:machine:ro": 1,
}
need := map[string]bool{
"member": true, "admin": true, "owner": true,
}
if !need[minRole] {
minRole = "member"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roles, ok := RolesFrom(r.Context())
if !ok || len(roles) == 0 {
utils.WriteError(w, http.StatusForbidden, "forbidden", "no roles in context")
return
}
max := 0
for _, ro := range roles {
if rank[ro] > max {
max = rank[ro]
}
}
if max < rank["role:"+minRole] {
utils.WriteError(w, http.StatusForbidden, "forbidden", "insufficient role")
return
}
next.ServeHTTP(w, r)
})
}
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountAdminRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authUser func(http.Handler) http.Handler) {
r.Route("/admin", func(admin chi.Router) {
admin.Route("/archer", func(archer chi.Router) {
archer.Use(authUser)
archer.Use(httpmiddleware.RequirePlatformAdmin())
archer.Get("/jobs", handlers.AdminListArcherJobs(db))
archer.Post("/jobs", handlers.AdminEnqueueArcherJob(db, jobs))
archer.Post("/jobs/{id}/retry", handlers.AdminRetryArcherJob(db))
archer.Post("/jobs/{id}/cancel", handlers.AdminCancelArcherJob(db))
archer.Get("/queues", handlers.AdminListArcherQueues(db))
})
admin.Route("/actions", func(action chi.Router) {
action.Use(authUser)
action.Use(httpmiddleware.RequirePlatformAdmin())
action.Get("/", handlers.ListActions(db))
action.Post("/", handlers.CreateAction(db))
action.Get("/{actionID}", handlers.GetAction(db))
action.Patch("/{actionID}", handlers.UpdateAction(db))
action.Delete("/{actionID}", handlers.DeleteAction(db))
})
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountAnnotationRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/annotations", func(a chi.Router) {
a.Use(authOrg)
a.Get("/", handlers.ListAnnotations(db))
a.Post("/", handlers.CreateAnnotation(db))
a.Get("/{id}", handlers.GetAnnotation(db))
a.Patch("/{id}", handlers.UpdateAnnotation(db))
a.Delete("/{id}", handlers.DeleteAnnotation(db))
})
}
package api
import (
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
r.Route("/api", func(api chi.Router) {
api.Route("/v1", func(v1 chi.Router) {
authUser := httpmiddleware.AuthMiddleware(db, false)
authOrg := httpmiddleware.AuthMiddleware(db, true)
// shared basics
mountMetaRoutes(v1)
mountAuthRoutes(v1, db)
// admin
mountAdminRoutes(v1, db, jobs, authUser)
// user/org scoped
mountMeRoutes(v1, db, authUser)
mountOrgRoutes(v1, db, authUser, authOrg)
mountCredentialRoutes(v1, db, authOrg)
mountSSHRoutes(v1, db, authOrg)
mountServerRoutes(v1, db, authOrg)
mountTaintRoutes(v1, db, authOrg)
mountLabelRoutes(v1, db, authOrg)
mountAnnotationRoutes(v1, db, authOrg)
mountNodePoolRoutes(v1, db, authOrg)
mountDNSRoutes(v1, db, authOrg)
mountLoadBalancerRoutes(v1, db, authOrg)
mountClusterRoutes(v1, db, jobs, authOrg)
})
})
}
package api
import (
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountAuthRoutes(r chi.Router, db *gorm.DB) {
r.Route("/auth", func(a chi.Router) {
a.Post("/{provider}/start", handlers.AuthStart(db))
a.Get("/{provider}/callback", handlers.AuthCallback(db))
a.Post("/refresh", handlers.Refresh(db))
a.Post("/logout", handlers.Logout(db))
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountClusterRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authOrg func(http.Handler) http.Handler) {
r.Route("/clusters", func(c chi.Router) {
c.Use(authOrg)
c.Get("/", handlers.ListClusters(db))
c.Post("/", handlers.CreateCluster(db))
c.Get("/{clusterID}", handlers.GetCluster(db))
c.Patch("/{clusterID}", handlers.UpdateCluster(db))
c.Delete("/{clusterID}", handlers.DeleteCluster(db))
c.Post("/{clusterID}/captain-domain", handlers.AttachCaptainDomain(db))
c.Delete("/{clusterID}/captain-domain", handlers.DetachCaptainDomain(db))
c.Post("/{clusterID}/control-plane-record-set", handlers.AttachControlPlaneRecordSet(db))
c.Delete("/{clusterID}/control-plane-record-set", handlers.DetachControlPlaneRecordSet(db))
c.Post("/{clusterID}/apps-load-balancer", handlers.AttachAppsLoadBalancer(db))
c.Delete("/{clusterID}/apps-load-balancer", handlers.DetachAppsLoadBalancer(db))
c.Post("/{clusterID}/glueops-load-balancer", handlers.AttachGlueOpsLoadBalancer(db))
c.Delete("/{clusterID}/glueops-load-balancer", handlers.DetachGlueOpsLoadBalancer(db))
c.Post("/{clusterID}/bastion", handlers.AttachBastionServer(db))
c.Delete("/{clusterID}/bastion", handlers.DetachBastionServer(db))
c.Post("/{clusterID}/kubeconfig", handlers.SetClusterKubeconfig(db))
c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db))
c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(db))
c.Get("/{clusterID}/runs", handlers.ListClusterRuns(db))
c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db))
c.Post("/{clusterID}/actions/{actionID}/runs", handlers.RunClusterAction(db, jobs))
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountCredentialRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/credentials", func(c chi.Router) {
c.Use(authOrg)
c.Get("/", handlers.ListCredentials(db))
c.Post("/", handlers.CreateCredential(db))
c.Get("/{id}", handlers.GetCredential(db))
c.Patch("/{id}", handlers.UpdateCredential(db))
c.Delete("/{id}", handlers.DeleteCredential(db))
c.Post("/{id}/reveal", handlers.RevealCredential(db))
})
}
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
pgapi "github.com/sosedoff/pgweb/pkg/api"
pgclient "github.com/sosedoff/pgweb/pkg/client"
pgcmd "github.com/sosedoff/pgweb/pkg/command"
)
func MountDbStudio(dbURL, prefix string, readonly bool) (http.Handler, error) {
// Normalize prefix for pgweb:
// - no leading slash
// - always trailing slash if not empty
prefix = strings.Trim(prefix, "/")
if prefix != "" {
prefix = prefix + "/"
}
pgcmd.Opts = pgcmd.Options{
URL: dbURL,
Prefix: prefix, // e.g. "db-studio/"
ReadOnly: readonly,
Sessions: false,
LockSession: true,
SkipOpen: true,
}
cli, err := pgclient.NewFromUrl(dbURL, nil)
if err != nil {
return nil, err
}
if readonly {
_ = cli.SetReadOnlyMode()
}
if err := cli.Test(); err != nil {
return nil, err
}
pgapi.DbClient = cli
gin.SetMode(gin.ReleaseMode)
g := gin.New()
g.Use(gin.Recovery())
pgapi.SetupRoutes(g)
pgapi.SetupMetrics(g)
return g, nil
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountDNSRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/dns", func(d chi.Router) {
d.Use(authOrg)
d.Get("/domains", handlers.ListDomains(db))
d.Post("/domains", handlers.CreateDomain(db))
d.Get("/domains/{id}", handlers.GetDomain(db))
d.Patch("/domains/{id}", handlers.UpdateDomain(db))
d.Delete("/domains/{id}", handlers.DeleteDomain(db))
d.Get("/domains/{domain_id}/records", handlers.ListRecordSets(db))
d.Post("/domains/{domain_id}/records", handlers.CreateRecordSet(db))
d.Get("/records/{id}", handlers.GetRecordSet(db))
d.Patch("/records/{id}", handlers.UpdateRecordSet(db))
d.Delete("/records/{id}", handlers.DeleteRecordSet(db))
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountLabelRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/labels", func(l chi.Router) {
l.Use(authOrg)
l.Get("/", handlers.ListLabels(db))
l.Post("/", handlers.CreateLabel(db))
l.Get("/{id}", handlers.GetLabel(db))
l.Patch("/{id}", handlers.UpdateLabel(db))
l.Delete("/{id}", handlers.DeleteLabel(db))
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountLoadBalancerRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/load-balancers", func(l chi.Router) {
l.Use(authOrg)
l.Get("/", handlers.ListLoadBalancers(db))
l.Post("/", handlers.CreateLoadBalancer(db))
l.Get("/{id}", handlers.GetLoadBalancer(db))
l.Patch("/{id}", handlers.UpdateLoadBalancer(db))
l.Delete("/{id}", handlers.DeleteLoadBalancer(db))
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountMeRoutes(r chi.Router, db *gorm.DB, authUser func(http.Handler) http.Handler) {
r.Route("/me", func(me chi.Router) {
me.Use(authUser)
me.Get("/", handlers.GetMe(db))
me.Patch("/", handlers.UpdateMe(db))
me.Get("/api-keys", handlers.ListUserAPIKeys(db))
me.Post("/api-keys", handlers.CreateUserAPIKey(db))
me.Delete("/api-keys/{id}", handlers.DeleteUserAPIKey(db))
})
}
package api
import (
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
)
func mountMetaRoutes(r chi.Router) {
// Versioned JWKS for swagger
r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
r.Get("/healthz", handlers.HealthCheck)
r.Get("/version", handlers.Version)
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountNodePoolRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/node-pools", func(n chi.Router) {
n.Use(authOrg)
n.Get("/", handlers.ListNodePools(db))
n.Post("/", handlers.CreateNodePool(db))
n.Get("/{id}", handlers.GetNodePool(db))
n.Patch("/{id}", handlers.UpdateNodePool(db))
n.Delete("/{id}", handlers.DeleteNodePool(db))
// Servers
n.Get("/{id}/servers", handlers.ListNodePoolServers(db))
n.Post("/{id}/servers", handlers.AttachNodePoolServers(db))
n.Delete("/{id}/servers/{serverId}", handlers.DetachNodePoolServer(db))
// Taints
n.Get("/{id}/taints", handlers.ListNodePoolTaints(db))
n.Post("/{id}/taints", handlers.AttachNodePoolTaints(db))
n.Delete("/{id}/taints/{taintId}", handlers.DetachNodePoolTaint(db))
// Labels
n.Get("/{id}/labels", handlers.ListNodePoolLabels(db))
n.Post("/{id}/labels", handlers.AttachNodePoolLabels(db))
n.Delete("/{id}/labels/{labelId}", handlers.DetachNodePoolLabel(db))
// Annotations
n.Get("/{id}/annotations", handlers.ListNodePoolAnnotations(db))
n.Post("/{id}/annotations", handlers.AttachNodePoolAnnotations(db))
n.Delete("/{id}/annotations/{annotationId}", handlers.DetachNodePoolAnnotation(db))
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountOrgRoutes(r chi.Router, db *gorm.DB, authUser, authOrg func(http.Handler) http.Handler) {
r.Route("/orgs", func(o chi.Router) {
o.Use(authUser)
o.Get("/", handlers.ListMyOrgs(db))
o.Post("/", handlers.CreateOrg(db))
o.Group(func(og chi.Router) {
og.Use(authOrg)
og.Get("/{id}", handlers.GetOrg(db))
og.Patch("/{id}", handlers.UpdateOrg(db))
og.Delete("/{id}", handlers.DeleteOrg(db))
// members
og.Get("/{id}/members", handlers.ListMembers(db))
og.Post("/{id}/members", handlers.AddOrUpdateMember(db))
og.Delete("/{id}/members/{user_id}", handlers.RemoveMember(db))
// org-scoped key/secret pair
og.Get("/{id}/api-keys", handlers.ListOrgKeys(db))
og.Post("/{id}/api-keys", handlers.CreateOrgKey(db))
og.Delete("/{id}/api-keys/{key_id}", handlers.DeleteOrgKey(db))
})
})
}
package api
import (
httpPprof "net/http/pprof"
"github.com/go-chi/chi/v5"
)
func mountPprofRoutes(r chi.Router) {
r.Route("/debug/pprof", func(pr chi.Router) {
pr.Get("/", httpPprof.Index)
pr.Get("/cmdline", httpPprof.Cmdline)
pr.Get("/profile", httpPprof.Profile)
pr.Get("/symbol", httpPprof.Symbol)
pr.Get("/trace", httpPprof.Trace)
pr.Handle("/allocs", httpPprof.Handler("allocs"))
pr.Handle("/block", httpPprof.Handler("block"))
pr.Handle("/goroutine", httpPprof.Handler("goroutine"))
pr.Handle("/heap", httpPprof.Handler("heap"))
pr.Handle("/mutex", httpPprof.Handler("mutex"))
pr.Handle("/threadcreate", httpPprof.Handler("threadcreate"))
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountServerRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/servers", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListServers(db))
s.Post("/", handlers.CreateServer(db))
s.Get("/{id}", handlers.GetServer(db))
s.Patch("/{id}", handlers.UpdateServer(db))
s.Delete("/{id}", handlers.DeleteServer(db))
s.Post("/{id}/reset-hostkey", handlers.ResetServerHostKey(db))
})
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountSSHRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/ssh", func(s chi.Router) {
s.Use(authOrg)
s.Get("/", handlers.ListPublicSshKeys(db))
s.Post("/", handlers.CreateSSHKey(db))
s.Get("/{id}", handlers.GetSSHKey(db))
s.Delete("/{id}", handlers.DeleteSSHKey(db))
s.Get("/{id}/download", handlers.DownloadSSHKey(db))
})
}
package api
import (
"fmt"
"html/template"
"net/http"
"github.com/glueops/autoglue/docs"
"github.com/go-chi/chi/v5"
)
func mountSwaggerRoutes(r chi.Router) {
r.Get("/swagger", RapidDocHandler("/swagger/swagger.yaml"))
r.Get("/swagger/index.html", RapidDocHandler("/swagger/swagger.yaml"))
r.Get("/swagger/openapi.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/json"))
r.Get("/swagger/openapi.yaml", serveSwaggerFromEmbed(docs.SwaggerYAML, "application/x-yaml"))
}
var rapidDocTmpl = template.Must(template.New("redoc").Parse(`
AutoGlue API Docs
`))
func RapidDocHandler(specURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := r.Host
defaultServer := fmt.Sprintf("%s://%s/api/v1", scheme, host)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := rapidDocTmpl.Execute(w, map[string]string{
"SpecURL": specURL,
"DefaultServer": defaultServer,
}); err != nil {
http.Error(w, "failed to render docs", http.StatusInternalServerError)
return
}
}
}
package api
import (
"net/http"
"github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
func mountTaintRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) {
r.Route("/taints", func(t chi.Router) {
t.Use(authOrg)
t.Get("/", handlers.ListTaints(db))
t.Post("/", handlers.CreateTaint(db))
t.Get("/{id}", handlers.GetTaint(db))
t.Patch("/{id}", handlers.UpdateTaint(db))
t.Delete("/{id}", handlers.DeleteTaint(db))
})
}
package api
import (
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
)
func zeroLogMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
dur := time.Since(start)
ev := log.Info()
if ww.Status() >= 500 {
ev = log.Error()
}
ev.
Str("remote_ip", r.RemoteAddr).
Str("request_id", middleware.GetReqID(r.Context())).
Str("method", r.Method).
Str("path", r.URL.Path).
Int("status", ww.Status()).
Int("bytes", ww.BytesWritten()).
Dur("duration", dur).
Msg("http_request")
})
}
}
package api
import (
"net/http"
"strings"
"github.com/glueops/autoglue/internal/config"
)
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// HSTS (enable only over TLS/behind HTTPS)
// HSTS only when not in dev and over TLS/behind a proxy that terminates TLS
if !config.IsDev() {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
}
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")
if config.IsDev() {
// --- Relaxed CSP for Vite dev server & Google Fonts ---
// Allows inline/eval for React Refresh preamble, HMR websocket, and fonts.
// Tighten these as you move to prod or self-host fonts.
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
// Vite dev & inline preamble/eval:
"script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173 https://unpkg.com",
// allow dev style + Google Fonts
"style-src 'self' 'unsafe-inline' http://localhost:5173 https://fonts.googleapis.com",
"img-src 'self' data: blob:",
// Google font files
"font-src 'self' data: https://fonts.gstatic.com",
// HMR connections
"connect-src 'self' http://localhost:5173 ws://localhost:5173 ws://localhost:8080 https://api.github.com https://unpkg.com",
"frame-ancestors 'none'",
}, "; "))
} else {
// --- Strict CSP for production ---
// If you keep using Google Fonts in prod, add:
// style-src ... https://fonts.googleapis.com
// font-src ... https://fonts.gstatic.com
// Recommended: self-host fonts in prod and keep these tight.
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"script-src 'self' 'unsafe-inline' https://unpkg.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self' ws://localhost:8080 https://api.github.com https://unpkg.com",
"frame-ancestors 'none'",
}, "; "))
}
next.ServeHTTP(w, r)
})
}
package api
import (
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers"
"github.com/glueops/autoglue/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"gorm.io/gorm"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func NewRouter(db *gorm.DB, jobs *bg.Jobs, studio http.Handler) http.Handler {
zerolog.TimeFieldFormat = time.RFC3339
l := log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"})
log.Logger = l
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(zeroLogMiddleware())
r.Use(middleware.Recoverer)
r.Use(SecurityHeaders)
r.Use(requestBodyLimit(10 << 20))
r.Use(httprate.LimitByIP(1000, 1*time.Minute))
r.Use(middleware.StripSlashes)
allowed := getAllowedOrigins()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowed,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{
"Content-Type",
"Authorization",
"X-Org-ID",
"X-API-KEY",
"X-ORG-KEY",
"X-ORG-SECRET",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 600,
}))
r.Use(middleware.Maybe(
middleware.AllowContentType("application/json"),
func(r *http.Request) bool {
// return true => run AllowContentType
// return false => skip AllowContentType for this request
return !strings.HasPrefix(r.URL.Path, "/db-studio")
}))
//r.Use(middleware.AllowContentType("application/json"))
// Unversioned, non-auth endpoints
r.Get("/.well-known/jwks.json", handlers.JWKSHandler)
// Versioned API
mountAPIRoutes(r, db, jobs)
// Optional DB studio
if studio != nil {
r.Group(func(gr chi.Router) {
authUser := httpmiddleware.AuthMiddleware(db, false)
adminOnly := httpmiddleware.RequirePlatformAdmin()
gr.Use(authUser, adminOnly)
gr.Mount("/db-studio", studio)
})
}
// pprof
if config.IsDebug() {
mountPprofRoutes(r)
}
// Swagger
if config.IsSwaggerEnabled() {
mountSwaggerRoutes(r)
}
// UI dev/prod
if config.IsUIDev() {
fmt.Println("Running in development mode")
proxy, err := web.DevProxy("http://localhost:5173")
if err != nil {
log.Error().Err(err).Msg("dev proxy init failed")
return r // fallback
}
mux := http.NewServeMux()
mux.Handle("/api/", r)
mux.Handle("/api", r)
mux.Handle("/swagger", r)
mux.Handle("/swagger/", r)
mux.Handle("/db-studio/", r)
mux.Handle("/debug/pprof/", r)
mux.Handle("/", proxy)
return mux
} else {
fmt.Println("Running in production mode")
if h, err := web.SPAHandler(); err == nil {
r.NotFound(h.ServeHTTP)
} else {
log.Error().Err(err).Msg("spa handler init failed")
}
}
return r
}
package api
import (
"net/http"
"os"
"strings"
)
func requestBodyLimit(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
func getAllowedOrigins() []string {
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
s := strings.TrimSpace(p)
if s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
// Defaults (dev)
return []string{
"http://localhost:5173",
"http://localhost:8080",
}
}
func serveSwaggerFromEmbed(data []byte, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
// nosemgrep: go.lang.security.audit.xss.no-direct-write-to-responsewriter
_, _ = w.Write(data)
}
}
package app
import (
"log"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
type Runtime struct {
Cfg config.Config
DB *gorm.DB
}
func NewRuntime() *Runtime {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
d := db.Open(cfg.DbURL)
err = db.Run(d,
&models.Job{},
&models.MasterKey{},
&models.SigningKey{},
&models.User{},
&models.Organization{},
&models.Account{},
&models.Membership{},
&models.APIKey{},
&models.UserEmail{},
&models.RefreshToken{},
&models.OrganizationKey{},
&models.SshKey{},
&models.Server{},
&models.Taint{},
&models.Label{},
&models.Annotation{},
&models.NodePool{},
&models.Credential{},
&models.Domain{},
&models.RecordSet{},
&models.LoadBalancer{},
&models.Cluster{},
&models.Action{},
&models.Cluster{},
&models.ClusterRun{},
)
if err != nil {
log.Fatalf("Error initializing database: %v", err)
}
return &Runtime{
Cfg: cfg,
DB: d,
}
}
package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/alexedwards/argon2id"
)
func SHA256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
var argonParams = &argon2id.Params{
Memory: 64 * 1024, // 64MB
Iterations: 3,
Parallelism: 2,
SaltLength: 16,
KeyLength: 32,
}
func HashSecretArgon2id(plain string) (string, error) {
return argon2id.CreateHash(plain, argonParams)
}
func VerifySecretArgon2id(encodedHash, plain string) (bool, error) {
if encodedHash == "" {
return false, errors.New("empty hash")
}
return argon2id.ComparePasswordAndHash(plain, encodedHash)
}
func NotExpired(expiresAt *time.Time) bool {
return expiresAt == nil || time.Now().Before(*expiresAt)
}
package auth
import (
"crypto/rand"
"encoding/base64"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
// URL-safe, no padding
return base64.RawURLEncoding.EncodeToString(b), nil
}
// IssueUserAPIKey creates a single-token user API key (X-API-KEY)
func IssueUserAPIKey(db *gorm.DB, userID uuid.UUID, name string, ttl *time.Duration) (plaintext string, rec models.APIKey, err error) {
plaintext, err = randomToken(32)
if err != nil {
return "", models.APIKey{}, err
}
rec = models.APIKey{
Name: name,
Scope: "user",
UserID: &userID,
KeyHash: SHA256Hex(plaintext), // deterministic lookup
}
if ttl != nil {
ex := time.Now().Add(*ttl)
rec.ExpiresAt = &ex
}
if err = db.Create(&rec).Error; err != nil {
return "", models.APIKey{}, err
}
return plaintext, rec, nil
}
package auth
import (
"crypto/ed25519"
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
)
// base64url (no padding)
func b64url(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// convert small int (RSA exponent) to big-endian bytes
func fromInt(i int) []byte {
var x big.Int
x.SetInt64(int64(i))
return x.Bytes()
}
// --- public accessors for JWKS ---
// KeyMeta is a minimal metadata view exposed for JWKS rendering.
type KeyMeta struct {
Alg string
}
// MetaFor returns minimal metadata (currently the alg) for a given kid.
// If not found, returns zero value (Alg == "").
func MetaFor(kid string) KeyMeta {
kc.mu.RLock()
defer kc.mu.RUnlock()
if m, ok := kc.meta[kid]; ok {
return KeyMeta{Alg: m.Alg}
}
return KeyMeta{}
}
// KcCopy invokes fn with a shallow copy of the public key map (kid -> public key instance).
// Useful to iterate without holding the lock during JSON building.
func KcCopy(fn func(map[string]interface{})) {
kc.mu.RLock()
defer kc.mu.RUnlock()
out := make(map[string]interface{}, len(kc.pub))
for kid, pk := range kc.pub {
out[kid] = pk
}
fmt.Println(out)
fn(out)
}
// PubToJWK converts a parsed public key into bare JWK parameters + kty.
// - RSA: returns n/e (base64url) and kty="RSA"
// - Ed25519: returns x (base64url) and kty="OKP"
func PubToJWK(_kid, _alg string, pub any) (map[string]string, string) {
switch k := pub.(type) {
case *rsa.PublicKey:
return map[string]string{
"n": b64url(k.N.Bytes()),
"e": b64url(fromInt(k.E)),
}, "RSA"
case ed25519.PublicKey:
return map[string]string{
"x": b64url([]byte(k)),
}, "OKP"
default:
return nil, ""
}
}
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type IssueOpts struct {
Subject string
Issuer string
Audience string
TTL time.Duration
Claims map[string]any // extra app claims
}
func IssueAccessToken(opts IssueOpts) (string, error) {
kc.mu.RLock()
defer kc.mu.RUnlock()
if kc.selPriv == nil || kc.selKid == "" || kc.selAlg == "" {
return "", errors.New("no active signing key")
}
claims := jwt.MapClaims{
"iss": opts.Issuer,
"aud": opts.Audience,
"sub": opts.Subject,
"iat": time.Now().Unix(),
"exp": time.Now().Add(opts.TTL).Unix(),
}
for k, v := range opts.Claims {
claims[k] = v
}
var method jwt.SigningMethod
switch kc.selAlg {
case "RS256":
method = jwt.SigningMethodRS256
case "RS384":
method = jwt.SigningMethodRS384
case "RS512":
method = jwt.SigningMethodRS512
case "EdDSA":
method = jwt.SigningMethodEdDSA
default:
return "", errors.New("unsupported alg")
}
token := jwt.NewWithClaims(method, claims)
token.Header["kid"] = kc.selKid
return token.SignedString(kc.selPriv)
}
package auth
import (
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"sync"
"time"
"github.com/glueops/autoglue/internal/keys"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
type keyCache struct {
mu sync.RWMutex
pub map[string]interface{} // kid -> public key object
meta map[string]models.SigningKey
selKid string
selAlg string
selPriv any
}
var kc keyCache
// Refresh loads active keys into memory. Call on startup and periodically (ticker/cron).
func Refresh(db *gorm.DB, encKeyB64 string) error {
var rows []models.SigningKey
if err := db.Where("is_active = true AND (expires_at IS NULL OR expires_at > ?)", time.Now()).
Order("created_at desc").Find(&rows).Error; err != nil {
return err
}
pub := make(map[string]interface{}, len(rows))
meta := make(map[string]models.SigningKey, len(rows))
var selKid string
var selAlg string
var selPriv any
for i, r := range rows {
// parse public
block, _ := pem.Decode([]byte(r.PublicPEM))
if block == nil {
continue
}
var pubKey any
switch r.Alg {
case "RS256", "RS384", "RS512":
pubKey, _ = x509.ParsePKCS1PublicKey(block.Bytes)
if pubKey == nil {
// also allow PKIX format
if k, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
pubKey = k
}
}
case "EdDSA":
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err == nil {
if edk, ok := k.(ed25519.PublicKey); ok {
pubKey = edk
}
}
}
if pubKey == nil {
continue
}
pub[r.Kid] = pubKey
meta[r.Kid] = r
// pick first row as current signer (most recent because of order desc)
if i == 0 {
privPEM := r.PrivatePEM
// decrypt if necessary
if len(privPEM) > 10 && privPEM[:10] == "enc:aesgcm" {
pt, err := keysDecrypt(encKeyB64, privPEM)
if err != nil {
continue
}
privPEM = string(pt)
}
blockPriv, _ := pem.Decode([]byte(privPEM))
if blockPriv == nil {
continue
}
switch r.Alg {
case "RS256", "RS384", "RS512":
if k, err := x509.ParsePKCS1PrivateKey(blockPriv.Bytes); err == nil {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
} else if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
if k, ok := kAny.(*rsa.PrivateKey); ok {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
}
}
case "EdDSA":
if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
if k, ok := kAny.(ed25519.PrivateKey); ok {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
}
}
}
}
}
kc.mu.Lock()
defer kc.mu.Unlock()
kc.pub = pub
kc.meta = meta
kc.selKid = selKid
kc.selAlg = selAlg
kc.selPriv = selPriv
return nil
}
func keysDecrypt(encKey, enc string) ([]byte, error) {
return keysDecryptImpl(encKey, enc)
}
// indirection for same package
var keysDecryptImpl = func(encKey, enc string) ([]byte, error) {
return nil, errors.New("not wired")
}
// Wire up from keys package
func init() {
keysDecryptImpl = keysDecryptShim
}
func keysDecryptShim(encKey, enc string) ([]byte, error) {
return keys.Decrypt(encKey, enc)
}
package auth
import (
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/models"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ValidateJWT verifies RS256/RS384/RS512/EdDSA tokens using the in-memory key cache.
// It honors kid when present, and falls back to any active key.
func ValidateJWT(tokenStr string, db *gorm.DB) *models.User {
cfg, _ := config.Load()
parser := jwt.NewParser(
jwt.WithIssuer(cfg.JWTIssuer),
jwt.WithAudience(cfg.JWTAudience),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "EdDSA"}),
)
token, err := parser.Parse(tokenStr, func(t *jwt.Token) (any, error) {
// Resolve by kid first
kid, _ := t.Header["kid"].(string)
kc.mu.RLock()
defer kc.mu.RUnlock()
if kid != "" {
if k, ok := kc.pub[kid]; ok {
return k, nil
}
}
// Fallback: try first active key
for _, k := range kc.pub {
return k, nil
}
return nil, jwt.ErrTokenUnverifiable
})
if err != nil || !token.Valid {
return nil
}
claims, _ := token.Claims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
uid, err := uuid.Parse(sub)
if err != nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", uid).Error; err != nil {
return nil
}
return &u
}
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
// random opaque token (returned to client once)
func generateOpaqueToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
type RefreshPair struct {
Plain string
Record models.RefreshToken
}
// Issue a new refresh token (new family if familyID == nil)
func IssueRefreshToken(db *gorm.DB, userID uuid.UUID, ttl time.Duration, familyID *uuid.UUID) (RefreshPair, error) {
plain, err := generateOpaqueToken(32)
if err != nil {
return RefreshPair{}, err
}
hash, err := HashSecretArgon2id(plain)
if err != nil {
return RefreshPair{}, err
}
fid := uuid.New()
if familyID != nil {
fid = *familyID
}
rec := models.RefreshToken{
UserID: userID,
FamilyID: fid,
TokenHash: hash,
ExpiresAt: time.Now().Add(ttl),
}
if err := db.Create(&rec).Error; err != nil {
return RefreshPair{}, err
}
return RefreshPair{Plain: plain, Record: rec}, nil
}
// ValidateRefreshToken refresh token; returns record if valid & not revoked/expired
func ValidateRefreshToken(db *gorm.DB, plain string) (*models.RefreshToken, error) {
if plain == "" {
return nil, errors.New("empty")
}
// var rec models.RefreshToken
// We can't query by hash w/ Argon; scan candidates by expiry window. Keep small TTL (e.g. 30d).
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
Find(&[]models.RefreshToken{}).Error; err != nil {
return nil, err
}
// Better: add a prefix column to narrow scan; omitted for brevity.
// Pragmatic approach: single SELECT per token:
// Add a TokenHashSHA256 column for deterministic lookup if you want O(1). (Optional)
// Minimal: iterate limited set; for simplicity we fetch by created window:
var recs []models.RefreshToken
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
Order("created_at desc").Limit(500).Find(&recs).Error; err != nil {
return nil, err
}
for _, r := range recs {
ok, _ := VerifySecretArgon2id(r.TokenHash, plain)
if ok {
return &r, nil
}
}
return nil, errors.New("invalid")
}
// RevokeFamily revokes all tokens in a family (logout everywhere)
func RevokeFamily(db *gorm.DB, familyID uuid.UUID) error {
now := time.Now()
return db.Model(&models.RefreshToken{}).
Where("family_id = ? AND revoked_at IS NULL", familyID).
Update("revoked_at", &now).Error
}
// RotateRefreshToken replaces one token with a fresh one within the same family
func RotateRefreshToken(db *gorm.DB, used *models.RefreshToken, ttl time.Duration) (RefreshPair, error) {
// revoke the used token (one-time use)
now := time.Now()
if err := db.Model(&models.RefreshToken{}).
Where("id = ? AND revoked_at IS NULL", used.ID).
Update("revoked_at", &now).Error; err != nil {
return RefreshPair{}, err
}
return IssueRefreshToken(db, used.UserID, ttl, &used.FamilyID)
}
package auth
import (
"time"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
// ValidateAPIKey validates a single-token user API key sent via X-API-KEY.
func ValidateAPIKey(rawKey string, db *gorm.DB) *models.User {
if rawKey == "" {
return nil
}
digest := SHA256Hex(rawKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
First(&k).Error; err != nil {
return nil
}
if k.UserID == nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
return nil
}
// Optional: touch last_used_at here if you've added it on the model.
return &u
}
// ValidateAppKeyPair validates a user key/secret pair via X-APP-KEY / X-APP-SECRET.
func ValidateAppKeyPair(appKey, secret string, db *gorm.DB) *models.User {
if appKey == "" || secret == "" {
return nil
}
digest := SHA256Hex(appKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
First(&k).Error; err != nil {
return nil
}
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
if !ok || k.UserID == nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
return nil
}
return &u
}
// ValidateOrgKeyPair validates an org key/secret via X-ORG-KEY / X-ORG-SECRET.
func ValidateOrgKeyPair(orgKey, secret string, db *gorm.DB) *models.Organization {
if orgKey == "" || secret == "" {
return nil
}
digest := SHA256Hex(orgKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "org", time.Now()).
First(&k).Error; err != nil {
return nil
}
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
if !ok || k.OrgID == nil {
return nil
}
var o models.Organization
if err := db.First(&o, "id = ?", *k.OrgID).Error; err != nil {
return nil
}
return &o
}
// local helper; avoids nil-deref when comparing secrets
func zeroIfNil(s *string) string {
if s == nil {
return ""
}
return *s
}
package bg
import (
"context"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/google/uuid"
"gorm.io/gorm"
)
type CleanupArgs struct {
RetainDays int `json:"retain_days"`
Table string `json:"table"`
}
type JobRow struct {
ID string `gorm:"primaryKey"`
Status string
UpdatedAt time.Time
}
func (JobRow) TableName() string { return "jobs" }
func CleanupWorker(gdb *gorm.DB, jobs *Jobs, retainDays int) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
if err := CleanupJobs(gdb, retainDays); err != nil {
return nil, err
}
// schedule tomorrow 03:30
next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 30*time.Minute)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"archer_cleanup",
CleanupArgs{RetainDays: retainDays, Table: "jobs"},
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return nil, nil
}
}
func CleanupJobs(db *gorm.DB, retainDays int) error {
cutoff := time.Now().AddDate(0, 0, -retainDays)
return db.
Where("status IN ?", []string{"success", "failed", "cancelled"}).
Where("updated_at < ?", cutoff).
Delete(&JobRow{}).Error
}
package bg
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type DbBackupArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type s3Scope struct {
Service string `json:"service"`
Region string `json:"region"`
}
type encAWS struct {
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
}
func DbBackupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := DbBackupArgs{IntervalS: 3600}
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 3600
}
if err := DbBackup(ctx, db); err != nil {
return nil, err
}
queue := j.QueueName
if strings.TrimSpace(queue) == "" {
queue = "db_backup_s3"
}
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
payload := DbBackupArgs{}
opts := []archer.FnOptions{
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
}
if _, err := jobs.Enqueue(ctx, uuid.NewString(), queue, payload, opts...); err != nil {
log.Error().Err(err).Str("queue", queue).Time("next", next).Msg("failed to enqueue next db backup")
} else {
log.Info().Str("queue", queue).Time("next", next).Msg("scheduled next db backup")
}
return nil, nil
}
}
func DbBackup(ctx context.Context, db *gorm.DB) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cred, sc, err := loadS3Credential(ctx, db)
if err != nil {
return fmt.Errorf("load credential: %w", err)
}
ak, sk, err := decryptAwsAccessKeys(ctx, db, cred)
if err != nil {
return fmt.Errorf("decrypt aws keys: %w", err)
}
region := sc.Region
if strings.TrimSpace(region) == "" {
region = cred.Region
if strings.TrimSpace(region) == "" {
region = "us-west-1"
}
}
bucket := strings.ToLower(fmt.Sprintf("%s-autoglue-backups-%s", cred.OrganizationID, region))
s3cli, err := makeS3Client(ctx, ak, sk, region)
if err != nil {
return err
}
if err := ensureBucket(ctx, s3cli, bucket, region); err != nil {
return fmt.Errorf("ensure bucket: %w", err)
}
tmpDir := os.TempDir()
now := time.Now().UTC()
key := fmt.Sprintf("%04d/%02d/%02d/backup-%02d.sql", now.Year(), now.Month(), now.Day(), now.Hour())
outPath := filepath.Join(tmpDir, "autoglue-backup-"+now.Format("20060102T150405Z")+".sql")
if err := runPgDump(ctx, cfg.DbURL, outPath); err != nil {
return fmt.Errorf("pg_dump: %w", err)
}
defer os.Remove(outPath)
if err := uploadFileToS3(ctx, s3cli, bucket, key, outPath); err != nil {
return fmt.Errorf("s3 upload: %w", err)
}
log.Info().Str("bucket", bucket).Str("key", key).Msg("backup uploaded")
return nil
}
// --- Helpers
func loadS3Credential(ctx context.Context, db *gorm.DB) (models.Credential, s3Scope, error) {
var c models.Credential
err := db.
WithContext(ctx).
Where("provider = ? AND kind = ? AND scope_kind = ?", "aws", "aws_access_key", "service").
Where("scope ->> 'service' = ?", "s3").
Order("created_at DESC").
First(&c).Error
if err != nil {
return models.Credential{}, s3Scope{}, fmt.Errorf("load credential: %w", err)
}
var sc s3Scope
_ = json.Unmarshal(c.Scope, &sc)
return c, sc, nil
}
func decryptAwsAccessKeys(ctx context.Context, db *gorm.DB, c models.Credential) (string, string, error) {
plain, err := utils.DecryptForOrg(c.OrganizationID, c.EncryptedData, c.IV, c.Tag, db)
if err != nil {
return "", "", err
}
var payload encAWS
if err := json.Unmarshal([]byte(plain), &payload); err != nil {
return "", "", fmt.Errorf("parse decrypted payload: %w", err)
}
if payload.AccessKeyID == "" || payload.SecretAccessKey == "" {
return "", "", errors.New("decrypted payload missing keys")
}
return payload.AccessKeyID, payload.SecretAccessKey, nil
}
func makeS3Client(ctx context.Context, accessKey, secret, region string) (*s3.Client, error) {
staticCredentialsProvider := credentials.NewStaticCredentialsProvider(accessKey, secret, "")
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithCredentialsProvider(staticCredentialsProvider), awsconfig.WithRegion(region))
if err != nil {
return nil, fmt.Errorf("aws config: %w", err)
}
return s3.NewFromConfig(cfg), nil
}
func ensureBucket(ctx context.Context, s3cli *s3.Client, bucket, region string) error {
_, err := s3cli.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
if err == nil {
return nil
}
if out, err := s3cli.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: aws.String(bucket)}); err == nil {
existing := string(out.LocationConstraint)
if existing == "" {
existing = "us-east-1"
}
if existing != region {
return fmt.Errorf("bucket %q already exists in region %q (requested %q)", bucket, existing, region)
}
}
// Create; LocationConstraint except us-east-1
in := &s3.CreateBucketInput{Bucket: aws.String(bucket)}
if region != "us-east-1" {
in.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{
LocationConstraint: s3types.BucketLocationConstraint(region),
}
}
if _, err := s3cli.CreateBucket(ctx, in); err != nil {
return fmt.Errorf("create bucket: %w", err)
}
// default SSE (best-effort)
_, _ = s3cli.PutBucketEncryption(ctx, &s3.PutBucketEncryptionInput{
Bucket: aws.String(bucket),
ServerSideEncryptionConfiguration: &s3types.ServerSideEncryptionConfiguration{
Rules: []s3types.ServerSideEncryptionRule{
{ApplyServerSideEncryptionByDefault: &s3types.ServerSideEncryptionByDefault{
SSEAlgorithm: s3types.ServerSideEncryptionAes256,
}},
},
},
})
return nil
}
func runPgDump(ctx context.Context, dbURL, outPath string) error {
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return err
}
args := []string{
"--no-owner",
"--no-privileges",
"--format=plain",
"--file", outPath,
dbURL,
}
cmd := exec.CommandContext(ctx, "pg_dump", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("pg_dump failed: %v | %s", err, stderr.String())
}
return nil
}
func uploadFileToS3(ctx context.Context, s3cli *s3.Client, bucket, key, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
info, _ := f.Stat()
_, err = s3cli.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: f,
ContentLength: aws.Int64(info.Size()),
ContentType: aws.String(mime.TypeByExtension(filepath.Ext(path))),
ServerSideEncryption: s3types.ServerSideEncryptionAes256,
})
return err
}
package bg
import (
"context"
"net"
"net/url"
"strings"
"time"
"github.com/dyaksa/archer"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"gorm.io/gorm"
)
type Jobs struct{ Client *archer.Client }
func archerOptionsFromDSN(dsn string) (*archer.Options, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, err
}
var user, pass string
if u.User != nil {
user = u.User.Username()
pass, _ = u.User.Password()
}
host := u.Host
if !strings.Contains(host, ":") {
host = net.JoinHostPort(host, "5432")
}
return &archer.Options{
Addr: host,
User: user,
Password: pass,
DBName: strings.TrimPrefix(u.Path, "/"),
SSL: u.Query().Get("sslmode"), // forward sslmode
}, nil
}
func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
opts, err := archerOptionsFromDSN(dbUrl)
if err != nil {
return nil, err
}
instances := viper.GetInt("archer.instances")
if instances <= 0 {
instances = 1
}
timeoutSec := viper.GetInt("archer.timeoutSec")
if timeoutSec <= 0 {
timeoutSec = 60
}
retainDays := viper.GetInt("archer.cleanup_retain_days")
if retainDays <= 0 {
retainDays = 7
}
c := archer.NewClient(
opts,
archer.WithSetTableName("jobs"), // <- ensure correct table
archer.WithSleepInterval(1*time.Second), // fast poll while debugging
archer.WithErrHandler(func(err error) { // bubble up worker SQL errors
log.Error().Err(err).Msg("[archer] worker error")
}),
)
jobs := &Jobs{Client: c}
c.Register(
"bootstrap_bastion",
BastionBootstrapWorker(gdb, jobs),
archer.WithInstances(instances),
archer.WithTimeout(time.Duration(timeoutSec)*time.Second),
)
c.Register(
"archer_cleanup",
CleanupWorker(gdb, jobs, retainDays),
archer.WithInstances(1),
archer.WithTimeout(5*time.Minute),
)
c.Register(
"tokens_cleanup",
TokensCleanupWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(5*time.Minute),
)
c.Register(
"db_backup_s3",
DbBackupWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(15*time.Minute),
)
c.Register(
"dns_reconcile",
DNSReconsileWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
/*
c.Register(
"prepare_cluster",
ClusterPrepareWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
c.Register(
"cluster_setup",
ClusterSetupWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
c.Register(
"cluster_bootstrap",
ClusterBootstrapWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(60*time.Minute),
)
*/
c.Register(
"org_key_sweeper",
OrgKeySweeperWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(5*time.Minute),
)
c.Register(
"cluster_action",
ClusterActionWorker(gdb),
archer.WithInstances(1),
)
return jobs, nil
}
func (j *Jobs) Start() error { return j.Client.Start() }
func (j *Jobs) Stop() { j.Client.Stop() }
func (j *Jobs) Enqueue(ctx context.Context, id, queue string, args any, opts ...archer.FnOptions) (any, error) {
return j.Client.Schedule(ctx, id, queue, args, opts...)
}
package bg
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/mapper"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type ClusterActionArgs struct {
OrgID uuid.UUID `json:"org_id"`
ClusterID uuid.UUID `json:"cluster_id"`
Action string `json:"action"`
MakeTarget string `json:"make_target"`
}
type ClusterActionResult struct {
Status string `json:"status"`
Action string `json:"action"`
ClusterID string `json:"cluster_id"`
ElapsedMs int `json:"elapsed_ms"`
}
func ClusterActionWorker(db *gorm.DB) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
start := time.Now()
var args ClusterActionArgs
_ = j.ParseArguments(&args)
runID, _ := uuid.Parse(j.ID)
updateRun := func(status string, errMsg string) {
updates := map[string]any{
"status": status,
"error": errMsg,
}
if status == "succeeded" || status == "failed" {
updates["finished_at"] = time.Now().UTC().Format(time.RFC3339)
}
db.Model(&models.ClusterRun{}).Where("id = ?", runID).Updates(updates)
}
updateRun("running", "")
logger := log.With().
Str("job", j.ID).
Str("cluster_id", args.ClusterID.String()).
Str("action", args.Action).
Logger()
var c models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers.SshKey").
Where("id = ? AND organization_id = ?", args.ClusterID, args.OrgID).
First(&c).Error; err != nil {
updateRun("failed", fmt.Errorf("load cluster: %w", err).Error())
return nil, fmt.Errorf("load cluster: %w", err)
}
// ---- Step 1: Prepare (mostly lifted from ClusterPrepareWorker)
if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
updateRun("failed", err.Error())
return nil, fmt.Errorf("mark bootstrapping: %w", err)
}
c.Status = clusterStatusBootstrapping
if err := validateClusterForPrepare(&c); err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
updateRun("failed", err.Error())
return nil, fmt.Errorf("validate: %w", err)
}
allServers := flattenClusterServers(&c)
keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, &c, allServers)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
updateRun("failed", err.Error())
return nil, fmt.Errorf("build ssh assets: %w", err)
}
dtoCluster := mapper.ClusterToDTO(c)
if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(
c.OrganizationID,
c.EncryptedKubeconfig,
c.KubeIV,
c.KubeTag,
db,
)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("decrypt kubeconfig: %w", err)
}
dtoCluster.Kubeconfig = &kubeconfig
}
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(db, c.OrganizationID, c.ID, 24*time.Hour)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
updateRun("failed", err.Error())
return nil, fmt.Errorf("org key: %w", err)
}
dtoCluster.OrgKey = &orgKey
dtoCluster.OrgSecret = &orgSecret
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
updateRun("failed", err.Error())
return nil, fmt.Errorf("marshal payload: %w", err)
}
{
runCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
err := pushAssetsToBastion(runCtx, db, &c, sshConfig, keyPayloads, payloadJSON)
cancel()
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
updateRun("failed", err.Error())
return nil, fmt.Errorf("push assets: %w", err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
updateRun("failed", err.Error())
return nil, fmt.Errorf("mark pending: %w", err)
}
c.Status = clusterStatusPending
// ---- Step 2: Setup (ping-servers)
{
runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
out, err := runMakeOnBastion(runCtx, db, &c, "ping-servers")
cancel()
if err != nil {
logger.Error().Err(err).Str("output", out).Msg("ping-servers failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
updateRun("failed", err.Error())
return nil, fmt.Errorf("ping-servers: %w", err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
updateRun("failed", err.Error())
return nil, fmt.Errorf("mark provisioning: %w", err)
}
c.Status = clusterStatusProvisioning
// ---- Step 3: Bootstrap (parameterized target)
{
runCtx, cancel := context.WithTimeout(ctx, 60*time.Minute)
out, err := runMakeOnBastion(runCtx, db, &c, args.MakeTarget)
cancel()
if err != nil {
logger.Error().Err(err).Str("output", out).Msg("bootstrap target failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make %s: %v", args.MakeTarget, err))
updateRun("failed", err.Error())
return nil, fmt.Errorf("make %s: %w", args.MakeTarget, err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
updateRun("failed", err.Error())
return nil, fmt.Errorf("mark ready: %w", err)
}
updateRun("succeeded", "")
return ClusterActionResult{
Status: "ok",
Action: args.Action,
ClusterID: c.ID.String(),
ElapsedMs: int(time.Since(start).Milliseconds()),
}, nil
}
}
package bg
import (
"context"
"fmt"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type ClusterBootstrapArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type ClusterBootstrapResult struct {
Status string `json:"status"`
Processed int `json:"processed"`
Ready int `json:"ready"`
Failed int `json:"failed"`
ElapsedMs int `json:"elapsed_ms"`
FailedIDs []uuid.UUID `json:"failed_cluster_ids"`
}
func ClusterBootstrapWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := ClusterBootstrapArgs{IntervalS: 120}
jobID := j.ID
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 120
}
var clusters []models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Where("status = ?", clusterStatusProvisioning).
Find(&clusters).Error; err != nil {
log.Error().Err(err).Msg("[cluster_bootstrap] query clusters failed")
return nil, err
}
proc, ready, failCount := 0, 0, 0
var failedIDs []uuid.UUID
perClusterTimeout := 60 * time.Minute
for i := range clusters {
c := &clusters[i]
proc++
if c.BastionServer.ID == uuid.Nil || c.BastionServer.Status != "ready" {
continue
}
logger := log.With().
Str("job", jobID).
Str("cluster_id", c.ID.String()).
Str("cluster_name", c.Name).
Logger()
logger.Info().Msg("[cluster_bootstrap] running make bootstrap")
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
out, err := runMakeOnBastion(runCtx, db, c, "setup")
cancel()
if err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
logger.Error().Err(err).Str("output", out).Msg("[cluster_bootstrap] make setup failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make setup: %v", err))
continue
}
// you can choose a different terminal status here if you like
if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
logger.Error().Err(err).Msg("[cluster_bootstrap] failed to mark cluster ready")
continue
}
ready++
logger.Info().Msg("[cluster_bootstrap] cluster marked ready")
}
res := ClusterBootstrapResult{
Status: "ok",
Processed: proc,
Ready: ready,
Failed: failCount,
ElapsedMs: int(time.Since(start).Milliseconds()),
FailedIDs: failedIDs,
}
log.Info().
Int("processed", proc).
Int("ready", ready).
Int("failed", failCount).
Msg("[cluster_bootstrap] reconcile tick ok")
// self-reschedule
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"cluster_bootstrap",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return res, nil
}
}
package bg
import (
"context"
"fmt"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type ClusterSetupArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type ClusterSetupResult struct {
Status string `json:"status"`
Processed int `json:"processed"`
Provisioning int `json:"provisioning"`
Failed int `json:"failed"`
ElapsedMs int `json:"elapsed_ms"`
FailedCluster []uuid.UUID `json:"failed_cluster_ids"`
}
func ClusterSetupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := ClusterSetupArgs{IntervalS: 120}
jobID := j.ID
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 120
}
var clusters []models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Where("status = ?", clusterStatusPending).
Find(&clusters).Error; err != nil {
log.Error().Err(err).Msg("[cluster_setup] query clusters failed")
return nil, err
}
proc, prov, failCount := 0, 0, 0
var failedIDs []uuid.UUID
perClusterTimeout := 30 * time.Minute
for i := range clusters {
c := &clusters[i]
proc++
if c.BastionServer.ID == uuid.Nil || c.BastionServer.Status != "ready" {
continue
}
logger := log.With().
Str("job", jobID).
Str("cluster_id", c.ID.String()).
Str("cluster_name", c.Name).
Logger()
logger.Info().Msg("[cluster_setup] running make setup")
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
out, err := runMakeOnBastion(runCtx, db, c, "ping-servers")
cancel()
if err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
logger.Error().Err(err).Str("output", out).Msg("[cluster_setup] make ping-servers failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
continue
}
if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
failCount++
failedIDs = append(failedIDs, c.ID)
logger.Error().Err(err).Msg("[cluster_setup] failed to mark cluster provisioning")
continue
}
prov++
logger.Info().Msg("[cluster_setup] cluster moved to provisioning")
}
res := ClusterSetupResult{
Status: "ok",
Processed: proc,
Provisioning: prov,
Failed: failCount,
ElapsedMs: int(time.Since(start).Milliseconds()),
FailedCluster: failedIDs,
}
log.Info().
Int("processed", proc).
Int("provisioning", prov).
Int("failed", failCount).
Msg("[cluster_setup] reconcile tick ok")
// self-reschedule
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"cluster_setup",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return res, nil
}
}
package bg
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
r53 "github.com/aws/aws-sdk-go-v2/service/route53"
r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
"github.com/aws/smithy-go"
smithyhttp "github.com/aws/smithy-go/transport/http"
)
/************* args & small DTOs *************/
type DNSReconcileArgs struct {
MaxDomains int `json:"max_domains,omitempty"`
MaxRecords int `json:"max_records,omitempty"`
IntervalS int `json:"interval_seconds,omitempty"`
}
// TXT marker content (compact)
type ownershipMarker struct {
Ver string `json:"v"` // "ag1"
Org string `json:"org"` // org UUID
Rec string `json:"rec"` // record UUID
Fp string `json:"fp"` // short fp (first 16 of sha256)
}
// ExternalDNS poison owner id β MUST NOT match any real external-dns --txt-owner-id
const externalDNSPoisonOwner = "autoglue-lock"
// ExternalDNS poison content β fake owner so real external-dns skips it.
const externalDNSPoisonValue = "heritage=external-dns,external-dns/owner=" + externalDNSPoisonOwner + ",external-dns/resource=manual/autoglue"
// Default TTL for non-alias records (alias not supported in this reconciler)
const defaultRecordTTLSeconds int64 = 300
/************* entrypoint worker *************/
func DNSReconsileWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := DNSReconcileArgs{MaxDomains: 25, MaxRecords: 100, IntervalS: 30}
_ = j.ParseArguments(&args)
if args.MaxDomains <= 0 {
args.MaxDomains = 25
}
if args.MaxRecords <= 0 {
args.MaxRecords = 100
}
if args.IntervalS <= 0 {
args.IntervalS = 30
}
processedDomains, processedRecords, err := reconcileDNSOnce(ctx, db, args)
if err != nil {
log.Error().Err(err).Msg("[dns] reconcile tick failed")
} else {
log.Debug().
Int("domains", processedDomains).
Int("records", processedRecords).
Msg("[dns] reconcile tick ok")
}
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(ctx, uuid.NewString(), "dns_reconcile", args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return map[string]any{
"domains_processed": processedDomains,
"records_processed": processedRecords,
}, nil
}
}
/************* core tick *************/
func reconcileDNSOnce(ctx context.Context, db *gorm.DB, args DNSReconcileArgs) (int, int, error) {
var domains []models.Domain
// 1) validate/backfill pending domains
if err := db.
Where("status = ?", "pending").
Order("created_at ASC").
Limit(args.MaxDomains).
Find(&domains).Error; err != nil {
return 0, 0, err
}
domainsProcessed := 0
for i := range domains {
if err := processDomain(ctx, db, &domains[i]); err != nil {
log.Error().Err(err).Str("domain", domains[i].DomainName).Msg("[dns] domain processing failed")
} else {
domainsProcessed++
}
}
// 2) apply pending record sets for ready domains
var readyDomains []models.Domain
if err := db.Where("status = ?", "ready").Find(&readyDomains).Error; err != nil {
return domainsProcessed, 0, err
}
recordsProcessed := 0
for i := range readyDomains {
n, err := processPendingRecordsForDomain(ctx, db, &readyDomains[i], args.MaxRecords)
if err != nil {
log.Error().Err(err).Str("domain", readyDomains[i].DomainName).Msg("[dns] record processing failed")
continue
}
recordsProcessed += n
}
return domainsProcessed, recordsProcessed, nil
}
/************* domain processing *************/
func processDomain(ctx context.Context, db *gorm.DB, d *models.Domain) error {
orgID := d.OrganizationID
// 1) Load credential (org-guarded)
var cred models.Credential
if err := db.Where("id = ? AND organization_id = ?", d.CredentialID, orgID).First(&cred).Error; err != nil {
return setDomainFailed(db, d, fmt.Errorf("credential not found: %w", err))
}
// 2) Decrypt β dto.AWSCredential
secret, err := utils.DecryptForOrg(orgID, cred.EncryptedData, cred.IV, cred.Tag, db)
if err != nil {
return setDomainFailed(db, d, fmt.Errorf("decrypt: %w", err))
}
var awsCred dto.AWSCredential
if err := jsonUnmarshalStrict([]byte(secret), &awsCred); err != nil {
return setDomainFailed(db, d, fmt.Errorf("secret decode: %w", err))
}
// 3) Client
r53c, _, err := newRoute53Client(ctx, awsCred)
if err != nil {
return setDomainFailed(db, d, err)
}
// 4) Backfill zone id if missing
zoneID := strings.TrimSpace(d.ZoneID)
if zoneID == "" {
zid, err := findHostedZoneID(ctx, r53c, d.DomainName)
if err != nil {
return setDomainFailed(db, d, fmt.Errorf("discover zone id: %w", err))
}
zoneID = zid
d.ZoneID = zoneID
}
// 5) Sanity: can fetch zone
if _, err := r53c.GetHostedZone(ctx, &r53.GetHostedZoneInput{Id: aws.String(zoneID)}); err != nil {
return setDomainFailed(db, d, fmt.Errorf("get hosted zone: %w", err))
}
// 6) Mark ready
d.Status = "ready"
d.LastError = ""
if err := db.Save(d).Error; err != nil {
return err
}
return nil
}
func setDomainFailed(db *gorm.DB, d *models.Domain, cause error) error {
d.Status = "failed"
d.LastError = truncateErr(cause.Error())
_ = db.Save(d).Error
return cause
}
/************* record processing *************/
func processPendingRecordsForDomain(ctx context.Context, db *gorm.DB, d *models.Domain, max int) (int, error) {
orgID := d.OrganizationID
// reload credential
var cred models.Credential
if err := db.Where("id = ? AND organization_id = ?", d.CredentialID, orgID).First(&cred).Error; err != nil {
return 0, err
}
secret, err := utils.DecryptForOrg(orgID, cred.EncryptedData, cred.IV, cred.Tag, db)
if err != nil {
return 0, err
}
var awsCred dto.AWSCredential
if err := jsonUnmarshalStrict([]byte(secret), &awsCred); err != nil {
return 0, err
}
r53c, _, err := newRoute53Client(ctx, awsCred)
if err != nil {
return 0, err
}
var records []models.RecordSet
if err := db.
Where("domain_id = ? AND status = ?", d.ID, "pending").
Order("created_at ASC").
Limit(max).
Find(&records).Error; err != nil {
return 0, err
}
applied := 0
for i := range records {
if err := applyRecord(ctx, db, r53c, d, &records[i]); err != nil {
log.Error().
Err(err).
Str("zone_id", d.ZoneID).
Str("domain", d.DomainName).
Str("record_id", records[i].ID.String()).
Str("name", records[i].Name).
Str("type", strings.ToUpper(records[i].Type)).
Msg("[dns] apply record failed")
_ = setRecordFailed(db, &records[i], err)
continue
}
applied++
}
return applied, nil
}
// core write + ownership + external-dns hardening
func applyRecord(ctx context.Context, db *gorm.DB, r53c *r53.Client, d *models.Domain, r *models.RecordSet) error {
zoneID := strings.TrimSpace(d.ZoneID)
if zoneID == "" {
return errors.New("domain has no zone_id")
}
rt := strings.ToUpper(r.Type)
// FQDN & marker
fq := recordFQDN(r.Name, d.DomainName) // ends with "."
mname := markerName(fq)
expected := buildMarkerValue(d.OrganizationID.String(), r.ID.String(), r.Fingerprint)
logCtx := log.With().
Str("zone_id", zoneID).
Str("domain", d.DomainName).
Str("fqdn", fq).
Str("rr_type", rt).
Str("record_id", r.ID.String()).
Str("org_id", d.OrganizationID.String()).
Logger()
start := time.Now()
// ---- ExternalDNS preflight ----
extOwned, err := hasExternalDNSOwnership(ctx, r53c, zoneID, fq, rt)
if err != nil {
return fmt.Errorf("external_dns_lookup: %w", err)
}
if extOwned {
logCtx.Warn().Msg("[dns] ownership conflict: external-dns claims this record")
r.Owner = "external"
_ = db.Save(r).Error
return fmt.Errorf("ownership_conflict: external-dns claims %s; refusing to modify", strings.TrimSuffix(fq, "."))
}
// ---- Autoglue ownership preflight via _autoglue. TXT ----
markerVals, err := getMarkerTXTValues(ctx, r53c, zoneID, mname)
if err != nil {
return fmt.Errorf("marker lookup: %w", err)
}
hasForeignOwner := false
hasOurExact := false
for _, v := range markerVals {
mk, ok := parseMarkerValue(v)
if !ok {
continue
}
switch {
case mk.Org == d.OrganizationID.String() && mk.Rec == r.ID.String() && mk.Fp == shortFP(r.Fingerprint):
hasOurExact = true
case mk.Org != d.OrganizationID.String() || mk.Rec != r.ID.String():
hasForeignOwner = true
}
}
logCtx.Debug().
Bool("externaldns_owned", extOwned).
Int("marker_txt_count", len(markerVals)).
Bool("marker_has_our_exact", hasOurExact).
Bool("marker_has_foreign", hasForeignOwner).
Msg("[dns] ownership preflight")
if hasForeignOwner {
logCtx.Warn().Msg("[dns] ownership conflict: foreign _autoglue marker")
r.Owner = "external"
_ = db.Save(r).Error
return fmt.Errorf("ownership_conflict: marker for %s is owned by another controller; refusing to modify", strings.TrimSuffix(fq, "."))
}
// Decode user values
var userVals []string
rawVals := strings.TrimSpace(string(r.Values))
if rawVals != "" && rawVals != "null" {
if err := jsonUnmarshalStrict([]byte(rawVals), &userVals); err != nil {
return fmt.Errorf("values decode: %w", err)
}
}
// Quote TXT values as required by Route53
recs := make([]r53types.ResourceRecord, 0, len(userVals))
for _, v := range userVals {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if rt == "TXT" && !(strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`)) {
v = strconv.Quote(v)
}
recs = append(recs, r53types.ResourceRecord{Value: aws.String(v)})
}
// Alias is NOT supported - enforce at least one value for all record types we manage
if len(recs) == 0 {
logCtx.Warn().
Str("raw_values", truncateForLog(string(r.Values), 240)).
Int("decoded_value_count", len(userVals)).
Msg("[dns] invalid record: no values (alias not supported)")
return fmt.Errorf("invalid_record: %s %s requires at least one value (alias not supported)", strings.TrimSuffix(fq, "."), rt)
}
ttl := defaultRecordTTLSeconds
if r.TTL != nil && *r.TTL > 0 {
ttl = int64(*r.TTL)
}
// Build RR change (UPSERT)
rrChange := r53types.Change{
Action: r53types.ChangeActionUpsert,
ResourceRecordSet: &r53types.ResourceRecordSet{
Name: aws.String(fq),
Type: r53types.RRType(rt),
TTL: aws.Int64(ttl),
ResourceRecords: recs,
},
}
// Build marker TXT change (UPSERT)
markerChange := r53types.Change{
Action: r53types.ChangeActionUpsert,
ResourceRecordSet: &r53types.ResourceRecordSet{
Name: aws.String(mname),
Type: r53types.RRTypeTxt,
TTL: aws.Int64(defaultRecordTTLSeconds),
ResourceRecords: []r53types.ResourceRecord{
{Value: aws.String(strconv.Quote(expected))},
},
},
}
// Build external-dns poison TXT changes
poisonChanges := buildExternalDNSPoisonTXTChanges(fq, rt)
// Apply all in one batch (atomic-ish)
changes := []r53types.Change{rrChange, markerChange}
changes = append(changes, poisonChanges...)
// Log what we are about to send
logCtx.Debug().
Interface("route53_change_batch", toLogChangeBatch(zoneID, changes)).
Msg("[dns] route53 request preview")
_, err = r53c.ChangeResourceRecordSets(ctx, &r53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String(zoneID),
ChangeBatch: &r53types.ChangeBatch{Changes: changes},
})
if err != nil {
logAWSError(logCtx, err)
logCtx.Info().Dur("elapsed", time.Since(start)).Msg("[dns] apply failed")
return err
}
logCtx.Info().
Dur("elapsed", time.Since(start)).
Int("change_count", len(changes)).
Msg("[dns] apply ok")
// Success β mark ready & ownership
r.Status = "ready"
r.LastError = ""
r.Owner = "autoglue"
if err := db.Save(r).Error; err != nil {
return err
}
_ = hasOurExact // could be used to skip marker write in future
return nil
}
func setRecordFailed(db *gorm.DB, r *models.RecordSet, cause error) error {
msg := truncateErr(cause.Error())
r.Status = "failed"
r.LastError = msg
// classify ownership on conflict
if strings.HasPrefix(msg, "ownership_conflict") {
r.Owner = "external"
} else if r.Owner == "" || r.Owner == "unknown" {
r.Owner = "unknown"
}
_ = db.Save(r).Error
return cause
}
/************* AWS helpers *************/
func newRoute53Client(ctx context.Context, cred dto.AWSCredential) (*r53.Client, *aws.Config, error) {
// Route53 is global, but config still wants a region
region := strings.TrimSpace(cred.Region)
if region == "" {
region = "us-east-1"
}
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cred.AccessKeyID, cred.SecretAccessKey, "",
)),
)
if err != nil {
return nil, nil, err
}
return r53.NewFromConfig(cfg), &cfg, nil
}
func findHostedZoneID(ctx context.Context, c *r53.Client, domain string) (string, error) {
d := normalizeDomain(domain)
out, err := c.ListHostedZonesByName(ctx, &r53.ListHostedZonesByNameInput{
DNSName: aws.String(d),
})
if err != nil {
return "", err
}
for _, hz := range out.HostedZones {
if strings.TrimSuffix(aws.ToString(hz.Name), ".") == d {
return trimZoneID(aws.ToString(hz.Id)), nil
}
}
return "", fmt.Errorf("hosted zone not found for %q", d)
}
func trimZoneID(id string) string {
return strings.TrimPrefix(id, "/hostedzone/")
}
func normalizeDomain(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
return strings.TrimSuffix(s, ".")
}
func recordFQDN(name, domain string) string {
name = strings.TrimSpace(name)
if name == "" || name == "@" {
return normalizeDomain(domain) + "."
}
if strings.HasSuffix(name, ".") {
return name
}
return fmt.Sprintf("%s.%s.", name, normalizeDomain(domain))
}
/************* TXT marker / external-dns helpers *************/
func markerName(fqdn string) string {
trimmed := strings.TrimSuffix(fqdn, ".")
return "_autoglue." + trimmed + "."
}
func shortFP(full string) string {
if len(full) > 16 {
return full[:16]
}
return full
}
func buildMarkerValue(orgID, recID, fp string) string {
return "v=ag1 org=" + orgID + " rec=" + recID + " fp=" + shortFP(fp)
}
func parseMarkerValue(s string) (ownershipMarker, bool) {
out := ownershipMarker{}
fields := strings.Fields(s)
if len(fields) < 4 {
return out, false
}
kv := map[string]string{}
for _, f := range fields {
parts := strings.SplitN(f, "=", 2)
if len(parts) == 2 {
kv[parts[0]] = parts[1]
}
}
if kv["v"] == "" || kv["org"] == "" || kv["rec"] == "" || kv["fp"] == "" {
return out, false
}
out.Ver, out.Org, out.Rec, out.Fp = kv["v"], kv["org"], kv["rec"], kv["fp"]
return out, true
}
func getMarkerTXTValues(ctx context.Context, c *r53.Client, zoneID, marker string) ([]string, error) {
return getTXTValues(ctx, c, zoneID, marker)
}
// generic TXT fetcher
func getTXTValues(ctx context.Context, c *r53.Client, zoneID, name string) ([]string, error) {
out, err := c.ListResourceRecordSets(ctx, &r53.ListResourceRecordSetsInput{
HostedZoneId: aws.String(zoneID),
StartRecordName: aws.String(name),
StartRecordType: r53types.RRTypeTxt,
MaxItems: aws.Int32(1),
})
if err != nil {
return nil, err
}
if len(out.ResourceRecordSets) == 0 {
return nil, nil
}
rrset := out.ResourceRecordSets[0]
if aws.ToString(rrset.Name) != name || rrset.Type != r53types.RRTypeTxt {
return nil, nil
}
vals := make([]string, 0, len(rrset.ResourceRecords))
for _, rr := range rrset.ResourceRecords {
vals = append(vals, aws.ToString(rr.Value))
}
return vals, nil
}
// detect external-dns-style ownership for this fqdn/type
func hasExternalDNSOwnership(ctx context.Context, c *r53.Client, zoneID, fqdn, rrType string) (bool, error) {
base := strings.TrimSuffix(fqdn, ".")
candidates := []string{
// with txtPrefix=extdns-, external-dns writes both:
// extdns- and extdns--
"extdns-" + base + ".",
"extdns-" + strings.ToLower(rrType) + "-" + base + ".",
}
for _, name := range candidates {
vals, err := getTXTValues(ctx, c, zoneID, name)
if err != nil {
return false, err
}
for _, raw := range vals {
v := strings.TrimSpace(raw)
// strip surrounding quotes if present
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
if unq, err := strconv.Unquote(v); err == nil {
v = unq
}
}
meta := parseExternalDNSMeta(v)
if meta == nil {
continue
}
if meta["heritage"] == "external-dns" &&
meta["external-dns/owner"] != "" &&
meta["external-dns/owner"] != externalDNSPoisonOwner {
return true, nil
}
}
}
return false, nil
}
// parseExternalDNSMeta parses the comma-separated external-dns TXT format into a small map.
func parseExternalDNSMeta(v string) map[string]string {
parts := strings.Split(v, ",")
if len(parts) == 0 {
return nil
}
meta := make(map[string]string, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 {
continue
}
meta[kv[0]] = kv[1]
}
if len(meta) == 0 {
return nil
}
return meta
}
// build poison TXT records so external-dns thinks some *other* owner manages this
func buildExternalDNSPoisonTXTChanges(fqdn, rrType string) []r53types.Change {
base := strings.TrimSuffix(fqdn, ".")
names := []string{
"extdns-" + base + ".",
"extdns-" + strings.ToLower(rrType) + "-" + base + ".",
}
val := strconv.Quote(externalDNSPoisonValue)
changes := make([]r53types.Change, 0, len(names))
for _, n := range names {
changes = append(changes, r53types.Change{
Action: r53types.ChangeActionUpsert,
ResourceRecordSet: &r53types.ResourceRecordSet{
Name: aws.String(n),
Type: r53types.RRTypeTxt,
TTL: aws.Int64(defaultRecordTTLSeconds),
ResourceRecords: []r53types.ResourceRecord{
{Value: aws.String(val)},
},
},
})
}
return changes
}
/************* misc utils *************/
func truncateErr(s string) string {
const max = 2000
if len(s) > max {
return s[:max]
}
return s
}
// Strict unmarshal that treats "null" -> zero value correctly.
func jsonUnmarshalStrict(b []byte, dst any) error {
if len(b) == 0 {
return errors.New("empty json")
}
return json.Unmarshal(b, dst)
}
/************* logging DTOs & helpers *************/
type logRR struct {
Value string `json:"value"`
}
type logRRSet struct {
Action string `json:"action"`
Name string `json:"name"`
Type string `json:"type"`
TTL *int64 `json:"ttl,omitempty"`
Records []logRR `json:"records,omitempty"`
RecordCount int `json:"record_count"`
HasAliasTarget bool `json:"has_alias_target"`
SetIdentifier *string `json:"set_identifier,omitempty"`
}
type logChangeBatch struct {
HostedZoneID string `json:"hosted_zone_id"`
ChangeCount int `json:"change_count"`
Changes []logRRSet `json:"changes"`
}
func truncateForLog(s string, max int) string {
s = strings.TrimSpace(s)
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + "β¦"
}
func toLogChangeBatch(zoneID string, changes []r53types.Change) logChangeBatch {
out := logChangeBatch{
HostedZoneID: zoneID,
ChangeCount: len(changes),
Changes: make([]logRRSet, 0, len(changes)),
}
for _, ch := range changes {
if ch.ResourceRecordSet == nil {
continue
}
rrs := ch.ResourceRecordSet
lc := logRRSet{
Action: string(ch.Action),
Name: aws.ToString(rrs.Name),
Type: string(rrs.Type),
TTL: rrs.TTL,
HasAliasTarget: rrs.AliasTarget != nil,
SetIdentifier: rrs.SetIdentifier,
RecordCount: len(rrs.ResourceRecords),
Records: make([]logRR, 0, min(len(rrs.ResourceRecords), 5)),
}
// Log up to first 5 values (truncate each) to avoid log bloat / secrets
for i, rr := range rrs.ResourceRecords {
if i >= 5 {
break
}
lc.Records = append(lc.Records, logRR{Value: truncateForLog(aws.ToString(rr.Value), 160)})
}
out.Changes = append(out.Changes, lc)
}
return out
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// logAWSError extracts useful smithy/HTTP metadata (status code + request id + api code) into logs.
// logAWSError extracts useful smithy/HTTP metadata (status code + request id + api code) into logs.
func logAWSError(l zerolog.Logger, err error) {
// Add operation context if present
var opErr *smithy.OperationError
if errors.As(err, &opErr) {
l = l.With().
Str("aws_service", opErr.ServiceID).
Str("aws_operation", opErr.OperationName).
Logger()
err = opErr.Unwrap()
}
// HTTP status + request id (smithy-go transport/http)
var re *smithyhttp.ResponseError
if errors.As(err, &re) {
status := re.HTTPStatusCode()
reqID := ""
if resp := re.HTTPResponse(); resp != nil && resp.Header != nil {
reqID = resp.Header.Get("x-amzn-RequestId")
if reqID == "" {
reqID = resp.Header.Get("x-amz-request-id")
}
}
ev := l.Error().Int("http_status", status).Err(err)
if reqID != "" {
ev = ev.Str("aws_request_id", reqID)
}
ev.Msg("[dns] aws route53 call failed")
return
}
// API error code/message (best-effort)
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
l.Error().
Str("aws_error_code", apiErr.ErrorCode()).
Str("aws_error_message", apiErr.ErrorMessage()).
Err(err).
Msg("[dns] aws route53 api error")
return
}
l.Error().Err(err).Msg("[dns] aws route53 error")
}
package bg
import (
"context"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type OrgKeySweeperArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
RetentionDays int `json:"retention_days,omitempty"`
}
type OrgKeySweeperResult struct {
Status string `json:"status"`
MarkedRevoked int `json:"marked_revoked"`
DeletedEphemeral int `json:"deleted_ephemeral"`
ElapsedMs int `json:"elapsed_ms"`
}
func OrgKeySweeperWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := OrgKeySweeperArgs{
IntervalS: 3600,
RetentionDays: 10,
}
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 3600
}
if args.RetentionDays <= 0 {
args.RetentionDays = 10
}
now := time.Now()
// 1) Mark expired keys as revoked
res1 := db.Model(&models.APIKey{}).
Where("expires_at IS NOT NULL AND expires_at <= ? AND revoked = false", now).
Updates(map[string]any{
"revoked": true,
"updated_at": now,
})
if res1.Error != nil {
log.Error().Err(res1.Error).Msg("[org_key_sweeper] mark expired revoked failed")
return nil, res1.Error
}
markedRevoked := int(res1.RowsAffected)
// 2) Hard-delete ephemeral keys that are revoked and older than retention
cutoff := now.Add(-time.Duration(args.RetentionDays) * 24 * time.Hour)
res2 := db.
Where("is_ephemeral = ? AND revoked = ? AND updated_at <= ?", true, true, cutoff).
Delete(&models.APIKey{})
if res2.Error != nil {
log.Error().Err(res2.Error).Msg("[org_key_sweeper] delete revoked ephemeral keys failed")
return nil, res2.Error
}
deletedEphemeral := int(res2.RowsAffected)
out := OrgKeySweeperResult{
Status: "ok",
MarkedRevoked: markedRevoked,
DeletedEphemeral: deletedEphemeral,
ElapsedMs: int(time.Since(start).Milliseconds()),
}
log.Info().
Int("marked_revoked", markedRevoked).
Int("deleted_ephemeral", deletedEphemeral).
Msg("[org_key_sweeper] cleanup tick ok")
// Re-enqueue the sweeper
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"org_key_sweeper",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return out, nil
}
}
package bg
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"strings"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/mapper"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
type ClusterPrepareArgs struct {
IntervalS int `json:"interval_seconds,omitempty"`
}
type ClusterPrepareFailure struct {
ClusterID uuid.UUID `json:"cluster_id"`
Step string `json:"step"`
Reason string `json:"reason"`
}
type ClusterPrepareResult struct {
Status string `json:"status"`
Processed int `json:"processed"`
MarkedPending int `json:"marked_pending"`
Failed int `json:"failed"`
ElapsedMs int `json:"elapsed_ms"`
FailedIDs []uuid.UUID `json:"failed_cluster_ids"`
Failures []ClusterPrepareFailure `json:"failures"`
}
// Alias the status constants from models to avoid string drift.
const (
clusterStatusPrePending = models.ClusterStatusPrePending
clusterStatusPending = models.ClusterStatusPending
clusterStatusProvisioning = models.ClusterStatusProvisioning
clusterStatusReady = models.ClusterStatusReady
clusterStatusFailed = models.ClusterStatusFailed
clusterStatusBootstrapping = models.ClusterStatusBootstrapping
)
func ClusterPrepareWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
args := ClusterPrepareArgs{IntervalS: 120}
jobID := j.ID
start := time.Now()
_ = j.ParseArguments(&args)
if args.IntervalS <= 0 {
args.IntervalS = 120
}
// Load all clusters that are pre_pending; weβll filter for bastion.ready in memory.
var clusters []models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers.SshKey").
Where("status = ?", clusterStatusPrePending).
Find(&clusters).Error; err != nil {
log.Error().Err(err).Msg("[cluster_prepare] query clusters failed")
return nil, err
}
proc, ok, fail := 0, 0, 0
var failedIDs []uuid.UUID
var failures []ClusterPrepareFailure
perClusterTimeout := 8 * time.Minute
for i := range clusters {
c := &clusters[i]
proc++
// bastion must exist and be ready
if c.BastionServer == nil || c.BastionServerID == nil || *c.BastionServerID == uuid.Nil || c.BastionServer.Status != "ready" {
continue
}
if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
log.Error().Err(err).Msg("[cluster_prepare] failed to mark cluster bootstrapping")
continue
}
c.Status = clusterStatusBootstrapping
clusterLog := log.With().
Str("job", jobID).
Str("cluster_id", c.ID.String()).
Str("cluster_name", c.Name).
Logger()
clusterLog.Info().Msg("[cluster_prepare] starting")
if err := validateClusterForPrepare(c); err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "validate",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] validation failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
allServers := flattenClusterServers(c)
keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, c, allServers)
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "build_ssh_assets",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] build ssh assets failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
dtoCluster := mapper.ClusterToDTO(*c)
if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(
c.OrganizationID,
c.EncryptedKubeconfig,
c.KubeIV,
c.KubeTag,
db,
)
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "decrypt_kubeconfig",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] decrypt kubeconfig failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
dtoCluster.Kubeconfig = &kubeconfig
}
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(
db,
c.OrganizationID,
c.ID,
24*time.Hour,
)
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "create_org_key",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] create org key for payload failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
dtoCluster.OrgKey = &orgKey
dtoCluster.OrgSecret = &orgSecret
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "marshal_payload",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] json marshal failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
runCtx, cancel := context.WithTimeout(ctx, perClusterTimeout)
err = pushAssetsToBastion(runCtx, db, c, sshConfig, keyPayloads, payloadJSON)
cancel()
if err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "ssh_push",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] failed to push assets to bastion")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
continue
}
if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
fail++
failedIDs = append(failedIDs, c.ID)
failures = append(failures, ClusterPrepareFailure{
ClusterID: c.ID,
Step: "set_pending",
Reason: err.Error(),
})
clusterLog.Error().Err(err).Msg("[cluster_prepare] failed to mark cluster pending")
continue
}
ok++
clusterLog.Info().Msg("[cluster_prepare] cluster marked pending")
}
res := ClusterPrepareResult{
Status: "ok",
Processed: proc,
MarkedPending: ok,
Failed: fail,
ElapsedMs: int(time.Since(start).Milliseconds()),
FailedIDs: failedIDs,
Failures: failures,
}
log.Info().
Int("processed", proc).
Int("pending", ok).
Int("failed", fail).
Msg("[cluster_prepare] reconcile tick ok")
next := time.Now().Add(time.Duration(args.IntervalS) * time.Second)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"prepare_cluster",
args,
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return res, nil
}
}
// ---------- helpers ----------
func validateClusterForPrepare(c *models.Cluster) error {
if c.BastionServer == nil || c.BastionServerID == nil || *c.BastionServerID == uuid.Nil {
return fmt.Errorf("missing bastion server")
}
if c.BastionServer.Status != "ready" {
return fmt.Errorf("bastion server not ready (status=%s)", c.BastionServer.Status)
}
// CaptainDomain is a value type; presence is via *ID
if c.CaptainDomainID == nil || *c.CaptainDomainID == uuid.Nil {
return fmt.Errorf("missing captain domain for cluster")
}
// ControlPlaneRecordSet is a pointer; presence is via *ID + non-nil struct
if c.ControlPlaneRecordSetID == nil || *c.ControlPlaneRecordSetID == uuid.Nil || c.ControlPlaneRecordSet == nil {
return fmt.Errorf("missing control_plane_record_set for cluster")
}
if len(c.NodePools) == 0 {
return fmt.Errorf("cluster has no node pools")
}
hasServer := false
for i := range c.NodePools {
if len(c.NodePools[i].Servers) > 0 {
hasServer = true
break
}
}
if !hasServer {
return fmt.Errorf("cluster has no servers attached to node pools")
}
return nil
}
func flattenClusterServers(c *models.Cluster) []*models.Server {
var out []*models.Server
for i := range c.NodePools {
for j := range c.NodePools[i].Servers {
s := &c.NodePools[i].Servers[j]
out = append(out, s)
}
}
return out
}
type keyPayload struct {
FileName string
PrivateKeyB64 string
}
// build ssh-config for all servers + decrypt keys.
// ssh-config is intended to live on the bastion and connect via *private* IPs.
func buildSSHAssetsForCluster(db *gorm.DB, c *models.Cluster, servers []*models.Server) (map[uuid.UUID]keyPayload, string, error) {
var sb strings.Builder
keys := make(map[uuid.UUID]keyPayload)
for _, s := range servers {
// Defensive checks
if strings.TrimSpace(s.PrivateIPAddress) == "" {
return nil, "", fmt.Errorf("server %s missing private ip", s.ID)
}
if s.SshKeyID == uuid.Nil {
return nil, "", fmt.Errorf("server %s missing ssh key relation", s.ID)
}
// de-dupe keys: many servers may share the same ssh key
if _, ok := keys[s.SshKeyID]; !ok {
priv, err := utils.DecryptForOrg(
s.OrganizationID,
s.SshKey.EncryptedPrivateKey,
s.SshKey.PrivateIV,
s.SshKey.PrivateTag,
db,
)
if err != nil {
return nil, "", fmt.Errorf("decrypt key for server %s: %w", s.ID, err)
}
fname := fmt.Sprintf("%s.pem", s.SshKeyID.String())
keys[s.SshKeyID] = keyPayload{
FileName: fname,
PrivateKeyB64: base64.StdEncoding.EncodeToString([]byte(priv)),
}
}
// ssh config entry per server
keyFile := keys[s.SshKeyID].FileName
hostAlias := s.Hostname
if hostAlias == "" {
hostAlias = s.ID.String()
}
sb.WriteString(fmt.Sprintf("Host %s\n", hostAlias))
sb.WriteString(fmt.Sprintf(" HostName %s\n", s.PrivateIPAddress))
sb.WriteString(fmt.Sprintf(" User %s\n", s.SSHUser))
sb.WriteString(fmt.Sprintf(" IdentityFile ~/.ssh/autoglue/keys/%s\n", keyFile))
sb.WriteString(" IdentitiesOnly yes\n")
sb.WriteString(" StrictHostKeyChecking accept-new\n\n")
}
return keys, sb.String(), nil
}
func pushAssetsToBastion(
ctx context.Context,
db *gorm.DB,
c *models.Cluster,
sshConfig string,
keyPayloads map[uuid.UUID]keyPayload,
payloadJSON []byte,
) error {
bastion := c.BastionServer
if bastion == nil {
return fmt.Errorf("bastion server is nil")
}
if bastion.PublicIPAddress == nil || strings.TrimSpace(*bastion.PublicIPAddress) == "" {
return fmt.Errorf("bastion server missing public ip")
}
privKey, err := utils.DecryptForOrg(
bastion.OrganizationID,
bastion.SshKey.EncryptedPrivateKey,
bastion.SshKey.PrivateIV,
bastion.SshKey.PrivateTag,
db,
)
if err != nil {
return fmt.Errorf("decrypt bastion key: %w", err)
}
signer, err := ssh.ParsePrivateKey([]byte(privKey))
if err != nil {
return fmt.Errorf("parse bastion private key: %w", err)
}
hkcb := makeDBHostKeyCallback(db, bastion)
config := &ssh.ClientConfig{
User: bastion.SSHUser,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: hkcb,
Timeout: 30 * time.Second,
}
host := net.JoinHostPort(*bastion.PublicIPAddress, "22")
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", host)
if err != nil {
return fmt.Errorf("dial bastion: %w", err)
}
defer conn.Close()
cconn, chans, reqs, err := ssh.NewClientConn(conn, host, config)
if err != nil {
return fmt.Errorf("ssh handshake bastion: %w", err)
}
client := ssh.NewClient(cconn, chans, reqs)
defer client.Close()
sess, err := client.NewSession()
if err != nil {
return fmt.Errorf("ssh session: %w", err)
}
defer sess.Close()
// build one shot script to:
// - mkdir ~/.ssh/autoglue/keys
// - write cluster-specific ssh-config
// - write all private keys
// - write payload.json
clusterDir := fmt.Sprintf("$HOME/autoglue/clusters/%s", c.ID.String())
configPath := fmt.Sprintf("$HOME/.ssh/autoglue/cluster-%s.config", c.ID.String())
var script bytes.Buffer
script.WriteString("set -euo pipefail\n")
script.WriteString("mkdir -p \"$HOME/.ssh/autoglue/keys\"\n")
script.WriteString("mkdir -p " + clusterDir + "\n")
script.WriteString("chmod 700 \"$HOME/.ssh\" || true\n")
// ssh-config
script.WriteString("cat > " + configPath + " <<'EOF_CFG'\n")
script.WriteString(sshConfig)
script.WriteString("EOF_CFG\n")
script.WriteString("chmod 600 " + configPath + "\n")
// keys
for id, kp := range keyPayloads {
tag := "KEY_" + id.String()
target := fmt.Sprintf("$HOME/.ssh/autoglue/keys/%s", kp.FileName)
script.WriteString("cat <<'" + tag + "' | base64 -d > " + target + "\n")
script.WriteString(kp.PrivateKeyB64 + "\n")
script.WriteString(tag + "\n")
script.WriteString("chmod 600 " + target + "\n")
}
// payload.json
payloadPath := clusterDir + "/payload.json"
script.WriteString("cat > " + payloadPath + " <<'EOF_PAYLOAD'\n")
script.Write(payloadJSON)
script.WriteString("\nEOF_PAYLOAD\n")
script.WriteString("chmod 600 " + payloadPath + "\n")
// If you later want to always include cluster configs automatically, you can
// optionally manage ~/.ssh/config here (kept simple for now).
sess.Stdin = strings.NewReader(script.String())
out, runErr := sess.CombinedOutput("bash -s")
if runErr != nil {
return wrapSSHError(runErr, string(out))
}
return nil
}
func setClusterStatus(db *gorm.DB, id uuid.UUID, status, lastError string) error {
updates := map[string]any{
"status": status,
"updated_at": time.Now(),
}
if lastError != "" {
updates["last_error"] = lastError
}
return db.Model(&models.Cluster{}).
Where("id = ?", id).
Updates(updates).Error
}
// runMakeOnBastion runs `make ` from the cluster's directory on the bastion.
func runMakeOnBastion(
ctx context.Context,
db *gorm.DB,
c *models.Cluster,
target string,
) (string, error) {
logger := log.With().
Str("cluster_id", c.ID.String()).
Str("cluster_name", c.Name).
Logger()
bastion := c.BastionServer
if bastion == nil {
return "", fmt.Errorf("bastion server is nil")
}
if bastion.PublicIPAddress == nil || strings.TrimSpace(*bastion.PublicIPAddress) == "" {
return "", fmt.Errorf("bastion server missing public ip")
}
privKey, err := utils.DecryptForOrg(
bastion.OrganizationID,
bastion.SshKey.EncryptedPrivateKey,
bastion.SshKey.PrivateIV,
bastion.SshKey.PrivateTag,
db,
)
if err != nil {
return "", fmt.Errorf("decrypt bastion key: %w", err)
}
signer, err := ssh.ParsePrivateKey([]byte(privKey))
if err != nil {
return "", fmt.Errorf("parse bastion private key: %w", err)
}
hkcb := makeDBHostKeyCallback(db, bastion)
config := &ssh.ClientConfig{
User: bastion.SSHUser,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: hkcb,
Timeout: 30 * time.Second,
}
host := net.JoinHostPort(*bastion.PublicIPAddress, "22")
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", host)
if err != nil {
return "", fmt.Errorf("dial bastion: %w", err)
}
defer conn.Close()
cconn, chans, reqs, err := ssh.NewClientConn(conn, host, config)
if err != nil {
return "", fmt.Errorf("ssh handshake bastion: %w", err)
}
client := ssh.NewClient(cconn, chans, reqs)
defer client.Close()
sess, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("ssh session: %w", err)
}
defer sess.Close()
clusterDir := fmt.Sprintf("$HOME/autoglue/clusters/%s", c.ID.String())
sshDir := fmt.Sprintf("$HOME/.ssh")
cmd := fmt.Sprintf("cd %s && docker run -v %s:/root/.ssh -v ./payload.json:/opt/gluekube/platform.json %s:%s make %s", clusterDir, sshDir, c.DockerImage, c.DockerTag, target)
logger.Info().
Str("cmd", cmd).
Msg("[runMakeOnBastion] executing remote command")
out, runErr := sess.CombinedOutput(cmd)
if runErr != nil {
return string(out), wrapSSHError(runErr, string(out))
}
return string(out), nil
}
func randomB64URL(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func findOrCreateClusterAutomationKey(
db *gorm.DB,
orgID uuid.UUID,
clusterID uuid.UUID,
ttl time.Duration,
) (orgKey string, orgSecret string, err error) {
now := time.Now()
name := fmt.Sprintf("cluster-%s-bastion", clusterID.String())
// 1) Delete any existing ephemeral cluster-bastion key for this org+cluster
if err := db.Where(
"org_id = ? AND scope = ? AND purpose = ? AND cluster_id = ? AND is_ephemeral = ?",
orgID, "org", "cluster_bastion", clusterID, true,
).Delete(&models.APIKey{}).Error; err != nil {
return "", "", fmt.Errorf("delete existing cluster key: %w", err)
}
// 2) Mint a fresh keypair
keySuffix, err := randomB64URL(16)
if err != nil {
return "", "", fmt.Errorf("entropy_error: %w", err)
}
sec, err := randomB64URL(32)
if err != nil {
return "", "", fmt.Errorf("entropy_error: %w", err)
}
orgKey = "org_" + keySuffix
orgSecret = sec
keyHash := auth.SHA256Hex(orgKey)
secretHash, err := auth.HashSecretArgon2id(orgSecret)
if err != nil {
return "", "", fmt.Errorf("hash_error: %w", err)
}
exp := now.Add(ttl)
prefix := orgKey
if len(prefix) > 12 {
prefix = prefix[:12]
}
rec := models.APIKey{
OrgID: &orgID,
Scope: "org",
Purpose: "cluster_bastion",
ClusterID: &clusterID,
IsEphemeral: true,
Name: name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: &exp,
Revoked: false,
Prefix: &prefix,
}
if err := db.Create(&rec).Error; err != nil {
return "", "", fmt.Errorf("db_error: %w", err)
}
return orgKey, orgSecret, nil
}
package bg
import (
"context"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/google/uuid"
"gorm.io/gorm"
)
type RefreshTokenRow struct {
ID string `gorm:"primaryKey"`
RevokedAt *time.Time
ExpiresAt time.Time
UpdatedAt time.Time
}
func (RefreshTokenRow) TableName() string { return "refresh_tokens" }
type TokensCleanupArgs struct {
// kept in case you want to change retention or add dry-run later
}
func TokensCleanupWorker(db *gorm.DB, jobs *Jobs) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
if err := CleanupRefreshTokens(db); err != nil {
return nil, err
}
// schedule tomorrow 03:45
next := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(3*time.Hour + 45*time.Minute)
_, _ = jobs.Enqueue(
ctx,
uuid.NewString(),
"tokens_cleanup",
TokensCleanupArgs{},
archer.WithScheduleTime(next),
archer.WithMaxRetries(1),
)
return nil, nil
}
}
func CleanupRefreshTokens(db *gorm.DB) error {
now := time.Now()
return db.
Where("revoked_at IS NOT NULL OR expires_at < ?", now).
Delete(&RefreshTokenRow{}).Error
}
package common
import (
"time"
"github.com/google/uuid"
)
type AuditFields struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}
package config
import (
"errors"
"fmt"
"strings"
"sync"
"github.com/joho/godotenv"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
type Config struct {
DbURL string
DbURLRO string
Port string
Host string
JWTIssuer string
JWTAudience string
JWTPrivateEncKey string
OAuthRedirectBase string
GoogleClientID string
GoogleClientSecret string
GithubClientID string
GithubClientSecret string
UIDev bool
Env string
Debug bool
Swagger bool
SwaggerHost string
DBStudioEnabled bool
DBStudioBind string
DBStudioPort string
DBStudioUser string
DBStudioPass string
}
var (
once sync.Once
cached Config
loadErr error
)
func Load() (Config, error) {
once.Do(func() {
_ = godotenv.Load()
// Use a private viper to avoid global mutation/races
v := viper.New()
// Defaults
v.SetDefault("bind.address", "127.0.0.1")
v.SetDefault("bind.port", "8080")
v.SetDefault("database.url", "postgres://user:pass@localhost:5432/db?sslmode=disable")
v.SetDefault("database.url_ro", "")
v.SetDefault("db_studio.enabled", false)
v.SetDefault("db_studio.bind", "127.0.0.1")
v.SetDefault("db_studio.port", "0") // 0 = random
v.SetDefault("db_studio.user", "")
v.SetDefault("db_studio.pass", "")
v.SetDefault("ui.dev", false)
v.SetDefault("env", "development")
v.SetDefault("debug", false)
v.SetDefault("swagger", false)
v.SetDefault("swagger.host", "localhost:8080")
// Env setup and binding
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
keys := []string{
"bind.address",
"bind.port",
"database.url",
"database.url_ro",
"jwt.issuer",
"jwt.audience",
"jwt.private.enc.key",
"oauth.redirect.base",
"google.client.id",
"google.client.secret",
"github.client.id",
"github.client.secret",
"ui.dev",
"env",
"debug",
"swagger",
"swagger.host",
"db_studio.enabled",
"db_studio.bind",
"db_studio.port",
"db_studio.user",
"db_studio.pass",
}
for _, k := range keys {
_ = v.BindEnv(k)
}
// Build config
cfg := Config{
DbURL: v.GetString("database.url"),
DbURLRO: v.GetString("database.url_ro"),
Port: v.GetString("bind.port"),
Host: v.GetString("bind.address"),
JWTIssuer: v.GetString("jwt.issuer"),
JWTAudience: v.GetString("jwt.audience"),
JWTPrivateEncKey: v.GetString("jwt.private.enc.key"),
OAuthRedirectBase: v.GetString("oauth.redirect.base"),
GoogleClientID: v.GetString("google.client.id"),
GoogleClientSecret: v.GetString("google.client.secret"),
GithubClientID: v.GetString("github.client.id"),
GithubClientSecret: v.GetString("github.client.secret"),
UIDev: v.GetBool("ui.dev"),
Env: v.GetString("env"),
Debug: v.GetBool("debug"),
Swagger: v.GetBool("swagger"),
SwaggerHost: v.GetString("swagger.host"),
DBStudioEnabled: v.GetBool("db_studio.enabled"),
DBStudioBind: v.GetString("db_studio.bind"),
DBStudioPort: v.GetString("db_studio.port"),
DBStudioUser: v.GetString("db_studio.user"),
DBStudioPass: v.GetString("db_studio.pass"),
}
// Validate
if err := validateConfig(cfg); err != nil {
loadErr = err
return
}
cached = cfg
})
return cached, loadErr
}
func validateConfig(cfg Config) error {
var errs []string
// Required general settings
req := map[string]string{
"jwt.issuer": cfg.JWTIssuer,
"jwt.audience": cfg.JWTAudience,
"jwt.private.enc.key": cfg.JWTPrivateEncKey,
"oauth.redirect.base": cfg.OAuthRedirectBase,
}
for k, v := range req {
if strings.TrimSpace(v) == "" {
errs = append(errs, fmt.Sprintf("missing required config key %q (env %s)", k, envNameFromKey(k)))
}
}
// OAuth provider requirements:
googleOK := strings.TrimSpace(cfg.GoogleClientID) != "" && strings.TrimSpace(cfg.GoogleClientSecret) != ""
githubOK := strings.TrimSpace(cfg.GithubClientID) != "" && strings.TrimSpace(cfg.GithubClientSecret) != ""
// If partially configured, report what's missing for each
if !googleOK && (cfg.GoogleClientID != "" || cfg.GoogleClientSecret != "") {
if cfg.GoogleClientID == "" {
errs = append(errs, fmt.Sprintf("google.client.id is missing (env %s) while google.client.secret is set", envNameFromKey("google.client.id")))
}
if cfg.GoogleClientSecret == "" {
errs = append(errs, fmt.Sprintf("google.client.secret is missing (env %s) while google.client.id is set", envNameFromKey("google.client.secret")))
}
}
if !githubOK && (cfg.GithubClientID != "" || cfg.GithubClientSecret != "") {
if cfg.GithubClientID == "" {
errs = append(errs, fmt.Sprintf("github.client.id is missing (env %s) while github.client.secret is set", envNameFromKey("github.client.id")))
}
if cfg.GithubClientSecret == "" {
errs = append(errs, fmt.Sprintf("github.client.secret is missing (env %s) while github.client.id is set", envNameFromKey("github.client.secret")))
}
}
// Enforce minimum: at least one full provider
if !googleOK && !githubOK {
errs = append(errs, "at least one OAuth provider must be fully configured: either Google (google.client.id + google.client.secret) or GitHub (github.client.id + github.client.secret)")
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "; "))
}
return nil
}
func envNameFromKey(key string) string {
return strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
}
func DebugPrintConfig() {
cfg, _ := Load()
b, err := yaml.Marshal(cfg)
if err != nil {
fmt.Println("error marshalling config:", err)
return
}
fmt.Println("Loaded configuration:")
fmt.Println(string(b))
}
func IsUIDev() bool {
cfg, _ := Load()
return cfg.UIDev
}
func IsDev() bool {
cfg, _ := Load()
return strings.EqualFold(cfg.Env, "development")
}
func IsDebug() bool {
cfg, _ := Load()
return cfg.Debug
}
func IsSwaggerEnabled() bool {
cfg, _ := Load()
return cfg.Swagger
}
package db
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Open(dsn string) *gorm.DB {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
if err != nil {
log.Fatalf("failed to connect to db: %v", err)
}
return db
}
package db
import (
"fmt"
"gorm.io/gorm"
)
func Run(db *gorm.DB, models ...any) error {
return db.Transaction(func(tx *gorm.DB) error {
// 0) Extensions
if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS pgcrypto`).Error; err != nil {
return fmt.Errorf("enable pgcrypto: %w", err)
}
if err := tx.Exec(`CREATE EXTENSION IF NOT EXISTS citext`).Error; err != nil {
return fmt.Errorf("enable citext: %w", err)
}
// 1) AutoMigrate (pass parents before children in caller)
if err := tx.AutoMigrate(models...); err != nil {
return fmt.Errorf("automigrate: %w", err)
}
return nil
})
}
package dto
import (
"time"
"github.com/google/uuid"
)
type ActionResponse struct {
ID uuid.UUID `json:"id" format:"uuid"`
Label string `json:"label"`
Description string `json:"description"`
MakeTarget string `json:"make_target"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
type CreateActionRequest struct {
Label string `json:"label"`
Description string `json:"description"`
MakeTarget string `json:"make_target"`
}
type UpdateActionRequest struct {
Label *string `json:"label,omitempty"`
Description *string `json:"description,omitempty"`
MakeTarget *string `json:"make_target,omitempty"`
}
package dto
import "github.com/glueops/autoglue/internal/common"
type AnnotationResponse struct {
common.AuditFields
Key string `json:"key"`
Value string `json:"value"`
}
type CreateAnnotationRequest struct {
Key string `json:"key"`
Value string `json:"value"`
}
type UpdateAnnotationRequest struct {
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
}
package dto
// swagger:model AuthStartResponse
type AuthStartResponse struct {
AuthURL string `json:"auth_url" example:"https://accounts.google.com/o/oauth2/v2/auth?client_id=..."`
}
// swagger:model TokenPair
type TokenPair struct {
AccessToken string `json:"access_token" example:"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ij..."`
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf...."`
TokenType string `json:"token_type" example:"Bearer"`
ExpiresIn int64 `json:"expires_in" example:"3600"`
}
// swagger:model RefreshRequest
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
}
// swagger:model LogoutRequest
type LogoutRequest struct {
RefreshToken string `json:"refresh_token" example:"m0l9o8rT3t0V8d3eFf..."`
}
package dto
import (
"time"
"github.com/google/uuid"
)
type ClusterRunResponse struct {
ID uuid.UUID `json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
ClusterID uuid.UUID `json:"cluster_id" format:"uuid"`
Action string `json:"action"`
Status string `json:"status"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
}
package dto
import (
"time"
"github.com/google/uuid"
)
type ClusterResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
CaptainDomain *DomainResponse `json:"captain_domain,omitempty"`
ControlPlaneRecordSet *RecordSetResponse `json:"control_plane_record_set,omitempty"`
ControlPlaneFQDN *string `json:"control_plane_fqdn,omitempty"`
AppsLoadBalancer *LoadBalancerResponse `json:"apps_load_balancer,omitempty"`
GlueOpsLoadBalancer *LoadBalancerResponse `json:"glueops_load_balancer,omitempty"`
BastionServer *ServerResponse `json:"bastion_server,omitempty"`
Provider string `json:"cluster_provider"`
Region string `json:"region"`
Status string `json:"status"`
LastError string `json:"last_error"`
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"`
NodePools []NodePoolResponse `json:"node_pools,omitempty"`
DockerImage string `json:"docker_image"`
DockerTag string `json:"docker_tag"`
Kubeconfig *string `json:"kubeconfig,omitempty"`
OrgKey *string `json:"org_key,omitempty"`
OrgSecret *string `json:"org_secret,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateClusterRequest struct {
Name string `json:"name"`
ClusterProvider string `json:"cluster_provider"`
Region string `json:"region"`
DockerImage string `json:"docker_image"`
DockerTag string `json:"docker_tag"`
}
type UpdateClusterRequest struct {
Name *string `json:"name,omitempty"`
ClusterProvider *string `json:"cluster_provider,omitempty"`
Region *string `json:"region,omitempty"`
DockerImage *string `json:"docker_image,omitempty"`
DockerTag *string `json:"docker_tag,omitempty"`
}
type AttachCaptainDomainRequest struct {
DomainID uuid.UUID `json:"domain_id"`
}
type AttachRecordSetRequest struct {
RecordSetID uuid.UUID `json:"record_set_id"`
}
type AttachLoadBalancerRequest struct {
LoadBalancerID uuid.UUID `json:"load_balancer_id"`
}
type AttachBastionRequest struct {
ServerID uuid.UUID `json:"server_id"`
}
type SetKubeconfigRequest struct {
Kubeconfig string `json:"kubeconfig"`
}
type AttachNodePoolRequest struct {
NodePoolID uuid.UUID `json:"node_pool_id"`
}
package dto
import (
"encoding/json"
"github.com/go-playground/validator/v10"
)
// RawJSON is a swagger-friendly wrapper for json.RawMessage.
type RawJSON = json.RawMessage
var Validate = validator.New()
func init() {
_ = Validate.RegisterValidation("awsarn", func(fl validator.FieldLevel) bool {
v := fl.Field().String()
return len(v) > 10 && len(v) < 2048 && len(v) >= 4 && v[:4] == "arn:"
})
}
/*** Shapes for secrets ***/
type AWSCredential struct {
AccessKeyID string `json:"access_key_id" validate:"required,alphanum,len=20"`
SecretAccessKey string `json:"secret_access_key" validate:"required"`
Region string `json:"region" validate:"omitempty"`
}
type BasicAuth struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}
type APIToken struct {
Token string `json:"token" validate:"required"`
}
type OAuth2Credential struct {
ClientID string `json:"client_id" validate:"required"`
ClientSecret string `json:"client_secret" validate:"required"`
RefreshToken string `json:"refresh_token" validate:"required"`
}
/*** Shapes for scopes ***/
type AWSProviderScope struct{}
type AWSServiceScope struct {
Service string `json:"service" validate:"required,oneof=route53 s3 ec2 iam rds dynamodb"`
}
type AWSResourceScope struct {
ARN string `json:"arn" validate:"required,awsarn"`
}
/*** Registries ***/
type ProviderDef struct {
New func() any
Validate func(any) error
}
type ScopeDef struct {
New func() any
Validate func(any) error
Specificity int // 0=provider, 1=service, 2=resource
}
// Secret shapes per provider/kind/version
var CredentialRegistry = map[string]map[string]map[int]ProviderDef{
"aws": {
"aws_access_key": {
1: {New: func() any { return &AWSCredential{} }, Validate: func(x any) error { return Validate.Struct(x) }},
},
},
"cloudflare": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"hetzner": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"digitalocean": {"api_token": {1: {New: func() any { return &APIToken{} }, Validate: func(x any) error { return Validate.Struct(x) }}}},
"generic": {
"basic_auth": {1: {New: func() any { return &BasicAuth{} }, Validate: func(x any) error { return Validate.Struct(x) }}},
"oauth2": {1: {New: func() any { return &OAuth2Credential{} }, Validate: func(x any) error { return Validate.Struct(x) }}},
},
}
// Scope shapes per provider/scopeKind/version
var ScopeRegistry = map[string]map[string]map[int]ScopeDef{
"aws": {
"provider": {1: {New: func() any { return &AWSProviderScope{} }, Validate: func(any) error { return nil }, Specificity: 0}},
"service": {1: {New: func() any { return &AWSServiceScope{} }, Validate: func(x any) error { return Validate.Struct(x) }, Specificity: 1}},
"resource": {1: {New: func() any { return &AWSResourceScope{} }, Validate: func(x any) error { return Validate.Struct(x) }, Specificity: 2}},
},
}
/*** API DTOs used by swagger ***/
// CreateCredentialRequest represents the POST /credentials payload
type CreateCredentialRequest struct {
CredentialProvider string `json:"credential_provider" validate:"required,oneof=aws cloudflare hetzner digitalocean generic"`
Kind string `json:"kind" validate:"required"` // aws_access_key, api_token, basic_auth, oauth2
SchemaVersion int `json:"schema_version" validate:"required,gte=1"` // secret schema version
Name string `json:"name" validate:"omitempty,max=100"` // human label
ScopeKind string `json:"scope_kind" validate:"required,oneof=credential_provider service resource"`
ScopeVersion int `json:"scope_version" validate:"required,gte=1"` // scope schema version
Scope RawJSON `json:"scope" validate:"required" swaggertype:"object"` // {"service":"route53"} or {"arn":"..."}
AccountID string `json:"account_id,omitempty" validate:"omitempty,max=32"`
Region string `json:"region,omitempty" validate:"omitempty,max=32"`
Secret RawJSON `json:"secret" validate:"required" swaggertype:"object"` // encrypted later
}
// UpdateCredentialRequest represents PATCH /credentials/{id}
type UpdateCredentialRequest struct {
Name *string `json:"name,omitempty"`
AccountID *string `json:"account_id,omitempty"`
Region *string `json:"region,omitempty"`
ScopeKind *string `json:"scope_kind,omitempty"`
ScopeVersion *int `json:"scope_version,omitempty"`
Scope *RawJSON `json:"scope,omitempty" swaggertype:"object"`
Secret *RawJSON `json:"secret,omitempty" swaggertype:"object"` // set if rotating
}
// CredentialOut is what we return (no secrets)
type CredentialOut struct {
ID string `json:"id"`
CredentialProvider string `json:"credential_provider"`
Kind string `json:"kind"`
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
ScopeKind string `json:"scope_kind"`
ScopeVersion int `json:"scope_version"`
Scope RawJSON `json:"scope" swaggertype:"object"`
AccountID string `json:"account_id,omitempty"`
Region string `json:"region,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
package dto
import (
"encoding/json"
"strings"
"github.com/go-playground/validator/v10"
)
var dnsValidate = validator.New()
func init() {
_ = dnsValidate.RegisterValidation("fqdn", func(fl validator.FieldLevel) bool {
s := strings.TrimSpace(fl.Field().String())
if s == "" || len(s) > 253 {
return false
}
// Minimal: lower-cased, no trailing dot in our API (normalize server-side)
// You can add stricter checks later.
return !strings.HasPrefix(s, ".") && !strings.Contains(s, "..")
})
_ = dnsValidate.RegisterValidation("rrtype", func(fl validator.FieldLevel) bool {
switch strings.ToUpper(fl.Field().String()) {
case "A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA":
return true
default:
return false
}
})
}
// ---- Domains ----
type CreateDomainRequest struct {
DomainName string `json:"domain_name" validate:"required,fqdn"`
CredentialID string `json:"credential_id" validate:"required,uuid4"`
ZoneID string `json:"zone_id,omitempty" validate:"omitempty,max=128"`
}
type UpdateDomainRequest struct {
CredentialID *string `json:"credential_id,omitempty" validate:"omitempty,uuid4"`
ZoneID *string `json:"zone_id,omitempty" validate:"omitempty,max=128"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending provisioning ready failed"`
DomainName *string `json:"domain_name,omitempty" validate:"omitempty,fqdn"`
}
type DomainResponse struct {
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
DomainName string `json:"domain_name"`
ZoneID string `json:"zone_id"`
Status string `json:"status"`
LastError string `json:"last_error"`
CredentialID string `json:"credential_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ---- Record Sets ----
type AliasTarget struct {
HostedZoneID string `json:"hosted_zone_id" validate:"required"`
DNSName string `json:"dns_name" validate:"required"`
EvaluateTargetHealth bool `json:"evaluate_target_health"`
}
type CreateRecordSetRequest struct {
// Name relative to domain ("endpoint") OR FQDN ("endpoint.example.com").
// Server normalizes to relative.
Name string `json:"name" validate:"required,max=253"`
Type string `json:"type" validate:"required,rrtype"`
TTL *int `json:"ttl,omitempty" validate:"omitempty,gte=1,lte=86400"`
Values []string `json:"values" validate:"omitempty,dive,min=1,max=1024"`
}
type UpdateRecordSetRequest struct {
// Any change flips status back to pending (worker will UPSERT)
Name *string `json:"name,omitempty" validate:"omitempty,max=253"`
Type *string `json:"type,omitempty" validate:"omitempty,rrtype"`
TTL *int `json:"ttl,omitempty" validate:"omitempty,gte=1,lte=86400"`
Values *[]string `json:"values,omitempty" validate:"omitempty,dive,min=1,max=1024"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=pending provisioning ready failed"`
}
type RecordSetResponse struct {
ID string `json:"id"`
DomainID string `json:"domain_id"`
Name string `json:"name"`
Type string `json:"type"`
TTL *int `json:"ttl,omitempty"`
Values json.RawMessage `json:"values" swaggertype:"object"` // []string JSON
Fingerprint string `json:"fingerprint"`
Status string `json:"status"`
LastError string `json:"last_error"`
Owner string `json:"owner"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DNSValidate Quick helper to validate DTOs in handlers
func DNSValidate(i any) error {
return dnsValidate.Struct(i)
}
package dto
import (
"encoding/json"
"time"
)
type JobStatus string
const (
StatusQueued JobStatus = "queued"
StatusRunning JobStatus = "running"
StatusSucceeded JobStatus = "succeeded"
StatusFailed JobStatus = "failed"
StatusCanceled JobStatus = "canceled"
StatusRetrying JobStatus = "retrying"
StatusScheduled JobStatus = "scheduled"
)
// Job represents a background job managed by Archer.
// swagger:model Job
type Job struct {
ID string `json:"id" example:"01HF7SZK8Z8WG1M3J7S2Z8M2N6"`
Type string `json:"type" example:"email.send"`
Queue string `json:"queue" example:"default"`
Status JobStatus `json:"status" example:"queued" enums:"queued|running|succeeded|failed|canceled|retrying|scheduled"`
Attempts int `json:"attempts" example:"0"`
MaxAttempts int `json:"max_attempts,omitempty" example:"3"`
CreatedAt time.Time `json:"created_at" example:"2025-11-04T09:30:00Z"`
UpdatedAt *time.Time `json:"updated_at,omitempty" example:"2025-11-04T09:30:00Z"`
LastError *string `json:"last_error,omitempty" example:"error message"`
RunAt *time.Time `json:"run_at,omitempty" example:"2025-11-04T09:30:00Z"`
Payload any `json:"payload,omitempty"`
}
// QueueInfo holds queue-level counts.
// swagger:model QueueInfo
type QueueInfo struct {
Name string `json:"name" example:"default"`
Pending int `json:"pending" example:"42"`
Running int `json:"running" example:"3"`
Failed int `json:"failed" example:"5"`
Scheduled int `json:"scheduled" example:"7"`
}
// PageJob is a concrete paginated response for Job (generics not supported by swag).
// swagger:model PageJob
type PageJob struct {
Items []Job `json:"items"`
Total int `json:"total" example:"120"`
Page int `json:"page" example:"1"`
PageSize int `json:"page_size" example:"25"`
}
// EnqueueRequest is the POST body for creating a job.
// swagger:model EnqueueRequest
type EnqueueRequest struct {
Queue string `json:"queue" example:"default"`
Type string `json:"type" example:"email.send"`
Payload json.RawMessage `json:"payload" swaggertype:"object"`
RunAt *time.Time `json:"run_at" example:"2025-11-05T08:00:00Z"`
}
package dto
// JWK represents a single JSON Web Key (public only).
// swagger:model JWK
type JWK struct {
Kty string `json:"kty" example:"RSA" gorm:"-"`
Use string `json:"use,omitempty" example:"sig" gorm:"-"`
Kid string `json:"kid,omitempty" example:"7c6f1d0a-7a98-4e6a-9dbf-6b1af4b9f345" gorm:"-"`
Alg string `json:"alg,omitempty" example:"RS256" gorm:"-"`
N string `json:"n,omitempty" gorm:"-"`
E string `json:"e,omitempty" example:"AQAB" gorm:"-"`
X string `json:"x,omitempty" gorm:"-"`
}
// JWKS is a JSON Web Key Set container.
// swagger:model JWKS
type JWKS struct {
Keys []JWK `json:"keys" gorm:"-"`
}
package dto
import (
"github.com/glueops/autoglue/internal/common"
)
type LabelResponse struct {
common.AuditFields
Key string `json:"key"`
Value string `json:"value"`
}
type CreateLabelRequest struct {
Key string `json:"key"`
Value string `json:"value"`
}
type UpdateLabelRequest struct {
Key *string `json:"key"`
Value *string `json:"value"`
}
package dto
import (
"time"
"github.com/google/uuid"
)
type LoadBalancerResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Name string `json:"name"`
Kind string `json:"kind"`
PublicIPAddress string `json:"public_ip_address"`
PrivateIPAddress string `json:"private_ip_address"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateLoadBalancerRequest struct {
Name string `json:"name" example:"glueops"`
Kind string `json:"kind" example:"public" enums:"glueops,public"`
PublicIPAddress string `json:"public_ip_address" example:"8.8.8.8"`
PrivateIPAddress string `json:"private_ip_address" example:"192.168.0.2"`
}
type UpdateLoadBalancerRequest struct {
Name *string `json:"name" example:"glue"`
Kind *string `json:"kind" example:"public" enums:"glueops,public"`
PublicIPAddress *string `json:"public_ip_address" example:"8.8.8.8"`
PrivateIPAddress *string `json:"private_ip_address" example:"192.168.0.2"`
}
package dto
import "github.com/glueops/autoglue/internal/common"
type NodeRole string
const (
NodeRoleMaster NodeRole = "master"
NodeRoleWorker NodeRole = "worker"
)
type CreateNodePoolRequest struct {
Name string `json:"name"`
Role NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
}
type UpdateNodePoolRequest struct {
Name *string `json:"name"`
Role *NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
}
type NodePoolResponse struct {
common.AuditFields
Name string `json:"name"`
Role NodeRole `json:"role" enums:"master,worker" swaggertype:"string"`
Servers []ServerResponse `json:"servers"`
Annotations []AnnotationResponse `json:"annotations"`
Labels []LabelResponse `json:"labels"`
Taints []TaintResponse `json:"taints"`
}
type AttachServersRequest struct {
ServerIDs []string `json:"server_ids"`
}
type AttachTaintsRequest struct {
TaintIDs []string `json:"taint_ids"`
}
type AttachLabelsRequest struct {
LabelIDs []string `json:"label_ids"`
}
type AttachAnnotationsRequest struct {
AnnotationIDs []string `json:"annotation_ids"`
}
package dto
import "github.com/google/uuid"
type CreateServerRequest struct {
Hostname string `json:"hostname,omitempty"`
PublicIPAddress string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `json:"private_ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID string `json:"ssh_key_id"`
Role string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
}
type UpdateServerRequest struct {
Hostname *string `json:"hostname,omitempty"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress *string `json:"private_ip_address,omitempty"`
SSHUser *string `json:"ssh_user,omitempty"`
SshKeyID *string `json:"ssh_key_id,omitempty"`
Role *string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
}
type ServerResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Hostname string `json:"hostname"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `json:"private_ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID uuid.UUID `json:"ssh_key_id"`
Role string `json:"role" example:"master|worker|bastion" enums:"master,worker,bastion"`
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed" enums:"pending,provisioning,ready,failed"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
package dto
import (
"github.com/glueops/autoglue/internal/common"
)
type CreateSSHRequest struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty" example:"deploy@autoglue"`
Bits *int `json:"bits,omitempty"` // Only for RSA
Type *string `json:"type,omitempty"` // "rsa" (default) or "ed25519"
}
type SshResponse struct {
common.AuditFields
Name string `json:"name"`
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
EncryptedPrivateKey string `json:"-"`
PrivateIV string `json:"-"`
PrivateTag string `json:"-"`
}
type SshRevealResponse struct {
SshResponse
PrivateKey string `json:"private_key"`
}
type SshMaterialJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Fingerprint string `json:"fingerprint"`
// Exactly one of the following will be populated for part=public/private.
PublicKey *string `json:"public_key,omitempty"` // OpenSSH authorized_key (string)
PrivatePEM *string `json:"private_pem,omitempty"` // PKCS#1/PEM (string)
// For part=both with mode=json we'll return a base64 zip
ZipBase64 *string `json:"zip_base64,omitempty"` // base64-encoded zip
// Suggested filenames (SDKs can save to disk without inferring names)
Filenames []string `json:"filenames"`
}
package dto
import "github.com/google/uuid"
type TaintResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type CreateTaintRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Effect string `json:"effect"`
}
type UpdateTaintRequest struct {
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
Effect *string `json:"effect,omitempty"`
}
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListActions godoc
//
// @ID ListActions
// @Summary List available actions
// @Description Returns all admin-configured actions.
// @Tags Actions
// @Produce json
// @Success 200 {array} dto.ActionResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "db error"
// @Router /admin/actions [get]
// @Security BearerAuth
func ListActions(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var rows []models.Action
if err := db.Order("label ASC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ActionResponse, 0, len(rows))
for _, a := range rows {
out = append(out, actionToDTO(a))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetAction godoc
//
// @ID GetAction
// @Summary Get a single action by ID
// @Description Returns a single action.
// @Tags Actions
// @Produce json
// @Param actionID path string true "Action ID"
// @Success 200 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [get]
// @Security BearerAuth
func GetAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
var row models.Action
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
}
}
// CreateAction godoc
//
// @ID CreateAction
// @Summary Create an action
// @Description Creates a new admin-configured action.
// @Tags Actions
// @Accept json
// @Produce json
// @Param body body dto.CreateActionRequest true "payload"
// @Success 201 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "db error"
// @Router /admin/actions [post]
// @Security BearerAuth
func CreateAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in dto.CreateActionRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
label := strings.TrimSpace(in.Label)
desc := strings.TrimSpace(in.Description)
target := strings.TrimSpace(in.MakeTarget)
if label == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label is required")
return
}
if desc == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description is required")
return
}
if target == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target is required")
return
}
row := models.Action{
Label: label,
Description: desc,
MakeTarget: target,
}
if err := db.Create(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusCreated, actionToDTO(row))
}
}
// UpdateAction godoc
//
// @ID UpdateAction
// @Summary Update an action
// @Description Updates an action. Only provided fields are modified.
// @Tags Actions
// @Accept json
// @Produce json
// @Param actionID path string true "Action ID"
// @Param body body dto.UpdateActionRequest true "payload"
// @Success 200 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [patch]
// @Security BearerAuth
func UpdateAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
var in dto.UpdateActionRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var row models.Action
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if in.Label != nil {
v := strings.TrimSpace(*in.Label)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label cannot be empty")
return
}
row.Label = v
}
if in.Description != nil {
v := strings.TrimSpace(*in.Description)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description cannot be empty")
return
}
row.Description = v
}
if in.MakeTarget != nil {
v := strings.TrimSpace(*in.MakeTarget)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target cannot be empty")
return
}
row.MakeTarget = v
}
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
}
}
// DeleteAction godoc
//
// @ID DeleteAction
// @Summary Delete an action
// @Description Deletes an action.
// @Tags Actions
// @Produce json
// @Param actionID path string true "Action ID"
// @Success 204 {string} string "deleted"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [delete]
// @Security BearerAuth
func DeleteAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
tx := db.Where("id = ?", actionID).Delete(&models.Action{})
if tx.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if tx.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func actionToDTO(a models.Action) dto.ActionResponse {
return dto.ActionResponse{
ID: a.ID,
Label: a.Label,
Description: a.Description,
MakeTarget: a.MakeTarget,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
}
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListAnnotations godoc
//
// @ID ListAnnotations
// @Summary List annotations (org scoped)
// @Description Returns annotations for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
// @Tags Annotations
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key"
// @Param value query string false "Exact value"
// @Param q query string false "key contains (case-insensitive)"
// @Success 200 {array} dto.AnnotationResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list annotations"
// @Router /annotations [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListAnnotations(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
q = q.Where(`key = ?`, key)
}
if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
q = q.Where(`value = ?`, val)
}
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where(`key ILIKE ?`, "%"+needle+"%")
}
var out []dto.AnnotationResponse
if err := q.Model(&models.Annotation{}).Order("created_at DESC").Scan(&out).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if out == nil {
out = []dto.AnnotationResponse{}
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetAnnotation godoc
//
// @ID GetAnnotation
// @Summary Get annotation by ID (org scoped)
// @Description Returns one annotation. Add `include=node_pools` to include node pools.
// @Tags Annotations
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Annotation ID (UUID)"
// @Success 200 {object} dto.AnnotationResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /annotations/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetAnnotation(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var out dto.AnnotationResponse
if err := db.Model(&models.Annotation{}).Where("id = ? AND organization_id = ?", id, orgID).First(&out).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateAnnotation godoc
//
// @ID CreateAnnotation
// @Summary Create annotation (org scoped)
// @Description Creates an annotation.
// @Tags Annotations
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateAnnotationRequest true "Annotation payload"
// @Success 201 {object} dto.AnnotationResponse
// @Failure 400 {string} string "invalid json / missing fields"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /annotations [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateAnnotation(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateAnnotationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Key = strings.TrimSpace(req.Key)
req.Value = strings.TrimSpace(req.Value)
if req.Key == "" || req.Value == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value")
return
}
a := models.Annotation{
AuditFields: common.AuditFields{OrganizationID: orgID},
Key: req.Key,
Value: req.Value,
}
if err := db.Create(&a).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.AnnotationResponse{
AuditFields: a.AuditFields,
Key: a.Key,
Value: a.Value,
}
utils.WriteJSON(w, http.StatusCreated, out)
}
}
// UpdateAnnotation godoc
//
// @ID UpdateAnnotation
// @Summary Update annotation (org scoped)
// @Description Partially update annotation fields.
// @Tags Annotations
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Annotation ID (UUID)"
// @Param body body dto.UpdateAnnotationRequest true "Fields to update"
// @Success 200 {object} dto.AnnotationResponse
// @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /annotations/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateAnnotation(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var a models.Annotation
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&a).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.UpdateAnnotationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
if req.Key != nil {
a.Key = strings.TrimSpace(*req.Key)
}
if req.Value != nil {
a.Value = strings.TrimSpace(*req.Value)
}
if err := db.Save(&a).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.AnnotationResponse{
AuditFields: a.AuditFields,
Key: a.Key,
Value: a.Value,
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// DeleteAnnotation godoc
//
// @ID DeleteAnnotation
// @Summary Delete annotation (org scoped)
// @Description Permanently deletes the annotation.
// @Tags Annotations
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Annotation ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /annotations/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteAnnotation(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Annotation{}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"html/template"
"net/http"
"net/url"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
type oauthProvider struct {
Name string
Issuer string
Scopes []string
ClientID string
Secret string
}
func providerConfig(cfg config.Config, name string) (oauthProvider, bool) {
switch strings.ToLower(name) {
case "google":
return oauthProvider{
Name: "google",
Issuer: "https://accounts.google.com",
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
ClientID: cfg.GoogleClientID,
Secret: cfg.GoogleClientSecret,
}, true
case "github":
// GitHub is not a pure OIDC provider; we use OAuth2 + user email API
return oauthProvider{
Name: "github",
Issuer: "github",
Scopes: []string{"read:user", "user:email"},
ClientID: cfg.GithubClientID, Secret: cfg.GithubClientSecret,
}, true
}
return oauthProvider{}, false
}
// AuthStart godoc
//
// @ID AuthStart
// @Summary Begin social login
// @Description Returns provider authorization URL for the frontend to redirect
// @Tags Auth
// @Param provider path string true "google|github"
// @Produce json
// @Success 200 {object} dto.AuthStartResponse
// @Router /auth/{provider}/start [post]
func AuthStart(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
provider := strings.ToLower(chi.URLParam(r, "provider"))
p, ok := providerConfig(cfg, provider)
if !ok || p.ClientID == "" || p.Secret == "" {
utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
return
}
redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
// Optional SPA hints to be embedded into state
mode := r.URL.Query().Get("mode") // "spa" enables postMessage callback page
origin := r.URL.Query().Get("origin") // e.g. http://localhost:5173
state := uuid.NewString()
if mode == "spa" && origin != "" {
state = state + "|mode=spa|origin=" + url.QueryEscape(origin)
}
var authURL string
if p.Issuer == "github" {
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Scopes: p.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
},
}
authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
} else {
// Google OIDC
ctx := context.Background()
prov, err := oidc.NewProvider(ctx, p.Issuer)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "oidc_discovery_failed", err.Error())
return
}
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Endpoint: prov.Endpoint(),
Scopes: p.Scopes,
}
authURL = o.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
utils.WriteJSON(w, http.StatusOK, dto.AuthStartResponse{AuthURL: authURL})
}
}
// AuthCallback godoc
//
// @ID AuthCallback
// @Summary Handle social login callback
// @Tags Auth
// @Param provider path string true "google|github"
// @Produce json
// @Success 200 {object} dto.TokenPair
// @Router /auth/{provider}/callback [get]
func AuthCallback(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
provider := strings.ToLower(chi.URLParam(r, "provider"))
p, ok := providerConfig(cfg, provider)
if !ok {
utils.WriteError(w, http.StatusBadRequest, "unsupported_provider", "provider not configured")
return
}
code := r.URL.Query().Get("code")
if code == "" {
utils.WriteError(w, http.StatusBadRequest, "invalid_request", "missing code")
return
}
redirect := cfg.OAuthRedirectBase + "/api/v1/auth/" + p.Name + "/callback"
var email, display, subject string
if p.Issuer == "github" {
// OAuth2 code exchange
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Scopes: p.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
},
}
tok, err := o.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, http.StatusUnauthorized, "exchange_failed", err.Error())
return
}
// Fetch user primary email
req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
req.Header.Set("Authorization", "token "+tok.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
utils.WriteError(w, http.StatusUnauthorized, "email_fetch_failed", "github user/emails")
return
}
defer resp.Body.Close()
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil || len(emails) == 0 {
utils.WriteError(w, http.StatusUnauthorized, "email_parse_failed", err.Error())
return
}
email = emails[0].Email
for _, e := range emails {
if e.Primary {
email = e.Email
break
}
}
subject = "github:" + email
display = strings.Split(email, "@")[0]
} else {
// Google OIDC
oidcProv, err := oidc.NewProvider(r.Context(), p.Issuer)
if err != nil {
utils.WriteError(w, 500, "oidc_discovery_failed", err.Error())
return
}
o := &oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.Secret,
RedirectURL: redirect,
Endpoint: oidcProv.Endpoint(),
Scopes: p.Scopes,
}
tok, err := o.Exchange(r.Context(), code)
if err != nil {
utils.WriteError(w, 401, "exchange_failed", err.Error())
return
}
verifier := oidcProv.Verifier(&oidc.Config{ClientID: p.ClientID})
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok {
utils.WriteError(w, 401, "no_id_token", "")
return
}
idt, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
utils.WriteError(w, 401, "id_token_invalid", err.Error())
return
}
var claims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Sub string `json:"sub"`
}
if err := idt.Claims(&claims); err != nil {
utils.WriteError(w, 401, "claims_parse_error", err.Error())
return
}
email = strings.ToLower(claims.Email)
display = claims.Name
subject = "google:" + claims.Sub
}
// Upsert Account + User; domain auto-join (member)
user, err := upsertAccountAndUser(db, p.Name, subject, email, display)
if err != nil {
utils.WriteError(w, 500, "account_upsert_failed", err.Error())
return
}
// Org auto-join: Organization.Domain == email domain
_ = ensureAutoMembership(db, user.ID, email)
// Issue tokens
accessTTL := 1 * time.Hour
refreshTTL := 30 * 24 * time.Hour
cfgLoaded, _ := config.Load()
access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: user.ID.String(),
Issuer: cfgLoaded.JWTIssuer,
Audience: cfgLoaded.JWTAudience,
TTL: accessTTL,
Claims: map[string]any{
"email": email,
"name": display,
},
})
if err != nil {
utils.WriteError(w, 500, "issue_access_failed", err.Error())
return
}
rp, err := auth.IssueRefreshToken(db, user.ID, refreshTTL, nil)
if err != nil {
utils.WriteError(w, 500, "issue_refresh_failed", err.Error())
return
}
secure := true
if u, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(u) {
secure = false
}
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
secure = strings.EqualFold(xf, "https")
}
http.SetCookie(w, &http.Cookie{
Name: "ag_jwt",
Value: "Bearer " + access,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
MaxAge: int((time.Hour * 8).Seconds()),
})
// If the state indicates SPA popup mode, postMessage tokens to the opener and close
state := r.URL.Query().Get("state")
if strings.Contains(state, "mode=spa") {
origin := canonicalOrigin(cfg.OAuthRedirectBase)
if origin == "" {
origin = cfg.OAuthRedirectBase
}
payload := dto.TokenPair{
AccessToken: access,
RefreshToken: rp.Plain,
TokenType: "Bearer",
ExpiresIn: int64(accessTTL.Seconds()),
}
writePostMessageHTML(w, origin, payload)
return
}
// Default JSON response
utils.WriteJSON(w, http.StatusOK, dto.TokenPair{
AccessToken: access,
RefreshToken: rp.Plain,
TokenType: "Bearer",
ExpiresIn: int64(accessTTL.Seconds()),
})
}
}
// Refresh godoc
//
// @ID Refresh
// @Summary Rotate refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body dto.RefreshRequest true "Refresh token"
// @Success 200 {object} dto.TokenPair
// @Router /auth/refresh [post]
func Refresh(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
var req dto.RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
if err != nil {
utils.WriteError(w, 401, "invalid_refresh", "")
return
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", rec.UserID).Error; err != nil {
utils.WriteError(w, 401, "user_disabled", "")
return
}
// rotate
newPair, err := auth.RotateRefreshToken(db, rec, 30*24*time.Hour)
if err != nil {
utils.WriteError(w, 500, "rotate_failed", err.Error())
return
}
// new access
access, err := auth.IssueAccessToken(auth.IssueOpts{
Subject: u.ID.String(),
Issuer: cfg.JWTIssuer,
Audience: cfg.JWTAudience,
TTL: 1 * time.Hour,
})
if err != nil {
utils.WriteError(w, 500, "issue_access_failed", err.Error())
return
}
secure := true
if uParsed, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(uParsed) {
secure = false
}
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
secure = strings.EqualFold(xf, "https")
}
http.SetCookie(w, &http.Cookie{
Name: "ag_jwt",
Value: "Bearer " + access,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
MaxAge: int((time.Hour * 8).Seconds()),
})
utils.WriteJSON(w, 200, dto.TokenPair{
AccessToken: access,
RefreshToken: newPair.Plain,
TokenType: "Bearer",
ExpiresIn: 3600,
})
}
}
// Logout godoc
//
// @ID Logout
// @Summary Revoke refresh token family (logout everywhere)
// @Tags Auth
// @Accept json
// @Produce json
// @Param body body dto.LogoutRequest true "Refresh token"
// @Success 204 "No Content"
// @Router /auth/logout [post]
func Logout(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cfg, _ := config.Load()
var req dto.LogoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
rec, err := auth.ValidateRefreshToken(db, req.RefreshToken)
if err != nil {
w.WriteHeader(204) // already invalid/revoked
goto clearCookie
}
if err := auth.RevokeFamily(db, rec.FamilyID); err != nil {
utils.WriteError(w, 500, "revoke_failed", err.Error())
return
}
clearCookie:
secure := true
if uParsed, err := url.Parse(cfg.OAuthRedirectBase); err == nil && isLocalDev(uParsed) {
secure = false
}
http.SetCookie(w, &http.Cookie{
Name: "ag_jwt",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
w.WriteHeader(204)
}
}
// Helpers
func upsertAccountAndUser(db *gorm.DB, provider, subject, email, display string) (*models.User, error) {
email = strings.ToLower(email)
var acc models.Account
if err := db.Where("provider = ? AND subject = ?", provider, subject).First(&acc).Error; err == nil {
var u models.User
if err := db.First(&u, "id = ?", acc.UserID).Error; err != nil {
return nil, err
}
return &u, nil
}
// Link by email if exists
var ue models.UserEmail
if err := db.Where("LOWER(email) = ?", email).First(&ue).Error; err == nil {
acc = models.Account{
UserID: ue.UserID,
Provider: provider,
Subject: subject,
Email: &email,
EmailVerified: true,
}
if err := db.Create(&acc).Error; err != nil {
return nil, err
}
var u models.User
if err := db.First(&u, "id = ?", ue.UserID).Error; err != nil {
return nil, err
}
return &u, nil
}
// Create user
u := models.User{DisplayName: &display, PrimaryEmail: &email}
if err := db.Create(&u).Error; err != nil {
return nil, err
}
ue = models.UserEmail{UserID: u.ID, Email: email, IsVerified: true, IsPrimary: true}
_ = db.Create(&ue).Error
acc = models.Account{UserID: u.ID, Provider: provider, Subject: subject, Email: &email, EmailVerified: true}
_ = db.Create(&acc).Error
return &u, nil
}
func ensureAutoMembership(db *gorm.DB, userID uuid.UUID, email string) error {
parts := strings.SplitN(strings.ToLower(email), "@", 2)
if len(parts) != 2 {
return nil
}
domain := parts[1]
var org models.Organization
if err := db.Where("LOWER(domain) = ?", domain).First(&org).Error; err != nil {
return nil
}
// if already member, done
var c int64
db.Model(&models.Membership{}).
Where("user_id = ? AND organization_id = ?", userID, org.ID).
Count(&c)
if c > 0 {
return nil
}
return db.Create(&models.Membership{
UserID: userID, OrganizationID: org.ID, Role: "member",
}).Error
}
// postMessage HTML template
var postMessageTpl = template.Must(template.New("postmsg").Parse(`
`))
type postMessageData struct {
Origin string
PayloadB64 string
}
// writePostMessageHTML sends a tiny HTML page that posts tokens to the SPA and closes the window.
func writePostMessageHTML(w http.ResponseWriter, origin string, payload dto.TokenPair) {
b, _ := json.Marshal(payload)
data := postMessageData{
Origin: origin,
PayloadB64: base64.StdEncoding.EncodeToString(b),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_ = postMessageTpl.Execute(w, data)
}
// canonicalOrigin returns scheme://host[:port] for a given URL, or "" if invalid.
func canonicalOrigin(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
// Normalize: no path/query/fragment β just the origin.
return (&url.URL{
Scheme: u.Scheme,
Host: u.Host,
}).String()
}
func isLocalDev(u *url.URL) bool {
host := strings.ToLower(u.Hostname())
return u.Scheme == "http" &&
(host == "localhost" || host == "127.0.0.1")
}
package handlers
import (
"errors"
"net/http"
"time"
"github.com/dyaksa/archer"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListClusterRuns godoc
//
// @ID ListClusterRuns
// @Summary List cluster runs (org scoped)
// @Description Returns runs for a cluster within the organization in X-Org-ID.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {array} dto.ClusterRunResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/runs [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListClusterRuns(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
// Ensure cluster exists + org scoped
if err := db.Select("id").
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&models.Cluster{}).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var rows []models.ClusterRun
if err := db.
Where("organization_id = ? AND cluster_id = ?", orgID, clusterID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ClusterRunResponse, 0, len(rows))
for _, cr := range rows {
out = append(out, clusterRunToDTO(cr))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetClusterRun godoc
//
// @ID GetClusterRun
// @Summary Get a cluster run (org scoped)
// @Description Returns a single run for a cluster within the organization in X-Org-ID.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param runID path string true "Run ID"
// @Success 200 {object} dto.ClusterRunResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/runs/{runID} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetClusterRun(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
runID, err := uuid.Parse(chi.URLParam(r, "runID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_run_id", "invalid run id")
return
}
var row models.ClusterRun
if err := db.
Where("id = ? AND organization_id = ? AND cluster_id = ?", runID, orgID, clusterID).
First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "run not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterRunToDTO(row))
}
}
// RunClusterAction godoc
//
// @ID RunClusterAction
// @Summary Run an admin-configured action on a cluster (org scoped)
// @Description Creates a ClusterRun record for the cluster/action. Execution is handled asynchronously by workers.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param actionID path string true "Action ID"
// @Success 201 {object} dto.ClusterRunResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or action not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/actions/{actionID}/runs [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func RunClusterAction(db *gorm.DB, jobs *bg.Jobs) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
// cluster must exist + org scoped
var cluster models.Cluster
if err := db.Select("id", "organization_id").
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// action is global/admin-configured (not org scoped)
var action models.Action
if err := db.Where("id = ?", actionID).First(&action).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "action_not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
run := models.ClusterRun{
OrganizationID: orgID,
ClusterID: clusterID,
Action: action.MakeTarget, // this is what you actually execute
Status: models.ClusterRunStatusQueued,
Error: "",
FinishedAt: time.Time{},
}
if err := db.Create(&run).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
args := bg.ClusterActionArgs{
OrgID: orgID,
ClusterID: clusterID,
Action: action.MakeTarget,
MakeTarget: action.MakeTarget,
}
// Enqueue with run.ID as the job ID so the worker can look it up.
_, enqueueErr := jobs.Enqueue(
r.Context(),
run.ID.String(),
"cluster_action",
args,
archer.WithMaxRetries(0),
)
if enqueueErr != nil {
_ = db.Model(&models.ClusterRun{}).
Where("id = ?", run.ID).
Updates(map[string]any{
"status": models.ClusterRunStatusFailed,
"error": "failed to enqueue job: " + enqueueErr.Error(),
"finished_at": time.Now().UTC(),
}).Error
utils.WriteError(w, http.StatusInternalServerError, "job_error", "failed to enqueue cluster action")
return
}
utils.WriteJSON(w, http.StatusCreated, clusterRunToDTO(run))
}
}
func clusterRunToDTO(cr models.ClusterRun) dto.ClusterRunResponse {
var finished *time.Time
if !cr.FinishedAt.IsZero() {
t := cr.FinishedAt
finished = &t
}
return dto.ClusterRunResponse{
ID: cr.ID,
OrganizationID: cr.OrganizationID,
ClusterID: cr.ClusterID,
Action: cr.Action,
Status: cr.Status,
Error: cr.Error,
CreatedAt: cr.CreatedAt,
UpdatedAt: cr.UpdatedAt,
FinishedAt: finished,
}
}
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListClusters godoc
//
// @ID ListClusters
// @Summary List clusters (org scoped)
// @Description Returns clusters for the organization in X-Org-ID. Filter by `q` (name contains).
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param q query string false "Name contains (case-insensitive)"
// @Success 200 {array} dto.ClusterResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list clusters"
// @Router /clusters [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListClusters(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where(`name ILIKE ?`, "%"+needle+"%")
}
var rows []models.Cluster
if err := q.
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ClusterResponse, 0, len(rows))
for _, row := range rows {
cr := clusterToDTO(row)
if row.EncryptedKubeconfig != "" && row.KubeIV != "" && row.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(orgID, row.EncryptedKubeconfig, row.KubeIV, row.KubeTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "kubeconfig_decrypt_failed", "failed to decrypt kubeconfig")
return
}
cr.Kubeconfig = &kubeconfig
}
out = append(out, cr)
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetCluster godoc
//
// @ID GetCluster
// @Summary Get a single cluster by ID (org scoped)
// @Description Returns a cluster with all related resources (domain, record set, load balancers, bastion, node pools).
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetCluster(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.
Where("id = ? AND organization_id = ?", clusterID, orgID).
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
resp := clusterToDTO(cluster)
if cluster.EncryptedKubeconfig != "" && cluster.KubeIV != "" && cluster.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(orgID, cluster.EncryptedKubeconfig, cluster.KubeIV, cluster.KubeTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "kubeconfig_decrypt_failed", "failed to decrypt kubeconfig")
return
}
resp.Kubeconfig = &kubeconfig
}
utils.WriteJSON(w, http.StatusOK, resp)
}
}
// CreateCluster godoc
//
// @ID CreateCluster
// @Summary Create cluster (org scoped)
// @Description Creates a cluster. Status is managed by the system and starts as `pre_pending` for validation.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateClusterRequest true "payload"
// @Success 201 {object} dto.ClusterResponse
// @Failure 400 {string} string "invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /clusters [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateCluster(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var in dto.CreateClusterRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
certificateKey, err := GenerateSecureHex(32)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to generate certificate key")
return
}
randomToken, err := GenerateFormattedToken()
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to generate random token")
return
}
c := models.Cluster{
OrganizationID: orgID,
Name: in.Name,
Provider: in.ClusterProvider,
Region: in.Region,
Status: models.ClusterStatusPrePending,
LastError: "",
CertificateKey: certificateKey,
RandomToken: randomToken,
DockerImage: in.DockerImage,
DockerTag: in.DockerTag,
}
if err := db.Create(&c).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusCreated, clusterToDTO(c))
}
}
// UpdateCluster godoc
//
// @ID UpdateCluster
// @Summary Update basic cluster details (org scoped)
// @Description Updates the cluster name, provider, and/or region. Status is managed by the system.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.UpdateClusterRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateCluster(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.UpdateClusterRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Apply only provided fields
if in.Name != nil {
cluster.Name = *in.Name
}
if in.ClusterProvider != nil {
cluster.Provider = *in.ClusterProvider
}
if in.Region != nil {
cluster.Region = *in.Region
}
if in.DockerImage != nil {
cluster.DockerImage = *in.DockerImage
}
if in.DockerTag != nil {
cluster.DockerTag = *in.DockerTag
}
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Any change to the cluster config may require re-validation.
_ = markClusterNeedsValidation(db, cluster.ID)
// Preload for a rich response
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DeleteCluster godoc
//
// @ID DeleteCluster
// @Summary Delete a cluster (org scoped)
// @Description Deletes the cluster. Related resources are cleaned up via DB constraints (e.g. CASCADE).
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 204 {string} string "deleted"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteCluster(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
tx := db.Where("id = ? AND organization_id = ?", clusterID, orgID).Delete(&models.Cluster{})
if tx.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if tx.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// AttachCaptainDomain godoc
//
// @ID AttachCaptainDomain
// @Summary Attach a captain domain to a cluster
// @Description Sets captain_domain_id on the cluster. Validation of shape happens asynchronously.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachCaptainDomainRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or domain not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/captain-domain [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachCaptainDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachCaptainDomainRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Ensure domain exists and belongs to the org
var domain models.Domain
if err := db.Where("id = ? AND organization_id = ?", in.DomainID, orgID).First(&domain).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "domain_not_found", "domain not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.CaptainDomainID = &domain.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := markClusterNeedsValidation(db, cluster.ID); err != nil {
// Don't fail the request, just log if you have logging.
}
// Preload domain for response
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachCaptainDomain godoc
//
// @ID DetachCaptainDomain
// @Summary Detach the captain domain from a cluster
// @Description Clears captain_domain_id on the cluster. This will likely cause the cluster to become incomplete.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/captain-domain [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachCaptainDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.CaptainDomainID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachControlPlaneRecordSet godoc
//
// @ID AttachControlPlaneRecordSet
// @Summary Attach a control plane record set to a cluster
// @Description Sets control_plane_record_set_id on the cluster.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachRecordSetRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or record set not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/control-plane-record-set [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachRecordSetRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// record sets are indirectly org-scoped via their domain
var rs models.RecordSet
if err := db.
Joins("JOIN domains d ON d.id = record_sets.domain_id").
Where("record_sets.id = ? AND d.organization_id = ?", in.RecordSetID, orgID).
First(&rs).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "recordset_not_found", "record set not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.ControlPlaneRecordSetID = &rs.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachControlPlaneRecordSet godoc
//
// @ID DetachControlPlaneRecordSet
// @Summary Detach the control plane record set from a cluster
// @Description Clears control_plane_record_set_id on the cluster.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/control-plane-record-set [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachControlPlaneRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.ControlPlaneRecordSetID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachAppsLoadBalancer godoc
//
// @ID AttachAppsLoadBalancer
// @Summary Attach an apps load balancer to a cluster
// @Description Sets apps_load_balancer_id on the cluster.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachLoadBalancerRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or load balancer not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/apps-load-balancer [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachLoadBalancerRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var lb models.LoadBalancer
if err := db.Where("id = ? AND organization_id = ?", in.LoadBalancerID, orgID).First(&lb).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "lb_not_found", "load balancer not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.AppsLoadBalancerID = &lb.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachAppsLoadBalancer godoc
//
// @ID DetachAppsLoadBalancer
// @Summary Detach the apps load balancer from a cluster
// @Description Clears apps_load_balancer_id on the cluster.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/apps-load-balancer [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachAppsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.AppsLoadBalancerID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachGlueOpsLoadBalancer godoc
//
// @ID AttachGlueOpsLoadBalancer
// @Summary Attach a GlueOps/control-plane load balancer to a cluster
// @Description Sets glueops_load_balancer_id on the cluster.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachLoadBalancerRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or load balancer not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/glueops-load-balancer [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachLoadBalancerRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var lb models.LoadBalancer
if err := db.Where("id = ? AND organization_id = ?", in.LoadBalancerID, orgID).First(&lb).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "lb_not_found", "load balancer not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.GlueOpsLoadBalancerID = &lb.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachGlueOpsLoadBalancer godoc
//
// @ID DetachGlueOpsLoadBalancer
// @Summary Detach the GlueOps/control-plane load balancer from a cluster
// @Description Clears glueops_load_balancer_id on the cluster.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/glueops-load-balancer [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachGlueOpsLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.GlueOpsLoadBalancerID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachBastionServer godoc
//
// @ID AttachBastionServer
// @Summary Attach a bastion server to a cluster
// @Description Sets bastion_server_id on the cluster.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachBastionRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or server not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/bastion [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachBastionServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachBastionRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var server models.Server
if err := db.Where("id = ? AND organization_id = ?", in.ServerID, orgID).First(&server).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.BastionServerID = &server.ID
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachBastionServer godoc
//
// @ID DetachBastionServer
// @Summary Detach the bastion server from a cluster
// @Description Clears bastion_server_id on the cluster.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/bastion [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachBastionServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.BastionServerID = nil
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// SetClusterKubeconfig godoc
//
// @ID SetClusterKubeconfig
// @Summary Set (or replace) the kubeconfig for a cluster
// @Description Stores the kubeconfig encrypted per organization. The kubeconfig is never returned in responses.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.SetKubeconfigRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/kubeconfig [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func SetClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.SetKubeconfigRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
ct, iv, tag, err := utils.EncryptForOrg(orgID, []byte(in.Kubeconfig), db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "encryption_error", "failed to encrypt kubeconfig")
return
}
cluster.EncryptedKubeconfig = ct
cluster.KubeIV = iv
cluster.KubeTag = tag
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// ClearClusterKubeconfig godoc
//
// @ID ClearClusterKubeconfig
// @Summary Clear the kubeconfig for a cluster
// @Description Removes the encrypted kubeconfig, IV, and tag from the cluster record.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/kubeconfig [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ClearClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var cluster models.Cluster
if err := db.Where("id = ? AND organization_id = ?", clusterID, orgID).First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
cluster.EncryptedKubeconfig = ""
cluster.KubeIV = ""
cluster.KubeTag = ""
if err := db.Save(&cluster).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// AttachNodePool godoc
//
// @ID AttachNodePool
// @Summary Attach a node pool to a cluster
// @Description Adds an entry in the cluster_node_pools join table.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachNodePoolRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or node pool not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/node-pools [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachNodePool(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
var in dto.AttachNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
// Load cluster (org scoped)
var cluster models.Cluster
if err := db.
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Load node pool (org scoped)
var np models.NodePool
if err := db.
Where("id = ? AND organization_id = ?", in.NodePoolID, orgID).
First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "nodepool_not_found", "node pool not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Create association in join table
if err := db.Model(&cluster).Association("NodePools").Append(&np); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to attach node pool")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
// Reload for rich response
if err := db.
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachNodePool godoc
//
// @ID DetachNodePool
// @Summary Detach a node pool from a cluster
// @Description Removes an entry from the cluster_node_pools join table.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param nodePoolID path string true "Node Pool ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or node pool not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/node-pools/{nodePoolID} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachNodePool(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
nodePoolID, err := uuid.Parse(chi.URLParam(r, "nodePoolID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_nodepool_id", "invalid node pool id")
return
}
var cluster models.Cluster
if err := db.
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var np models.NodePool
if err := db.
Where("id = ? AND organization_id = ?", nodePoolID, orgID).
First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "nodepool_not_found", "node pool not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Model(&cluster).Association("NodePools").Delete(&np); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to detach node pool")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// -- Helpers
func clusterToDTO(c models.Cluster) dto.ClusterResponse {
var bastion *dto.ServerResponse
if c.BastionServer != nil {
b := serverToDTO(*c.BastionServer)
bastion = &b
}
var captainDomain *dto.DomainResponse
if c.CaptainDomainID != nil && c.CaptainDomain.ID != uuid.Nil {
dr := domainToDTO(c.CaptainDomain)
captainDomain = &dr
}
var controlPlane *dto.RecordSetResponse
if c.ControlPlaneRecordSet != nil {
rr := recordSetToDTO(*c.ControlPlaneRecordSet)
controlPlane = &rr
}
var cfqdn *string
if captainDomain != nil && controlPlane != nil {
fq := fmt.Sprintf("%s.%s", controlPlane.Name, captainDomain.DomainName)
cfqdn = &fq
}
var appsLB *dto.LoadBalancerResponse
if c.AppsLoadBalancer != nil {
lr := loadBalancerToDTO(*c.AppsLoadBalancer)
appsLB = &lr
}
var glueOpsLB *dto.LoadBalancerResponse
if c.GlueOpsLoadBalancer != nil {
lr := loadBalancerToDTO(*c.GlueOpsLoadBalancer)
glueOpsLB = &lr
}
nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
for _, np := range c.NodePools {
nps = append(nps, nodePoolToDTO(np))
}
return dto.ClusterResponse{
ID: c.ID,
Name: c.Name,
CaptainDomain: captainDomain,
ControlPlaneRecordSet: controlPlane,
ControlPlaneFQDN: cfqdn,
AppsLoadBalancer: appsLB,
GlueOpsLoadBalancer: glueOpsLB,
BastionServer: bastion,
Provider: c.Provider,
Region: c.Region,
Status: c.Status,
LastError: c.LastError,
RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey,
NodePools: nps,
DockerImage: c.DockerImage,
DockerTag: c.DockerTag,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
func nodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
labels := make([]dto.LabelResponse, 0, len(np.Labels))
for _, l := range np.Labels {
labels = append(labels, dto.LabelResponse{
Key: l.Key,
Value: l.Value,
})
}
annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
for _, a := range np.Annotations {
annotations = append(annotations, dto.AnnotationResponse{
Key: a.Key,
Value: a.Value,
})
}
taints := make([]dto.TaintResponse, 0, len(np.Taints))
for _, t := range np.Taints {
taints = append(taints, dto.TaintResponse{
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
})
}
servers := make([]dto.ServerResponse, 0, len(np.Servers))
for _, s := range np.Servers {
servers = append(servers, serverToDTO(s))
}
return dto.NodePoolResponse{
AuditFields: common.AuditFields{
ID: np.ID,
OrganizationID: np.OrganizationID,
CreatedAt: np.CreatedAt,
UpdatedAt: np.UpdatedAt,
},
Name: np.Name,
Role: dto.NodeRole(np.Role),
Labels: labels,
Annotations: annotations,
Taints: taints,
Servers: servers,
}
}
func serverToDTO(s models.Server) dto.ServerResponse {
return dto.ServerResponse{
ID: s.ID,
Hostname: s.Hostname,
PrivateIPAddress: s.PrivateIPAddress,
PublicIPAddress: s.PublicIPAddress,
Role: s.Role,
Status: s.Status,
SSHUser: s.SSHUser,
SshKeyID: s.SshKeyID,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func domainToDTO(d models.Domain) dto.DomainResponse {
return dto.DomainResponse{
ID: d.ID.String(),
OrganizationID: d.OrganizationID.String(),
DomainName: d.DomainName,
ZoneID: d.ZoneID,
Status: d.Status,
LastError: d.LastError,
CredentialID: d.CredentialID.String(),
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func recordSetToDTO(rs models.RecordSet) dto.RecordSetResponse {
return dto.RecordSetResponse{
ID: rs.ID.String(),
DomainID: rs.DomainID.String(),
Name: rs.Name,
Type: rs.Type,
TTL: rs.TTL,
Values: []byte(rs.Values),
Fingerprint: rs.Fingerprint,
Status: rs.Status,
Owner: rs.Owner,
LastError: rs.LastError,
CreatedAt: rs.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: rs.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func loadBalancerToDTO(lb models.LoadBalancer) dto.LoadBalancerResponse {
return dto.LoadBalancerResponse{
ID: lb.ID,
OrganizationID: lb.OrganizationID,
Name: lb.Name,
Kind: lb.Kind,
PublicIPAddress: lb.PublicIPAddress,
PrivateIPAddress: lb.PrivateIPAddress,
CreatedAt: lb.CreatedAt,
UpdatedAt: lb.UpdatedAt,
}
}
func GenerateSecureHex(n int) (string, error) {
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return hex.EncodeToString(bytes), nil
}
func GenerateFormattedToken() (string, error) {
part1, err := GenerateSecureHex(3)
if err != nil {
return "", fmt.Errorf("failed to generate token part 1: %w", err)
}
part2, err := GenerateSecureHex(8)
if err != nil {
return "", fmt.Errorf("failed to generate token part 2: %w", err)
}
return fmt.Sprintf("%s.%s", part1, part2), nil
}
func markClusterNeedsValidation(db *gorm.DB, clusterID uuid.UUID) error {
return db.Model(&models.Cluster{}).Where("id = ?", clusterID).Updates(map[string]any{
"status": models.ClusterStatusPrePending,
"last_error": "",
}).Error
}
package handlers
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// ListCredentials godoc
//
// @ID ListCredentials
// @Summary List credentials (metadata only)
// @Description Returns credential metadata for the current org. Secrets are never returned.
// @Tags Credentials
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param credential_provider query string false "Filter by provider (e.g., aws)"
// @Param kind query string false "Filter by kind (e.g., aws_access_key)"
// @Param scope_kind query string false "Filter by scope kind (credential_provider/service/resource)"
// @Success 200 {array} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListCredentials(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if v := r.URL.Query().Get("credential_provider"); v != "" {
q = q.Where("provider = ?", v)
}
if v := r.URL.Query().Get("kind"); v != "" {
q = q.Where("kind = ?", v)
}
if v := r.URL.Query().Get("scope_kind"); v != "" {
q = q.Where("scope_kind = ?", v)
}
var rows []models.Credential
if err := q.Order("updated_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
out := make([]dto.CredentialOut, 0, len(rows))
for i := range rows {
out = append(out, credOut(&rows[i]))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetCredential godoc
//
// @ID GetCredential
// @Summary Get credential by ID (metadata only)
// @Tags Credentials
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 200 {object} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
idStr := chi.URLParam(r, "id")
id, err := uuid.Parse(idStr)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, credOut(&row))
}
}
// CreateCredential godoc
//
// @ID CreateCredential
// @Summary Create a credential (encrypts secret)
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param body body dto.CreateCredentialRequest true "Credential payload"
// @Success 201 {object} dto.CredentialOut
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "internal server error"
// @Router /credentials [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var in dto.CreateCredentialRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.Validate.Struct(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
cred, err := SaveCredentialWithScope(
r.Context(), db, orgID,
in.CredentialProvider, in.Kind, in.SchemaVersion,
in.ScopeKind, in.ScopeVersion, json.RawMessage(in.Scope), json.RawMessage(in.Secret),
in.Name, in.AccountID, in.Region,
)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "save_failed", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, credOut(cred))
}
}
// UpdateCredential godoc
//
// @ID UpdateCredential
// @Summary Update credential metadata and/or rotate secret
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Param body body dto.UpdateCredentialRequest true "Fields to update"
// @Success 200 {object} dto.CredentialOut
// @Failure 403 {string} string "X-Org-ID required"
// @Failure 404 {string} string "not found"
// @Router /credentials/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateCredentialRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
// Update metadata
if in.Name != nil {
row.Name = *in.Name
}
if in.AccountID != nil {
row.AccountID = *in.AccountID
}
if in.Region != nil {
row.Region = *in.Region
}
// Update scope (re-validate + fingerprint)
if in.ScopeKind != nil || in.Scope != nil || in.ScopeVersion != nil {
newKind := row.ScopeKind
if in.ScopeKind != nil {
newKind = *in.ScopeKind
}
newVersion := row.ScopeVersion
if in.ScopeVersion != nil {
newVersion = *in.ScopeVersion
}
if in.Scope == nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "scope must be provided when changing scope kind/version")
return
}
prScopes := dto.ScopeRegistry[row.Provider]
kScopes := prScopes[newKind]
sdef := kScopes[newVersion]
dst := sdef.New()
if err := json.Unmarshal(*in.Scope, dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_scope_json", err.Error())
return
}
if err := sdef.Validate(dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_scope", err.Error())
return
}
canonScope, err := canonicalJSON(dst)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "canon_error", err.Error())
return
}
row.Scope = canonScope
row.ScopeKind = newKind
row.ScopeVersion = newVersion
row.ScopeFingerprint = sha256Hex(canonScope)
}
// Rotate secret
if in.Secret != nil {
// validate against current Provider/Kind/SchemaVersion
def := dto.CredentialRegistry[row.Provider][row.Kind][row.SchemaVersion]
dst := def.New()
if err := json.Unmarshal(*in.Secret, dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_secret_json", err.Error())
return
}
if err := def.Validate(dst); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_secret", err.Error())
return
}
canonSecret, err := canonicalJSON(dst)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "canon_error", err.Error())
return
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, canonSecret, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "encrypt_error", err.Error())
return
}
row.EncryptedData = cipher
row.IV = iv
row.Tag = tag
}
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, credOut(&row))
}
}
// DeleteCredential godoc
//
// @ID DeleteCredential
// @Summary Delete credential
// @Tags Credentials
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 204
// @Failure 404 {string} string "not found"
// @Router /credentials/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
res := db.Where("organization_id = ? AND id = ?", orgID, id).Delete(&models.Credential{})
if res.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// RevealCredential godoc
//
// @ID RevealCredential
// @Summary Reveal decrypted secret (one-time read)
// @Tags Credentials
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization ID (UUID)"
// @Param id path string true "Credential ID (UUID)"
// @Success 200 {object} map[string]any
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /credentials/{id}/reveal [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func RevealCredential(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Credential
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "credential not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
plain, err := utils.DecryptForOrg(orgID, row.EncryptedData, row.IV, row.Tag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "decrypt_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, plain)
}
}
// -- Helpers
func canonicalJSON(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var m any
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
return marshalSorted(m)
}
func marshalSorted(v any) ([]byte, error) {
switch vv := v.(type) {
case map[string]any:
keys := make([]string, 0, len(vv))
for k := range vv {
keys = append(keys, k)
}
sort.Strings(keys)
buf := bytes.NewBufferString("{")
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
kb, _ := json.Marshal(k)
buf.Write(kb)
buf.WriteByte(':')
b, err := marshalSorted(vv[k])
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte('}')
return buf.Bytes(), nil
case []any:
buf := bytes.NewBufferString("[")
for i, e := range vv {
if i > 0 {
buf.WriteByte(',')
}
b, err := marshalSorted(e)
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte(']')
return buf.Bytes(), nil
default:
return json.Marshal(v)
}
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// SaveCredentialWithScope validates secret+scope, encrypts, fingerprints, and stores.
func SaveCredentialWithScope(
ctx context.Context,
db *gorm.DB,
orgID uuid.UUID,
provider, kind string,
schemaVersion int,
scopeKind string,
scopeVersion int,
rawScope json.RawMessage,
rawSecret json.RawMessage,
name, accountID, region string,
) (*models.Credential, error) {
// 1) secret shape
pv, ok := dto.CredentialRegistry[provider]
if !ok {
return nil, fmt.Errorf("unknown provider %q", provider)
}
kv, ok := pv[kind]
if !ok {
return nil, fmt.Errorf("unknown kind %q for provider %q", kind, provider)
}
def, ok := kv[schemaVersion]
if !ok {
return nil, fmt.Errorf("unsupported schema version %d for %s/%s", schemaVersion, provider, kind)
}
secretDst := def.New()
if err := json.Unmarshal(rawSecret, secretDst); err != nil {
return nil, fmt.Errorf("payload is not valid JSON for %s/%s: %w", provider, kind, err)
}
if err := def.Validate(secretDst); err != nil {
return nil, fmt.Errorf("invalid %s/%s: %w", provider, kind, err)
}
// 2) scope shape
prScopes, ok := dto.ScopeRegistry[provider]
if !ok {
return nil, fmt.Errorf("no scopes registered for provider %q", provider)
}
kScopes, ok := prScopes[scopeKind]
if !ok {
return nil, fmt.Errorf("invalid scope_kind %q for provider %q", scopeKind, provider)
}
sdef, ok := kScopes[scopeVersion]
if !ok {
return nil, fmt.Errorf("unsupported scope version %d for %s/%s", scopeVersion, provider, scopeKind)
}
scopeDst := sdef.New()
if err := json.Unmarshal(rawScope, scopeDst); err != nil {
return nil, fmt.Errorf("invalid scope JSON: %w", err)
}
if err := sdef.Validate(scopeDst); err != nil {
return nil, fmt.Errorf("invalid scope: %w", err)
}
// 3) canonicalize scope (also what we persist in plaintext)
canonScope, err := canonicalJSON(scopeDst)
if err != nil {
return nil, err
}
fp := sha256Hex(canonScope) // or HMAC if you have a server-side key
// 4) canonicalize + encrypt secret
canonSecret, err := canonicalJSON(secretDst)
if err != nil {
return nil, err
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, canonSecret, db)
if err != nil {
return nil, fmt.Errorf("encrypt: %w", err)
}
cred := &models.Credential{
OrganizationID: orgID,
Provider: provider,
Kind: kind,
SchemaVersion: schemaVersion,
Name: name,
ScopeKind: scopeKind,
Scope: datatypes.JSON(canonScope),
ScopeVersion: scopeVersion,
AccountID: accountID,
Region: region,
ScopeFingerprint: fp,
EncryptedData: cipher,
IV: iv,
Tag: tag,
}
if err := db.WithContext(ctx).Create(cred).Error; err != nil {
return nil, err
}
return cred, nil
}
// credOut converts model β response DTO
func credOut(c *models.Credential) dto.CredentialOut {
return dto.CredentialOut{
ID: c.ID.String(),
CredentialProvider: c.Provider,
Kind: c.Kind,
SchemaVersion: c.SchemaVersion,
Name: c.Name,
ScopeKind: c.ScopeKind,
ScopeVersion: c.ScopeVersion,
Scope: dto.RawJSON(c.Scope),
AccountID: c.AccountID,
Region: c.Region,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339),
}
}
package handlers
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// ---------- Helpers ----------
func normLowerNoDot(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
return strings.TrimSuffix(s, ".")
}
func fqdn(domain string, rel string) string {
d := normLowerNoDot(domain)
r := normLowerNoDot(rel)
if r == "" || r == "@" {
return d
}
return r + "." + d
}
func canonicalJSONAny(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var anyv any
if err := json.Unmarshal(b, &anyv); err != nil {
return nil, err
}
return marshalSortedDNS(anyv)
}
func marshalSortedDNS(v any) ([]byte, error) {
switch vv := v.(type) {
case map[string]any:
keys := make([]string, 0, len(vv))
for k := range vv {
keys = append(keys, k)
}
sortStrings(keys)
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
kb, _ := json.Marshal(k)
buf.Write(kb)
buf.WriteByte(':')
b, err := marshalSortedDNS(vv[k])
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte('}')
return buf.Bytes(), nil
case []any:
var buf bytes.Buffer
buf.WriteByte('[')
for i, e := range vv {
if i > 0 {
buf.WriteByte(',')
}
b, err := marshalSortedDNS(e)
if err != nil {
return nil, err
}
buf.Write(b)
}
buf.WriteByte(']')
return buf.Bytes(), nil
default:
return json.Marshal(v)
}
}
func sortStrings(a []string) {
for i := 0; i < len(a); i++ {
for j := i + 1; j < len(a); j++ {
if a[j] < a[i] {
a[i], a[j] = a[j], a[i]
}
}
}
}
func sha256HexBytes(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
/* Fingerprint (provider-agnostic) */
type desiredRecord struct {
ZoneID string `json:"zone_id"`
FQDN string `json:"fqdn"`
Type string `json:"type"`
TTL *int `json:"ttl,omitempty"`
Values []string `json:"values,omitempty"`
}
func computeFingerprint(zoneID, fqdn, typ string, ttl *int, values datatypes.JSON) (string, error) {
var vals []string
if len(values) > 0 && string(values) != "null" {
if err := json.Unmarshal(values, &vals); err != nil {
return "", err
}
sortStrings(vals)
}
payload := &desiredRecord{
ZoneID: zoneID, FQDN: fqdn, Type: strings.ToUpper(typ), TTL: ttl, Values: vals,
}
can, err := canonicalJSONAny(payload)
if err != nil {
return "", err
}
return sha256HexBytes(can), nil
}
func mustSameOrgDomainWithCredential(db *gorm.DB, orgID uuid.UUID, credID uuid.UUID) error {
var cred models.Credential
if err := db.Where("id = ? AND organization_id = ?", credID, orgID).First(&cred).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("credential not found or belongs to different org")
}
return err
}
if cred.Provider != "aws" || cred.ScopeKind != "service" {
return fmt.Errorf("credential must be AWS Route 53 service scoped")
}
var scope map[string]any
if err := json.Unmarshal(cred.Scope, &scope); err != nil {
return fmt.Errorf("credential scope invalid json: %w", err)
}
if strings.ToLower(fmt.Sprint(scope["service"])) != "route53" {
return fmt.Errorf("credential scope.service must be route53")
}
return nil
}
// ---------- Domain Handlers ----------
// ListDomains godoc
//
// @ID ListDomains
// @Summary List domains (org scoped)
// @Description Returns domains for X-Org-ID. Filters: `domain_name`, `status`, `q` (contains).
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param domain_name query string false "Exact domain name (lowercase, no trailing dot)"
// @Param status query string false "pending|provisioning|ready|failed"
// @Param q query string false "Domain contains (case-insensitive)"
// @Success 200 {array} dto.DomainResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "db error"
// @Router /dns/domains [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListDomains(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Model(&models.Domain{}).Where("organization_id = ?", orgID)
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("domain_name"))); v != "" {
q = q.Where("LOWER(domain_name) = ?", v)
}
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); v != "" {
q = q.Where("status = ?", v)
}
if needle := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))); needle != "" {
q = q.Where("LOWER(domain_name) LIKE ?", "%"+needle+"%")
}
var rows []models.Domain
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.DomainResponse, 0, len(rows))
for i := range rows {
out = append(out, domainOut(&rows[i]))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetDomain godoc
//
// @ID GetDomain
// @Summary Get a domain (org scoped)
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Domain ID (UUID)"
// @Success 200 {object} dto.DomainResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/domains/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Domain
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, domainOut(&row))
}
}
// CreateDomain godoc
//
// @ID CreateDomain
// @Summary Create a domain (org scoped)
// @Description Creates a domain bound to a Route 53 scoped credential. Archer will backfill ZoneID if omitted.
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateDomainRequest true "Domain payload"
// @Success 201 {object} dto.DomainResponse
// @Failure 400 {string} string "validation error"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "db error"
// @Router /dns/domains [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var in dto.CreateDomainRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.DNSValidate(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
credID, _ := uuid.Parse(in.CredentialID)
if err := mustSameOrgDomainWithCredential(db, orgID, credID); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_credential", err.Error())
return
}
row := &models.Domain{
OrganizationID: orgID,
DomainName: normLowerNoDot(in.DomainName),
ZoneID: strings.TrimSpace(in.ZoneID),
Status: "pending",
LastError: "",
CredentialID: credID,
}
if err := db.Create(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, domainOut(row))
}
}
// UpdateDomain godoc
//
// @ID UpdateDomain
// @Summary Update a domain (org scoped)
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Domain ID (UUID)"
// @Param body body dto.UpdateDomainRequest true "Fields to update"
// @Success 200 {object} dto.DomainResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/domains/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.Domain
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateDomainRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.DNSValidate(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
if in.DomainName != nil {
row.DomainName = normLowerNoDot(*in.DomainName)
}
if in.CredentialID != nil {
credID, _ := uuid.Parse(*in.CredentialID)
if err := mustSameOrgDomainWithCredential(db, orgID, credID); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_credential", err.Error())
return
}
row.CredentialID = credID
row.Status = "pending"
row.LastError = ""
}
if in.ZoneID != nil {
row.ZoneID = strings.TrimSpace(*in.ZoneID)
}
if in.Status != nil {
row.Status = *in.Status
if row.Status == "pending" {
row.LastError = ""
}
}
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, domainOut(&row))
}
}
// DeleteDomain godoc
//
// @ID DeleteDomain
// @Summary Delete a domain
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Domain ID (UUID)"
// @Success 204
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/domains/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteDomain(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
res := db.Where("organization_id = ? AND id = ?", orgID, id).Delete(&models.Domain{})
if res.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// ---------- Record Set Handlers ----------
// ListRecordSets godoc
//
// @ID ListRecordSets
// @Summary List record sets for a domain
// @Description Filters: `name`, `type`, `status`.
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param domain_id path string true "Domain ID (UUID)"
// @Param name query string false "Exact relative name or FQDN (server normalizes)"
// @Param type query string false "RR type (A, AAAA, CNAME, TXT, MX, NS, SRV, CAA)"
// @Param status query string false "pending|provisioning|ready|failed"
// @Success 200 {array} dto.RecordSetResponse
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "domain not found"
// @Router /dns/domains/{domain_id}/records [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListRecordSets(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
did, err := uuid.Parse(chi.URLParam(r, "domain_id"))
if err != nil {
log.Info().Msg(err.Error())
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid domain UUID:")
return
}
var domain models.Domain
if err := db.Where("organization_id = ? AND id = ?", orgID, did).First(&domain).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
q := db.Model(&models.RecordSet{}).Where("domain_id = ?", did)
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("name"))); v != "" {
dn := strings.ToLower(domain.DomainName)
rel := v
// normalize apex or FQDN into relative
if v == dn || v == dn+"." {
rel = ""
} else {
rel = strings.TrimSuffix(v, "."+dn)
rel = normLowerNoDot(rel)
}
q = q.Where("LOWER(name) = ?", rel)
}
if v := strings.TrimSpace(strings.ToUpper(r.URL.Query().Get("type"))); v != "" {
q = q.Where("type = ?", v)
}
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); v != "" {
q = q.Where("status = ?", v)
}
var rows []models.RecordSet
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.RecordSetResponse, 0, len(rows))
for i := range rows {
out = append(out, recordOut(&rows[i]))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetRecordSet godoc
//
// @ID GetRecordSet
// @Summary Get a record set (org scoped)
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Record Set ID (UUID)"
// @Success 200 {object} dto.RecordSetResponse
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/records/{id} [get]
func GetRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.RecordSet
if err := db.
Joins("Domain").
Where(`record_sets.id = ? AND "Domain"."organization_id" = ?`, id, orgID).
First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, recordOut(&row))
}
}
// CreateRecordSet godoc
//
// @ID CreateRecordSet
// @Summary Create a record set (pending; Archer will UPSERT to Route 53)
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param domain_id path string true "Domain ID (UUID)"
// @Param body body dto.CreateRecordSetRequest true "Record set payload"
// @Success 201 {object} dto.RecordSetResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "domain not found"
// @Router /dns/domains/{domain_id}/records [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
did, err := uuid.Parse(chi.URLParam(r, "domain_id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid domain UUID")
return
}
var domain models.Domain
if err := db.Where("organization_id = ? AND id = ?", orgID, did).First(&domain).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.CreateRecordSetRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.DNSValidate(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
t := strings.ToUpper(in.Type)
if t == "CNAME" && len(in.Values) != 1 {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "CNAME requires exactly one value")
return
}
rel := normLowerNoDot(in.Name)
fq := fqdn(domain.DomainName, rel)
// Pre-flight: block duplicate tuple and protect from non-autoglue rows
var existing models.RecordSet
if err := db.Where("domain_id = ? AND LOWER(name) = ? AND type = ?",
domain.ID, strings.ToLower(rel), t).First(&existing).Error; err == nil {
if existing.Owner != "" && existing.Owner != "autoglue" {
utils.WriteError(w, http.StatusConflict, "ownership_conflict",
"record with the same (name,type) exists but is not owned by autoglue")
return
}
utils.WriteError(w, http.StatusConflict, "already_exists",
"a record with the same (name,type) already exists; use PATCH to modify")
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
valuesJSON, _ := json.Marshal(in.Values)
fp, err := computeFingerprint(domain.ZoneID, fq, t, in.TTL, datatypes.JSON(valuesJSON))
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "fingerprint_error", err.Error())
return
}
row := &models.RecordSet{
DomainID: domain.ID,
Name: rel,
Type: t,
TTL: in.TTL,
Values: datatypes.JSON(valuesJSON),
Fingerprint: fp,
Status: "pending",
LastError: "",
Owner: "autoglue",
}
if err := db.Create(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, recordOut(row))
}
}
// UpdateRecordSet godoc
//
// @ID UpdateRecordSet
// @Summary Update a record set (flips to pending for reconciliation)
// @Tags DNS
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Record Set ID (UUID)"
// @Param body body dto.UpdateRecordSetRequest true "Fields to update"
// @Success 200 {object} dto.RecordSetResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/records/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.RecordSet
if err := db.
Joins("Domain").
Where(`record_sets.id = ? AND "Domain"."organization_id" = ?`, id, orgID).
First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var domain models.Domain
if err := db.Where("id = ?", row.DomainID).First(&domain).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateRecordSetRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if err := dto.DNSValidate(in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
if row.Owner != "" && row.Owner != "autoglue" {
utils.WriteError(w, http.StatusConflict, "ownership_conflict",
"record is not owned by autoglue; refuse to modify")
return
}
// Mutations
if in.Name != nil {
row.Name = normLowerNoDot(*in.Name)
}
if in.Type != nil {
row.Type = strings.ToUpper(*in.Type)
}
if in.TTL != nil {
row.TTL = in.TTL
}
if in.Values != nil {
t := row.Type
if in.Type != nil {
t = strings.ToUpper(*in.Type)
}
if t == "CNAME" && len(*in.Values) != 1 {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "CNAME requires exactly one value")
return
}
b, _ := json.Marshal(*in.Values)
row.Values = datatypes.JSON(b)
}
if in.Status != nil {
row.Status = *in.Status
} else {
row.Status = "pending"
row.LastError = ""
}
fq := fqdn(domain.DomainName, row.Name)
fp, err := computeFingerprint(domain.ZoneID, fq, row.Type, row.TTL, row.Values)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "fingerprint_error", err.Error())
return
}
row.Fingerprint = fp
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, recordOut(&row))
}
}
// DeleteRecordSet godoc
//
// @ID DeleteRecordSet
// @Summary Delete a record set (API removes row; worker can optionally handle external deletion policy)
// @Tags DNS
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Record Set ID (UUID)"
// @Success 204
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /dns/records/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteRecordSet(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
sub := db.Model(&models.RecordSet{}).
Select("record_sets.id").
Joins("JOIN domains ON domains.id = record_sets.domain_id").
Where("record_sets.id = ? AND domains.organization_id = ?", id, orgID)
res := db.Where("id IN (?)", sub).Delete(&models.RecordSet{})
if res.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// ---------- Out mappers ----------
func domainOut(m *models.Domain) dto.DomainResponse {
return dto.DomainResponse{
ID: m.ID.String(),
OrganizationID: m.OrganizationID.String(),
DomainName: m.DomainName,
ZoneID: m.ZoneID,
Status: m.Status,
LastError: m.LastError,
CredentialID: m.CredentialID.String(),
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func recordOut(r *models.RecordSet) dto.RecordSetResponse {
vals := r.Values
if len(vals) == 0 {
vals = datatypes.JSON("[]")
}
return dto.RecordSetResponse{
ID: r.ID.String(),
DomainID: r.DomainID.String(),
Name: r.Name,
Type: r.Type,
TTL: r.TTL,
Values: []byte(vals),
Fingerprint: r.Fingerprint,
Status: r.Status,
LastError: r.LastError,
Owner: r.Owner,
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
}
}
package handlers
import (
"net/http"
"github.com/glueops/autoglue/internal/utils"
)
type HealthStatus struct {
Status string `json:"status" example:"ok"`
}
// HealthCheck godoc
//
// @Summary Basic health check
// @Description Returns 200 OK when the service is up
// @Tags Health
// @ID HealthCheck // operationId
// @Produce json
// @Success 200 {object} HealthStatus
// @Router /healthz [get]
func HealthCheck(w http.ResponseWriter, r *http.Request) {
utils.WriteJSON(w, http.StatusOK, HealthStatus{Status: "ok"})
}
package handlers
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/dyaksa/archer"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// AdminListArcherJobs godoc
//
// @ID AdminListArcherJobs
// @Summary List Archer jobs (admin)
// @Description Paginated background jobs with optional filters. Search `q` may match id, type, error, payload (implementation-dependent).
// @Tags ArcherAdmin
// @Produce json
// @Param status query string false "Filter by status" Enums(queued,running,succeeded,failed,canceled,retrying,scheduled)
// @Param queue query string false "Filter by queue name / worker name"
// @Param q query string false "Free-text search"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Items per page" minimum(1) maximum(100) default(25)
// @Success 200 {object} dto.PageJob
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 500 {string} string "internal error"
// @Router /admin/archer/jobs [get]
// @Security BearerAuth
func AdminListArcherJobs(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
status := strings.TrimSpace(r.URL.Query().Get("status"))
queue := strings.TrimSpace(r.URL.Query().Get("queue"))
q := strings.TrimSpace(r.URL.Query().Get("q"))
page := atoiDefault(r.URL.Query().Get("page"), 1)
size := clamp(atoiDefault(r.URL.Query().Get("page_size"), 25), 1, 100)
base := db.Model(&models.Job{})
if status != "" {
base = base.Where("status = ?", status)
}
if queue != "" {
base = base.Where("queue_name = ?", queue)
}
if q != "" {
like := "%" + q + "%"
base = base.Where(
db.Where("id ILIKE ?", like).
Or("queue_name ILIKE ?", like).
Or("COALESCE(last_error,'') ILIKE ?", like).
Or("CAST(arguments AS TEXT) ILIKE ?", like),
)
}
var total int64
if err := base.Count(&total).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var rows []models.Job
offset := (page - 1) * size
if err := base.Order("created_at DESC").Limit(size).Offset(offset).Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
items := make([]dto.Job, 0, len(rows))
for _, m := range rows {
items = append(items, mapModelJobToDTO(m))
}
utils.WriteJSON(w, http.StatusOK, dto.PageJob{
Items: items,
Total: int(total),
Page: page,
PageSize: size,
})
}
}
// AdminEnqueueArcherJob godoc
//
// @ID AdminEnqueueArcherJob
// @Summary Enqueue a new Archer job (admin)
// @Description Create a job immediately or schedule it for the future via `run_at`.
// @Tags ArcherAdmin
// @Accept json
// @Produce json
// @Param body body dto.EnqueueRequest true "Job parameters"
// @Success 200 {object} dto.Job
// @Failure 400 {string} string "invalid json or missing fields"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 500 {string} string "internal error"
// @Router /admin/archer/jobs [post]
// @Security BearerAuth
func AdminEnqueueArcherJob(db *gorm.DB, jobs *bg.Jobs) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in dto.EnqueueRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid json")
return
}
in.Queue = strings.TrimSpace(in.Queue)
in.Type = strings.TrimSpace(in.Type)
if in.Queue == "" || in.Type == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "queue and type are required")
return
}
// Parse payload into generic 'args' for Archer.
var args any
if len(in.Payload) > 0 && string(in.Payload) != "null" {
if err := json.Unmarshal(in.Payload, &args); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "payload must be valid JSON")
return
}
}
id := uuid.NewString()
opts := []archer.FnOptions{
archer.WithMaxRetries(0), // adjust or expose in request if needed
}
if in.RunAt != nil {
opts = append(opts, archer.WithScheduleTime(*in.RunAt))
}
// Schedule with Archer (queue == worker name).
if _, err := jobs.Enqueue(context.Background(), id, in.Queue, args, opts...); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "enqueue_failed", err.Error())
return
}
// Read back the just-created row.
var m models.Job
if err := db.First(&m, "id = ?", id).Error; err != nil {
// Fallback: return a synthesized job if row not visible yet.
now := time.Now()
out := dto.Job{
ID: id,
Type: in.Type,
Queue: in.Queue,
Status: dto.StatusQueued,
Attempts: 0,
MaxAttempts: 0,
CreatedAt: now,
UpdatedAt: &now,
RunAt: in.RunAt,
Payload: args,
}
utils.WriteJSON(w, http.StatusOK, out)
return
}
utils.WriteJSON(w, http.StatusOK, mapModelJobToDTO(m))
}
}
// AdminRetryArcherJob godoc
//
// @ID AdminRetryArcherJob
// @Summary Retry a failed/canceled Archer job (admin)
// @Description Marks the job retriable (DB flip). Swap this for an Archer admin call if you expose one.
// @Tags ArcherAdmin
// @Accept json
// @Produce json
// @Param id path string true "Job ID"
// @Success 200 {object} dto.Job
// @Failure 400 {string} string "invalid job or not eligible"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 404 {string} string "not found"
// @Router /admin/archer/jobs/{id}/retry [post]
// @Security BearerAuth
func AdminRetryArcherJob(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var m models.Job
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
utils.WriteError(w, http.StatusNotFound, "not_found", "job not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
// Only allow retry from failed/canceled (adjust as you see fit).
if m.Status != string(dto.StatusFailed) && m.Status != string(dto.StatusCanceled) {
utils.WriteError(w, http.StatusBadRequest, "not_eligible", "job is not failed/canceled")
return
}
// Reset to queued; clear started_at; bump updated_at.
now := time.Now()
if err := db.Model(&m).Updates(map[string]any{
"status": string(dto.StatusQueued),
"started_at": nil,
"updated_at": now,
}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
// Re-read and return.
if err := db.First(&m, "id = ?", id).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, mapModelJobToDTO(m))
}
}
// AdminCancelArcherJob godoc
//
// @ID AdminCancelArcherJob
// @Summary Cancel an Archer job (admin)
// @Description Set job status to canceled if cancellable. For running jobs, this only affects future picks; wire to Archer if you need active kill.
// @Tags ArcherAdmin
// @Accept json
// @Produce json
// @Param id path string true "Job ID"
// @Success 200 {object} dto.Job
// @Failure 400 {string} string "invalid job or not cancellable"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 404 {string} string "not found"
// @Router /admin/archer/jobs/{id}/cancel [post]
// @Security BearerAuth
func AdminCancelArcherJob(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var m models.Job
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
utils.WriteError(w, http.StatusNotFound, "not_found", "job not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
// If already finished, bail.
switch m.Status {
case string(dto.StatusSucceeded), string(dto.StatusCanceled):
utils.WriteError(w, http.StatusBadRequest, "not_cancellable", "job already finished")
return
}
now := time.Now()
if err := db.Model(&m).Updates(map[string]any{
"status": string(dto.StatusCanceled),
"updated_at": now,
}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
if err := db.First(&m, "id = ?", id).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, mapModelJobToDTO(m))
}
}
// AdminListArcherQueues godoc
//
// @ID AdminListArcherQueues
// @Summary List Archer queues (admin)
// @Description Summary metrics per queue (pending, running, failed, scheduled).
// @Tags ArcherAdmin
// @Produce json
// @Success 200 {array} dto.QueueInfo
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "forbidden"
// @Failure 500 {string} string "internal error"
// @Router /admin/archer/queues [get]
// @Security BearerAuth
func AdminListArcherQueues(db *gorm.DB) http.HandlerFunc {
type row struct {
QueueName string
Pending int
Running int
Failed int
Scheduled int
}
return func(w http.ResponseWriter, r *http.Request) {
var rows []row
// Use filtered aggregate; adjust status values if your Archer differs.
if err := db.
Raw(`
SELECT
queue_name,
COUNT(*) FILTER (WHERE status = 'queued') AS pending,
COUNT(*) FILTER (WHERE status = 'running') AS running,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
COUNT(*) FILTER (WHERE status = 'scheduled') AS scheduled
FROM jobs
GROUP BY queue_name
ORDER BY queue_name ASC
`).Scan(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
out := make([]dto.QueueInfo, 0, len(rows))
for _, r := range rows {
out = append(out, dto.QueueInfo{
Name: r.QueueName,
Pending: r.Pending,
Running: r.Running,
Failed: r.Failed,
Scheduled: r.Scheduled,
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// Helpers
func atoiDefault(s string, def int) int {
if s == "" {
return def
}
if n, err := strconv.Atoi(s); err == nil {
return n
}
return def
}
func clamp(n, lo, hi int) int {
if n < lo {
return lo
}
if n > hi {
return hi
}
return n
}
func mapModelJobToDTO(m models.Job) dto.Job {
var payload any
if len(m.Arguments) > 0 {
_ = json.Unmarshal([]byte(m.Arguments), &payload)
}
var updated *time.Time
if !m.UpdatedAt.IsZero() {
updated = &m.UpdatedAt
}
var runAt *time.Time
if !m.ScheduledAt.IsZero() {
rt := m.ScheduledAt
runAt = &rt
}
return dto.Job{
ID: m.ID,
// If you distinguish between queue and type elsewhere, set Type accordingly.
Type: m.QueueName,
Queue: m.QueueName,
Status: dto.JobStatus(m.Status),
Attempts: m.RetryCount,
MaxAttempts: m.MaxRetry,
CreatedAt: m.CreatedAt,
UpdatedAt: updated,
LastError: m.LastError,
RunAt: runAt,
Payload: payload,
}
}
package handlers
import (
"net/http"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/utils"
)
type jwk struct {
Kty string `json:"kty"`
Use string `json:"use,omitempty"`
Kid string `json:"kid,omitempty"`
Alg string `json:"alg,omitempty"`
N string `json:"n,omitempty"` // RSA modulus (base64url)
E string `json:"e,omitempty"` // RSA exponent (base64url)
X string `json:"x,omitempty"` // Ed25519 public key (base64url)
}
type jwks struct {
Keys []jwk `json:"keys"`
}
// JWKSHandler godoc
//
// @ID getJWKS
// @Summary Get JWKS
// @Description Returns the JSON Web Key Set for token verification
// @Tags Auth
// @Produce json
// @Success 200 {object} dto.JWKS
// @Router /.well-known/jwks.json [get]
func JWKSHandler(w http.ResponseWriter, _ *http.Request) {
out := dto.JWKS{Keys: make([]dto.JWK, 0)}
auth.KcCopy(func(pub map[string]interface{}) {
for kid, pk := range pub {
meta := auth.MetaFor(kid)
params, kty := auth.PubToJWK(kid, meta.Alg, pk)
if kty == "" {
continue
}
j := dto.JWK{
Kty: kty,
Use: "sig",
Kid: kid,
Alg: meta.Alg,
N: params["n"],
E: params["e"],
X: params["x"],
}
out.Keys = append(out.Keys, j)
}
})
utils.WriteJSON(w, http.StatusOK, out)
}
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListLabels godoc
//
// @ID ListLabels
// @Summary List node labels (org scoped)
// @Description Returns node labels for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node groups.
// @Tags Labels
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key"
// @Param value query string false "Exact value"
// @Param q query string false "Key contains (case-insensitive)"
// @Success 200 {array} dto.LabelResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node taints"
// @Router /labels [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListLabels(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
q = q.Where(`key = ?`, key)
}
if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
q = q.Where(`value = ?`, val)
}
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where(`key ILIKE ?`, "%"+needle+"%")
}
var out []dto.LabelResponse
if err := q.Model(&models.Label{}).Order("created_at DESC").Scan(&out).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if out == nil {
out = []dto.LabelResponse{}
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetLabel godoc
//
// @ID GetLabel
// @Summary Get label by ID (org scoped)
// @Description Returns one label.
// @Tags Labels
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label ID (UUID)"
// @Success 200 {object} dto.LabelResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /labels/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetLabel(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
return
}
var out dto.LabelResponse
if err := db.Model(&models.Label{}).Where("id = ? AND organization_id = ?", id, orgID).Limit(1).Scan(&out).Error; err != nil {
if out.ID == uuid.Nil {
utils.WriteError(w, http.StatusNotFound, "label_not_found", "label not found")
return
}
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateLabel godoc
//
// @ID CreateLabel
// @Summary Create label (org scoped)
// @Description Creates a label.
// @Tags Labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateLabelRequest true "Label payload"
// @Success 201 {object} dto.LabelResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /labels [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateLabel(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateLabelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Key = strings.TrimSpace(req.Key)
req.Value = strings.TrimSpace(req.Value)
if req.Key == "" || req.Value == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value")
return
}
l := models.Label{
AuditFields: common.AuditFields{OrganizationID: orgID},
Key: req.Key,
Value: req.Value,
}
if err := db.Create(&l).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.LabelResponse{
AuditFields: l.AuditFields,
Key: l.Key,
Value: l.Value,
}
utils.WriteJSON(w, http.StatusCreated, out)
}
}
// UpdateLabel godoc
// UpdateLabel godoc
//
// @ID UpdateLabel
// @Summary Update label (org scoped)
// @Description Partially update label fields.
// @Tags Labels
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label ID (UUID)"
// @Param body body dto.UpdateLabelRequest true "Fields to update"
// @Success 200 {object} dto.LabelResponse
// @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /labels/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateLabel(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
return
}
var l models.Label
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&l).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "label_not_found", "label not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.UpdateLabelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
if req.Key != nil {
l.Key = strings.TrimSpace(*req.Key)
}
if req.Value != nil {
l.Value = strings.TrimSpace(*req.Value)
}
if err := db.Save(&l).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.LabelResponse{
AuditFields: l.AuditFields,
Key: l.Key,
Value: l.Value,
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// DeleteLabel godoc
//
// @ID DeleteLabel
// @Summary Delete label (org scoped)
// @Description Permanently deletes the label.
// @Tags Labels
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /labels/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteLabel(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Label{}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListLoadBalancers godoc
//
// @ID ListLoadBalancers
// @Summary List load balancers (org scoped)
// @Description Returns load balancers for the organization in X-Org-ID.
// @Tags LoadBalancers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Success 200 {array} dto.LoadBalancerResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list clusters"
// @Router /load-balancers [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListLoadBalancers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var rows []models.LoadBalancer
if err := db.Where("organization_id = ?", orgID).Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.LoadBalancerResponse, 0, len(rows))
for _, row := range rows {
out = append(out, loadBalancerOut(&row))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetLoadBalancer godoc
//
// @ID GetLoadBalancers
// @Summary Get a load balancer (org scoped)
// @Description Returns load balancer for the organization in X-Org-ID.
// @Tags LoadBalancers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "LoadBalancer ID (UUID)"
// @Success 200 {array} dto.LoadBalancerResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list clusters"
// @Router /load-balancers/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
var row models.LoadBalancer
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := loadBalancerOut(&row)
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateLoadBalancer godoc
//
// @ID CreateLoadBalancer
// @Summary Create a load balancer
// @Tags LoadBalancers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateLoadBalancerRequest true "Record set payload"
// @Success 201 {object} dto.LoadBalancerResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "domain not found"
// @Router /load-balancers [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var in dto.CreateLoadBalancerRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if strings.ToLower(in.Kind) != "glueops" && strings.ToLower(in.Kind) != "public" {
fmt.Println(in.Kind)
utils.WriteError(w, http.StatusBadRequest, "bad_kind", "invalid kind only 'glueops' or 'public'")
return
}
row := &models.LoadBalancer{
OrganizationID: orgID,
Name: in.Name,
Kind: strings.ToLower(in.Kind),
PublicIPAddress: in.PublicIPAddress,
PrivateIPAddress: in.PrivateIPAddress,
}
if err := db.Create(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, loadBalancerOut(row))
}
}
// UpdateLoadBalancer godoc
//
// @ID UpdateLoadBalancer
// @Summary Update a load balancer (org scoped)
// @Tags LoadBalancers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Load Balancer ID (UUID)"
// @Param body body dto.UpdateLoadBalancerRequest true "Fields to update"
// @Success 200 {object} dto.LoadBalancerResponse
// @Failure 400 {string} string "validation error"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /load-balancers/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
row := &models.LoadBalancer{}
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
var in dto.UpdateLoadBalancerRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if in.Name != nil {
row.Name = *in.Name
}
if in.Kind != nil {
fmt.Println(*in.Kind)
if strings.ToLower(*in.Kind) != "glueops" && strings.ToLower(*in.Kind) != "public" {
utils.WriteError(w, http.StatusBadRequest, "bad_kind", "invalid kind only 'glueops' or 'public'")
return
}
row.Kind = strings.ToLower(*in.Kind)
}
if in.PublicIPAddress != nil {
row.PublicIPAddress = *in.PublicIPAddress
}
if in.PrivateIPAddress != nil {
row.PrivateIPAddress = *in.PrivateIPAddress
}
if err := db.Save(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusOK, loadBalancerOut(row))
}
}
// DeleteLoadBalancer godoc
//
// @ID DeleteLoadBalancer
// @Summary Delete a load balancer
// @Tags LoadBalancers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Load Balancer ID (UUID)"
// @Success 204
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Router /load-balancers/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteLoadBalancer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
return
}
row := &models.LoadBalancer{}
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "load balancer not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
if err := db.Delete(row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// ---------- Out mappers ----------
func loadBalancerOut(m *models.LoadBalancer) dto.LoadBalancerResponse {
return dto.LoadBalancerResponse{
ID: m.ID,
OrganizationID: m.OrganizationID,
Name: m.Name,
Kind: m.Kind,
PublicIPAddress: m.PublicIPAddress,
PrivateIPAddress: m.PrivateIPAddress,
CreatedAt: m.CreatedAt.UTC(),
UpdatedAt: m.UpdatedAt.UTC(),
}
}
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
type userAPIKeyOut struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name *string `json:"name,omitempty"`
Scope string `json:"scope"` // "user"
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Plain *string `json:"plain,omitempty"` // Shown only on create:
}
// ListUserAPIKeys godoc
//
// @ID ListUserAPIKeys
// @Summary List my API keys
// @Tags MeAPIKeys
// @Produce json
// @Success 200 {array} userAPIKeyOut
// @Router /me/api-keys [get]
// @Security BearerAuth
// @Security ApiKeyAuth
func ListUserAPIKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var rows []models.APIKey
if err := db.
Where("scope = ? AND user_id = ?", "user", u.ID).
Order("created_at desc").
Find(&rows).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
out := make([]userAPIKeyOut, 0, len(rows))
for _, k := range rows {
out = append(out, toUserKeyOut(k, nil))
}
utils.WriteJSON(w, 200, out)
}
}
type createUserKeyRequest struct {
Name string `json:"name,omitempty"`
ExpiresInHours *int `json:"expires_in_hours,omitempty"` // optional TTL
}
// CreateUserAPIKey godoc
//
// @ID CreateUserAPIKey
// @Summary Create a new user API key
// @Description Returns the plaintext key once. Store it securely on the client side.
// @Tags MeAPIKeys
// @Accept json
// @Produce json
// @Param body body createUserKeyRequest true "Key options"
// @Success 201 {object} userAPIKeyOut
// @Router /me/api-keys [post]
// @Security BearerAuth
// @Security ApiKeyAuth
func CreateUserAPIKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var req createUserKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
plain, err := generateUserAPIKey()
if err != nil {
utils.WriteError(w, 500, "gen_failed", err.Error())
return
}
hash := auth.SHA256Hex(plain)
var exp *time.Time
if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
t := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
exp = &t
}
rec := models.APIKey{
Scope: "user",
UserID: &u.ID,
KeyHash: hash,
Name: req.Name, // if field exists
ExpiresAt: exp,
// SecretHash: nil (not used for user keys)
}
if err := db.Create(&rec).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, http.StatusCreated, toUserKeyOut(rec, &plain))
}
}
// DeleteUserAPIKey godoc
//
// @ID DeleteUserAPIKey
// @Summary Delete a user API key
// @Tags MeAPIKeys
// @Produce json
// @Param id path string true "Key ID (UUID)"
// @Success 204 "No Content"
// @Router /me/api-keys/{id} [delete]
// @Security BearerAuth
func DeleteUserAPIKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 400, "invalid_id", "must be uuid")
return
}
tx := db.Where("id = ? AND scope = ? AND user_id = ?", id, "user", u.ID).
Delete(&models.APIKey{})
if tx.Error != nil {
utils.WriteError(w, 500, "db_error", tx.Error.Error())
return
}
if tx.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "key not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func toUserKeyOut(k models.APIKey, plain *string) userAPIKeyOut {
return userAPIKeyOut{
ID: k.ID,
Name: &k.Name, // if your model has it; else remove
Scope: k.Scope,
CreatedAt: k.CreatedAt,
ExpiresAt: k.ExpiresAt,
LastUsedAt: k.LastUsedAt, // if present; else remove
Plain: plain,
}
}
func generateUserAPIKey() (string, error) {
// 24 random bytes β base64url (no padding), with "u_" prefix
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
s := base64.RawURLEncoding.EncodeToString(b)
return "u_" + s, nil
}
package handlers
import (
"encoding/json"
"net/http"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"gorm.io/gorm"
)
type meResponse struct {
models.User `json:",inline"`
Emails []models.UserEmail `json:"emails"`
Organizations []models.Organization `json:"organizations"`
}
// GetMe godoc
//
// @ID GetMe
// @Summary Get current user profile
// @Tags Me
// @Produce json
// @Success 200 {object} meResponse
// @Router /me [get]
// @Security BearerAuth
// @Security ApiKeyAuth
func GetMe(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var user models.User
if err := db.First(&user, "id = ? AND is_disabled = false", u.ID).Error; err != nil {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "user not found/disabled")
return
}
var emails []models.UserEmail
_ = db.Preload("User").Where("user_id = ?", user.ID).Order("is_primary desc, created_at asc").Find(&emails).Error
var orgs []models.Organization
{
var rows []models.Membership
_ = db.Where("user_id = ?", user.ID).Find(&rows).Error
if len(rows) > 0 {
var ids []interface{}
for _, m := range rows {
ids = append(ids, m.OrganizationID)
}
_ = db.Find(&orgs, "id IN ?", ids).Error
}
}
utils.WriteJSON(w, http.StatusOK, meResponse{
User: user,
Emails: emails,
Organizations: orgs,
})
}
}
type updateMeRequest struct {
DisplayName *string `json:"display_name,omitempty"`
// You can add more editable fields here (timezone, avatar, etc)
}
// UpdateMe godoc
//
// @ID UpdateMe
// @Summary Update current user profile
// @Tags Me
// @Accept json
// @Produce json
// @Param body body updateMeRequest true "Patch profile"
// @Success 200 {object} models.User
// @Router /me [patch]
// @Security BearerAuth
// @Security ApiKeyAuth
func UpdateMe(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := httpmiddleware.UserFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "not signed in")
return
}
var req updateMeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_json", err.Error())
}
updates := map[string]interface{}{}
if req.DisplayName != nil {
updates["display_name"] = req.DisplayName
}
if len(updates) == 0 {
var user models.User
if err := db.First(&user, "id = ?", u.ID).Error; err != nil {
utils.WriteError(w, 404, "not_found", "user")
return
}
utils.WriteJSON(w, 200, user)
return
}
if err := db.Model(&models.User{}).Where("id = ?", u.ID).Updates(updates).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
var out models.User
_ = db.First(&out, "id = ?", u.ID).Error
utils.WriteJSON(w, 200, out)
}
}
package handlers
import (
"os"
"testing"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/testutil/pgtest"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestMain(m *testing.M) {
code := m.Run()
pgtest.Stop()
os.Exit(code)
}
func TestParseUUIDs_Success(t *testing.T) {
u1 := uuid.New()
u2 := uuid.New()
got, err := parseUUIDs([]string{u1.String(), u2.String()})
if err != nil {
t.Fatalf("parseUUIDs returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 UUIDs, got %d", len(got))
}
if got[0] != u1 || got[1] != u2 {
t.Fatalf("unexpected UUIDs: got=%v", got)
}
}
func TestParseUUIDs_Invalid(t *testing.T) {
_, err := parseUUIDs([]string{"not-a-uuid"})
if err == nil {
t.Fatalf("expected error for invalid UUID, got nil")
}
}
// --- ensureServersBelongToOrg ---
func TestEnsureServersBelongToOrg_AllBelong(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-a"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
sshKey := createTestSshKey(t, db, org.ID, "org-a-key")
s1 := models.Server{
OrganizationID: org.ID,
Hostname: "srv-1",
SSHUser: "ubuntu",
SshKeyID: sshKey.ID,
Role: "worker",
Status: "pending",
}
s2 := models.Server{
OrganizationID: org.ID,
Hostname: "srv-2",
SSHUser: "ubuntu",
SshKeyID: sshKey.ID,
Role: "worker",
Status: "pending",
}
if err := db.Create(&s1).Error; err != nil {
t.Fatalf("create server 1: %v", err)
}
if err := db.Create(&s2).Error; err != nil {
t.Fatalf("create server 2: %v", err)
}
ids := []uuid.UUID{s1.ID, s2.ID}
if err := ensureServersBelongToOrg(org.ID, ids, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureServersBelongToOrg_ForeignOrgFails(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
sshKeyA := createTestSshKey(t, db, orgA.ID, "org-a-key")
sshKeyB := createTestSshKey(t, db, orgB.ID, "org-b-key")
s1 := models.Server{
OrganizationID: orgA.ID,
Hostname: "srv-a-1",
SSHUser: "ubuntu",
SshKeyID: sshKeyA.ID,
Role: "worker",
Status: "pending",
}
s2 := models.Server{
OrganizationID: orgB.ID,
Hostname: "srv-b-1",
SSHUser: "ubuntu",
SshKeyID: sshKeyB.ID,
Role: "worker",
Status: "pending",
}
if err := db.Create(&s1).Error; err != nil {
t.Fatalf("create server s1: %v", err)
}
if err := db.Create(&s2).Error; err != nil {
t.Fatalf("create server s2: %v", err)
}
ids := []uuid.UUID{s1.ID, s2.ID}
if err := ensureServersBelongToOrg(orgA.ID, ids, db); err == nil {
t.Fatalf("expected error when one server belongs to a different org")
}
}
// --- ensureTaintsBelongToOrg ---
func TestEnsureTaintsBelongToOrg_AllBelong(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-taints"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
t1 := models.Taint{
OrganizationID: org.ID,
Key: "key1",
Value: "val1",
Effect: "NoSchedule",
}
t2 := models.Taint{
OrganizationID: org.ID,
Key: "key2",
Value: "val2",
Effect: "PreferNoSchedule",
}
if err := db.Create(&t1).Error; err != nil {
t.Fatalf("create taint 1: %v", err)
}
if err := db.Create(&t2).Error; err != nil {
t.Fatalf("create taint 2: %v", err)
}
ids := []uuid.UUID{t1.ID, t2.ID}
if err := ensureTaintsBelongToOrg(org.ID, ids, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureTaintsBelongToOrg_ForeignOrgFails(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
t1 := models.Taint{
OrganizationID: orgA.ID,
Key: "key1",
Value: "val1",
Effect: "NoSchedule",
}
t2 := models.Taint{
OrganizationID: orgB.ID,
Key: "key2",
Value: "val2",
Effect: "NoSchedule",
}
if err := db.Create(&t1).Error; err != nil {
t.Fatalf("create taint 1: %v", err)
}
if err := db.Create(&t2).Error; err != nil {
t.Fatalf("create taint 2: %v", err)
}
ids := []uuid.UUID{t1.ID, t2.ID}
if err := ensureTaintsBelongToOrg(orgA.ID, ids, db); err == nil {
t.Fatalf("expected error when a taint belongs to another org")
}
}
// --- ensureLabelsBelongToOrg ---
func TestEnsureLabelsBelongToOrg_AllBelong(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-labels"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
l1 := models.Label{
AuditFields: common.AuditFields{
OrganizationID: org.ID,
},
Key: "env",
Value: "dev",
}
l2 := models.Label{
AuditFields: common.AuditFields{
OrganizationID: org.ID,
},
Key: "env",
Value: "prod",
}
if err := db.Create(&l1).Error; err != nil {
t.Fatalf("create label 1: %v", err)
}
if err := db.Create(&l2).Error; err != nil {
t.Fatalf("create label 2: %v", err)
}
ids := []uuid.UUID{l1.ID, l2.ID}
if err := ensureLabelsBelongToOrg(org.ID, ids, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureLabelsBelongToOrg_ForeignOrgFails(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
l1 := models.Label{
AuditFields: common.AuditFields{
OrganizationID: orgA.ID,
},
Key: "env",
Value: "dev",
}
l2 := models.Label{
AuditFields: common.AuditFields{
OrganizationID: orgB.ID,
},
Key: "env",
Value: "prod",
}
if err := db.Create(&l1).Error; err != nil {
t.Fatalf("create label 1: %v", err)
}
if err := db.Create(&l2).Error; err != nil {
t.Fatalf("create label 2: %v", err)
}
ids := []uuid.UUID{l1.ID, l2.ID}
if err := ensureLabelsBelongToOrg(orgA.ID, ids, db); err == nil {
t.Fatalf("expected error when a label belongs to another org")
}
}
// --- ensureAnnotaionsBelongToOrg (typo in original name is preserved) ---
func TestEnsureAnnotationsBelongToOrg_AllBelong(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-annotations"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
a1 := models.Annotation{
AuditFields: common.AuditFields{
OrganizationID: org.ID,
},
Key: "team",
Value: "core",
}
a2 := models.Annotation{
AuditFields: common.AuditFields{
OrganizationID: org.ID,
},
Key: "team",
Value: "platform",
}
if err := db.Create(&a1).Error; err != nil {
t.Fatalf("create annotation 1: %v", err)
}
if err := db.Create(&a2).Error; err != nil {
t.Fatalf("create annotation 2: %v", err)
}
ids := []uuid.UUID{a1.ID, a2.ID}
if err := ensureAnnotaionsBelongToOrg(org.ID, ids, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureAnnotationsBelongToOrg_ForeignOrgFails(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
a1 := models.Annotation{
AuditFields: common.AuditFields{
OrganizationID: orgA.ID,
},
Key: "team",
Value: "core",
}
a2 := models.Annotation{
AuditFields: common.AuditFields{
OrganizationID: orgB.ID,
},
Key: "team",
Value: "platform",
}
if err := db.Create(&a1).Error; err != nil {
t.Fatalf("create annotation 1: %v", err)
}
if err := db.Create(&a2).Error; err != nil {
t.Fatalf("create annotation 2: %v", err)
}
ids := []uuid.UUID{a1.ID, a2.ID}
if err := ensureAnnotaionsBelongToOrg(orgA.ID, ids, db); err == nil {
t.Fatalf("expected error when an annotation belongs to another org")
}
}
func createTestSshKey(t *testing.T, db *gorm.DB, orgID uuid.UUID, name string) models.SshKey {
t.Helper()
key := models.SshKey{
AuditFields: common.AuditFields{
OrganizationID: orgID,
},
Name: name,
PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey",
EncryptedPrivateKey: "encrypted",
PrivateIV: "iv",
PrivateTag: "tag",
Fingerprint: "fp-" + name,
}
if err := db.Create(&key).Error; err != nil {
t.Fatalf("create ssh key %s: %v", name, err)
}
return key
}
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// -- Node Pools Core
// ListNodePools godoc
//
// @ID ListNodePools
// @Summary List node pools (org scoped)
// @Description Returns node pools for the organization in X-Org-ID.
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param q query string false "Name contains (case-insensitive)"
// @Success 200 {array} dto.NodePoolResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node pools"
// @Router /node-pools [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListNodePools(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where("name LIKE ?", "%"+needle+"%")
}
var pools []models.NodePool
if err := q.
Preload("Servers").
Preload("Labels").
Preload("Taints").
Preload("Annotations").
Order("created_at DESC").
Find(&pools).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.NodePoolResponse, 0, len(pools))
for _, p := range pools {
npr := dto.NodePoolResponse{
AuditFields: p.AuditFields,
Name: p.Name,
Role: dto.NodeRole(p.Role),
Servers: make([]dto.ServerResponse, 0, len(p.Servers)),
Labels: make([]dto.LabelResponse, 0, len(p.Labels)),
Taints: make([]dto.TaintResponse, 0, len(p.Taints)),
Annotations: make([]dto.AnnotationResponse, 0, len(p.Annotations)),
}
//Servers
for _, s := range p.Servers {
outSrv := dto.ServerResponse{
ID: s.ID,
Hostname: s.Hostname,
PublicIPAddress: s.PublicIPAddress,
PrivateIPAddress: s.PrivateIPAddress,
OrganizationID: s.OrganizationID,
SshKeyID: s.SshKeyID,
SSHUser: s.SSHUser,
Role: s.Role,
Status: s.Status,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
// add more fields as needed
}
npr.Servers = append(npr.Servers, outSrv)
}
//Labels
for _, l := range p.Labels {
outL := dto.LabelResponse{
AuditFields: common.AuditFields{
ID: l.ID,
OrganizationID: l.OrganizationID,
CreatedAt: l.CreatedAt,
UpdatedAt: l.UpdatedAt,
},
Key: l.Key,
Value: l.Value,
}
npr.Labels = append(npr.Labels, outL)
}
// Taints
for _, t := range p.Taints {
outT := dto.TaintResponse{
ID: t.ID,
OrganizationID: t.OrganizationID,
CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
}
npr.Taints = append(npr.Taints, outT)
}
// Annotations
for _, a := range p.Annotations {
outA := dto.AnnotationResponse{
AuditFields: common.AuditFields{
ID: a.ID,
OrganizationID: a.OrganizationID,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
},
Key: a.Key,
Value: a.Value,
}
npr.Annotations = append(npr.Annotations, outA)
}
out = append(out, npr)
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetNodePool godoc
//
// @ID GetNodePool
// @Summary Get node pool by ID (org scoped)
// @Description Returns one node pool. Add `include=servers` to include servers.
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Success 200 {object} dto.NodePoolResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetNodePool(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
return
}
var out dto.NodePoolResponse
if err := db.Model(&models.NodePool{}).Preload("Servers").Where("id = ? AND organization_id = ?", id, orgID).First(&out, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateNodePool godoc
//
// @ID CreateNodePool
// @Summary Create node pool (org scoped)
// @Description Creates a node pool. Optionally attach initial servers.
// @Tags NodePools
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateNodePoolRequest true "NodePool payload"
// @Success 201 {object} dto.NodePoolResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid server_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /node-pools [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateNodePool(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Name = strings.TrimSpace(req.Name)
req.Role = dto.NodeRole(strings.TrimSpace(string(req.Role)))
if req.Name == "" || req.Role == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing name/role")
return
}
n := models.NodePool{
AuditFields: common.AuditFields{
OrganizationID: orgID,
},
Name: req.Name,
Role: string(req.Role),
}
if err := db.Create(&n).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.NodePoolResponse{
AuditFields: n.AuditFields,
Name: n.Name,
Role: dto.NodeRole(n.Role),
}
utils.WriteJSON(w, http.StatusCreated, out)
}
}
// UpdateNodePool godoc
//
// @ID UpdateNodePool
// @Summary Update node pool (org scoped)
// @Description Partially update node pool fields.
// @Tags NodePools
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param body body dto.UpdateNodePoolRequest true "Fields to update"
// @Success 200 {object} dto.NodePoolResponse
// @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /node-pools/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateNodePool(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
return
}
var n models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&n).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.UpdateNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
if req.Name != nil {
n.Name = strings.TrimSpace(*req.Name)
}
if req.Role != nil {
v := dto.NodeRole(strings.TrimSpace(string(*req.Role)))
n.Role = string(v)
}
if err := db.Save(&n).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.NodePoolResponse{
AuditFields: n.AuditFields,
Name: n.Name,
Role: dto.NodeRole(n.Role),
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// DeleteNodePool godoc
//
// @ID DeleteNodePool
// @Summary Delete node pool (org scoped)
// @Description Permanently deletes the node pool.
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /node-pools/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteNodePool(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.NodePool{}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// -- Node Pools Servers
// ListNodePoolServers godoc
//
// @ID ListNodePoolServers
// @Summary List servers attached to a node pool (org scoped)
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Success 200 {array} dto.ServerResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id}/servers [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListNodePoolServers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Servers").First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ServerResponse, 0, len(np.Servers))
for _, server := range np.Servers {
out = append(out, dto.ServerResponse{
ID: server.ID,
OrganizationID: server.OrganizationID,
Hostname: server.Hostname,
PrivateIPAddress: server.PrivateIPAddress,
PublicIPAddress: server.PublicIPAddress,
Role: server.Role,
SshKeyID: server.SshKeyID,
SSHUser: server.SSHUser,
Status: server.Status,
CreatedAt: server.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: server.UpdatedAt.UTC().Format(time.RFC3339),
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// AttachNodePoolServers godoc
//
// @ID AttachNodePoolServers
// @Summary Attach servers to a node pool (org scoped)
// @Tags NodePools
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param body body dto.AttachServersRequest true "Server IDs to attach"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid server_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /node-pools/{id}/servers [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachNodePoolServers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.AttachServersRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
ids, err := parseUUIDs(req.ServerIDs)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid server_ids")
return
}
if len(ids) == 0 {
// nothing to attach
utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach")
return
}
// validate IDs belong to org
if err := ensureServersBelongToOrg(orgID, ids, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid server_ids for this organization")
return
}
// fetch only the requested servers
var servers []models.Server
if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&servers).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error")
return
}
if len(servers) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
if err := db.Model(&np).Association("Servers").Append(&servers); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DetachNodePoolServer godoc
//
// @ID DetachNodePoolServer
// @Summary Detach one server from a node pool (org scoped)
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param serverId path string true "Server ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed"
// @Router /node-pools/{id}/servers/{serverId} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachNodePoolServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
serverId, err := uuid.Parse(chi.URLParam(r, "serverId"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "server id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var s models.Server
if err := db.Where("id = ? AND organization_id = ?", serverId, orgID).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Model(&np).Association("Servers").Delete(&s); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "detach error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// -- Node Pools Taints
// ListNodePoolTaints godoc
//
// @ID ListNodePoolTaints
// @Summary List taints attached to a node pool (org scoped)
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Success 200 {array} dto.TaintResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id}/taints [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListNodePoolTaints(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Taints").First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.TaintResponse, 0, len(np.Taints))
for _, t := range np.Taints {
out = append(out, dto.TaintResponse{
ID: t.ID,
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// AttachNodePoolTaints godoc
//
// @ID AttachNodePoolTaints
// @Summary Attach taints to a node pool (org scoped)
// @Tags NodePools
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param body body dto.AttachTaintsRequest true "Taint IDs to attach"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid taint_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /node-pools/{id}/taints [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachNodePoolTaints(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.AttachTaintsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
ids, err := parseUUIDs(req.TaintIDs)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid taint_ids")
return
}
if len(ids) == 0 {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach")
return
}
// validate IDs belong to org
if err := ensureTaintsBelongToOrg(orgID, ids, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid taint_ids for this organization")
return
}
var taints []models.Taint
if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&taints).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error")
return
}
if len(taints) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
if err := db.Model(&np).Association("Taints").Append(&taints); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DetachNodePoolTaint godoc
//
// @ID DetachNodePoolTaint
// @Summary Detach one taint from a node pool (org scoped)
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param taintId path string true "Taint ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed"
// @Router /node-pools/{id}/taints/{taintId} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachNodePoolTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
taintId, err := uuid.Parse(chi.URLParam(r, "taintId"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "taintId_required", "taint id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var t models.Taint
if err := db.Where("id = ? AND organization_id = ?", taintId, orgID).First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "taint_not_found", "taint not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Model(&np).Association("Taints").Delete(&t); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// -- Node Pools Labels
// ListNodePoolLabels godoc
//
// @ID ListNodePoolLabels
// @Summary List labels attached to a node pool (org scoped)
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Label Pool ID (UUID)"
// @Success 200 {array} dto.LabelResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id}/labels [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListNodePoolLabels(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Labels").First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.LabelResponse, 0, len(np.Taints))
for _, label := range np.Labels {
out = append(out, dto.LabelResponse{
AuditFields: common.AuditFields{
ID: label.ID,
OrganizationID: label.OrganizationID,
CreatedAt: label.CreatedAt,
UpdatedAt: label.UpdatedAt,
},
Key: label.Key,
Value: label.Value,
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// AttachNodePoolLabels godoc
//
// @ID AttachNodePoolLabels
// @Summary Attach labels to a node pool (org scoped)
// @Tags NodePools
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param body body dto.AttachLabelsRequest true "Label IDs to attach"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid server_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /node-pools/{id}/labels [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachNodePoolLabels(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.AttachLabelsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
ids, err := parseUUIDs(req.LabelIDs)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid label_ids")
return
}
if len(ids) == 0 {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach")
return
}
if err := ensureLabelsBelongToOrg(orgID, ids, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid label_ids for this organization")
}
var labels []models.Label
if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&labels).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach db error")
return
}
if len(labels) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
if err := db.Model(&np).Association("Labels").Append(&labels); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DetachNodePoolLabel godoc
//
// @ID DetachNodePoolLabel
// @Summary Detach one label from a node pool (org scoped)
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param labelId path string true "Label ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed"
// @Router /node-pools/{id}/labels/{labelId} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachNodePoolLabel(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
labelId, err := uuid.Parse(chi.URLParam(r, "labelId"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "labelId required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var l models.Label
if err := db.Where("id = ? AND organization_id = ?", labelId, orgID).First(&l).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "label_not_found", "label not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Model(&np).Association("Labels").Delete(&l); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "detach error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// -- Node Pools Annotations
// ListNodePoolAnnotations godoc
//
// @ID ListNodePoolAnnotations
// @Summary List annotations attached to a node pool (org scoped)
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Success 200 {array} dto.AnnotationResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /node-pools/{id}/annotations [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListNodePoolAnnotations(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Preload("Labels").First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.AnnotationResponse, 0, len(np.Annotations))
for _, ann := range np.Annotations {
out = append(out, dto.AnnotationResponse{
AuditFields: common.AuditFields{
ID: ann.ID,
OrganizationID: ann.OrganizationID,
CreatedAt: ann.CreatedAt,
UpdatedAt: ann.UpdatedAt,
},
Key: ann.Key,
Value: ann.Value,
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// AttachNodePoolAnnotations godoc
//
// @ID AttachNodePoolAnnotations
// @Summary Attach annotation to a node pool (org scoped)
// @Tags NodePools
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Group ID (UUID)"
// @Param body body dto.AttachAnnotationsRequest true "Annotation IDs to attach"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id / invalid server_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "attach failed"
// @Router /node-pools/{id}/annotations [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachNodePoolAnnotations(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.AttachAnnotationsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
ids, err := parseUUIDs(req.AnnotationIDs)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid annotation ids")
return
}
if len(ids) == 0 {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "nothing to attach")
return
}
if err := ensureAnnotaionsBelongToOrg(orgID, ids, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid annotation ids for this organization")
return
}
var ann []models.Annotation
if err := db.Where("organization_id = ? AND id IN ?", orgID, ids).Find(&ann).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if len(ann) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
if err := db.Model(&np).Association("Annotations").Append(&ann); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "attach failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DetachNodePoolAnnotation godoc
//
// @ID DetachNodePoolAnnotation
// @Summary Detach one annotation from a node pool (org scoped)
// @Tags NodePools
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Pool ID (UUID)"
// @Param annotationId path string true "Annotation ID (UUID)"
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "detach failed"
// @Router /node-pools/{id}/annotations/{annotationId} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachNodePoolAnnotation(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool id required")
return
}
annotationId, err := uuid.Parse(chi.URLParam(r, "annotationId"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_required", "node pool annotation id required")
return
}
var np models.NodePool
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "node_pool_not_found", "node pool not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var ann []models.Annotation
if err := db.Where("id = ? AND organization_id = ?", annotationId, orgID).First(&ann).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "annotation_not_found", "annotation not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Model(&np).Association("Annotations").Delete(&ann); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// -- Helpers
func parseUUIDs(ids []string) ([]uuid.UUID, error) {
out := make([]uuid.UUID, 0, len(ids))
for _, id := range ids {
u, err := uuid.Parse(id)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func ensureServersBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error {
var count int64
if err := db.Model(&models.Server{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return errors.New("some servers do not belong to this org")
}
return nil
}
func ensureTaintsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error {
var count int64
if err := db.Model(&models.Taint{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return errors.New("some taints do not belong to this org")
}
return nil
}
func ensureLabelsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error {
var count int64
if err := db.Model(&models.Label{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return errors.New("some labels do not belong to this org")
}
return nil
}
func ensureAnnotaionsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID, db *gorm.DB) error {
var count int64
if err := db.Model(&models.Annotation{}).Where("organization_id = ? AND id IN ?", orgID, ids).Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return errors.New("some annotations do not belong to this org")
}
return nil
}
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/auth"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ---------- Helpers ----------
func mustUser(r *http.Request) (*models.User, bool) {
return httpmiddleware.UserFrom(r.Context())
}
func isOrgRole(db *gorm.DB, userID, orgID uuid.UUID, want ...string) (bool, string) {
var m models.Membership
if err := db.Where("user_id = ? AND organization_id = ?", userID, orgID).First(&m).Error; err != nil {
return false, ""
}
got := strings.ToLower(m.Role)
for _, w := range want {
if got == strings.ToLower(w) {
return true, got
}
}
return false, got
}
func mustMember(db *gorm.DB, userID, orgID uuid.UUID) bool {
ok, _ := isOrgRole(db, userID, orgID, "owner", "admin", "member")
return ok
}
func randomB64URL(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// ---------- Orgs: list/create/get/update/delete ----------
type orgCreateReq struct {
Name string `json:"name" example:"Acme Corp"`
Domain *string `json:"domain,omitempty" example:"acme.com"`
}
// CreateOrg godoc
//
// @ID CreateOrg
// @Summary Create organization
// @Tags Orgs
// @Accept json
// @Produce json
// @Param body body orgCreateReq true "Org payload"
// @Success 201 {object} models.Organization
// @Failure 400 {object} utils.ErrorResponse
// @Failure 401 {object} utils.ErrorResponse
// @Failure 409 {object} utils.ErrorResponse
// @Router /orgs [post]
// @ID createOrg
// @Security BearerAuth
func CreateOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
return
}
var req orgCreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
utils.WriteError(w, 400, "validation_error", "name is required")
return
}
org := models.Organization{Name: req.Name}
if req.Domain != nil && strings.TrimSpace(*req.Domain) != "" {
org.Domain = req.Domain
}
if err := db.Create(&org).Error; err != nil {
utils.WriteError(w, 409, "conflict", err.Error())
return
}
// creator is owner
_ = db.Create(&models.Membership{
UserID: u.ID, OrganizationID: org.ID, Role: "owner",
}).Error
utils.WriteJSON(w, 201, org)
}
}
// ListMyOrgs godoc
//
// @ID ListMyOrgs
// @Summary List organizations I belong to
// @Tags Orgs
// @Produce json
// @Success 200 {array} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs [get]
// @ID listMyOrgs
// @Security BearerAuth
func ListMyOrgs(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, http.StatusUnauthorized, "unauthorized", "")
return
}
var orgs []models.Organization
if err := db.
Joins("join memberships m on m.organization_id = organizations.id").
Where("m.user_id = ?", u.ID).
Order("organizations.created_at desc").
Find(&orgs).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, 200, orgs)
}
}
// GetOrg godoc
//
// @ID GetOrg
// @Summary Get organization
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {object} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [get]
// @ID getOrg
// @Security BearerAuth
func GetOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "not a member")
return
}
var org models.Organization
if err := db.First(&org, "id = ?", oid).Error; err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
utils.WriteJSON(w, 200, org)
}
}
type orgUpdateReq struct {
Name *string `json:"name,omitempty"`
Domain *string `json:"domain,omitempty"`
}
// UpdateOrg godoc
//
// @ID UpdateOrg
// @Summary Update organization (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body orgUpdateReq true "Update payload"
// @Success 200 {object} models.Organization
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [patch]
// @ID updateOrg
// @Security BearerAuth
func UpdateOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req orgUpdateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
changes := map[string]any{}
if req.Name != nil {
changes["name"] = strings.TrimSpace(*req.Name)
}
if req.Domain != nil {
if d := strings.TrimSpace(*req.Domain); d == "" {
changes["domain"] = nil
} else {
changes["domain"] = d
}
}
if len(changes) > 0 {
if err := db.Model(&models.Organization{}).Where("id = ?", oid).Updates(changes).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
}
var out models.Organization
_ = db.First(&out, "id = ?", oid).Error
utils.WriteJSON(w, 200, out)
}
}
// DeleteOrg godoc
//
// @ID DeleteOrg
// @Summary Delete organization (owner)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 204 "Deleted"
// @Failure 401 {object} utils.ErrorResponse
// @Failure 404 {object} utils.ErrorResponse
// @Router /orgs/{id} [delete]
// @ID deleteOrg
// @Security BearerAuth
func DeleteOrg(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner"); !ok {
utils.WriteError(w, 401, "forbidden", "owner required")
return
}
// Optional safety: deny if members >1 or resources exist; here we just delete.
res := db.Delete(&models.Organization{}, "id = ?", oid)
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
w.WriteHeader(204)
}
}
// ---------- Members: list/add/update/delete ----------
type memberOut struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
Email string `json:"email"`
Role string `json:"role"` // owner/admin/member
}
type memberUpsertReq struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
Role string `json:"role" example:"member"`
}
// ListMembers godoc
//
// @ID ListMembers
// @Summary List members in org
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {array} memberOut
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members [get]
// @ID listMembers
// @Security BearerAuth
func ListMembers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "")
return
}
var ms []models.Membership
if err := db.Where("organization_id = ?", oid).Find(&ms).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
// load emails
userIDs := make([]uuid.UUID, 0, len(ms))
for _, m := range ms {
userIDs = append(userIDs, m.UserID)
}
var emails []models.UserEmail
if len(userIDs) > 0 {
_ = db.Where("user_id in ?", userIDs).Where("is_primary = true").Find(&emails).Error
}
emailByUser := map[uuid.UUID]string{}
for _, e := range emails {
emailByUser[e.UserID] = e.Email
}
out := make([]memberOut, 0, len(ms))
for _, m := range ms {
out = append(out, memberOut{
UserID: m.UserID,
Email: emailByUser[m.UserID],
Role: m.Role,
})
}
utils.WriteJSON(w, 200, out)
}
}
// AddOrUpdateMember godoc
//
// @ID AddOrUpdateMember
// @Summary Add or update a member (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body memberUpsertReq true "User & role"
// @Success 200 {object} memberOut
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members [post]
// @ID addOrUpdateMember
// @Security BearerAuth
func AddOrUpdateMember(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req memberUpsertReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "owner" && role != "admin" && role != "member" {
utils.WriteError(w, 400, "validation_error", "role must be owner|admin|member")
return
}
var m models.Membership
tx := db.Where("user_id = ? AND organization_id = ?", req.UserID, oid).First(&m)
if tx.Error == nil {
// update
if err := db.Model(&m).Update("role", role).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
} else if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
m = models.Membership{UserID: req.UserID, OrganizationID: oid, Role: role}
if err := db.Create(&m).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
} else {
utils.WriteError(w, 500, "db_error", tx.Error.Error())
return
}
// make response
var ue models.UserEmail
_ = db.Where("user_id = ? AND is_primary = true", req.UserID).First(&ue).Error
utils.WriteJSON(w, 200, memberOut{
UserID: req.UserID, Email: ue.Email, Role: m.Role,
})
}
}
// RemoveMember godoc
//
// @ID RemoveMember
// @Summary Remove a member (owner/admin)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param user_id path string true "User ID (UUID)"
// @Success 204 "Removed"
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/members/{user_id} [delete]
// @ID removeMember
// @Security BearerAuth
func RemoveMember(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
uid, err := uuid.Parse(chi.URLParam(r, "user_id"))
if err != nil {
utils.WriteError(w, 400, "invalid_user_id", "")
return
}
res := db.Where("user_id = ? AND organization_id = ?", uid, oid).Delete(&models.Membership{})
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
w.WriteHeader(204)
}
}
// ---------- Org API Keys (key/secret pair) ----------
type orgKeyCreateReq struct {
Name string `json:"name,omitempty" example:"automation-bot"`
ExpiresInHours *int `json:"expires_in_hours,omitempty" example:"720"`
}
type orgKeyCreateResp struct {
ID uuid.UUID `json:"id"`
Name string `json:"name,omitempty"`
Scope string `json:"scope"` // "org"
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
OrgKey string `json:"org_key"` // shown once:
OrgSecret string `json:"org_secret"` // shown once:
}
// ListOrgKeys godoc
//
// @ID ListOrgKeys
// @Summary List org-scoped API keys (no secrets)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Success 200 {array} models.APIKey
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys [get]
// @ID listOrgKeys
// @Security BearerAuth
func ListOrgKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || !mustMember(db, u.ID, oid) {
utils.WriteError(w, 401, "forbidden", "")
return
}
var keys []models.APIKey
if err := db.Where("org_id = ? AND scope = ?", oid, "org").
Order("created_at desc").
Find(&keys).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
// SecretHash must not be exposed; your json tags likely hide it already.
utils.WriteJSON(w, 200, keys)
}
}
// CreateOrgKey godoc
//
// @ID CreateOrgKey
// @Summary Create org key/secret pair (owner/admin)
// @Tags Orgs
// @Accept json
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param body body orgKeyCreateReq true "Key name + optional expiry"
// @Success 201 {object} orgKeyCreateResp
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys [post]
// @ID createOrgKey
// @Security BearerAuth
func CreateOrgKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
var req orgKeyCreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, 400, "invalid_json", err.Error())
return
}
// generate
keySuffix, err := randomB64URL(16)
if err != nil {
utils.WriteError(w, 500, "entropy_error", err.Error())
return
}
sec, err := randomB64URL(32)
if err != nil {
utils.WriteError(w, 500, "entropy_error", err.Error())
return
}
orgKey := "org_" + keySuffix
secretPlain := sec
keyHash := auth.SHA256Hex(orgKey)
secretHash, err := auth.HashSecretArgon2id(secretPlain)
if err != nil {
utils.WriteError(w, 500, "hash_error", err.Error())
return
}
var exp *time.Time
if req.ExpiresInHours != nil && *req.ExpiresInHours > 0 {
e := time.Now().Add(time.Duration(*req.ExpiresInHours) * time.Hour)
exp = &e
}
prefix := orgKey
if len(prefix) > 12 {
prefix = prefix[:12]
}
rec := models.APIKey{
OrgID: &oid,
Scope: "org",
Purpose: "user",
IsEphemeral: false,
Name: req.Name,
KeyHash: keyHash,
SecretHash: &secretHash,
ExpiresAt: exp,
Revoked: false,
Prefix: &prefix,
}
if err := db.Create(&rec).Error; err != nil {
utils.WriteError(w, 500, "db_error", err.Error())
return
}
utils.WriteJSON(w, 201, orgKeyCreateResp{
ID: rec.ID,
Name: rec.Name,
Scope: rec.Scope,
CreatedAt: rec.CreatedAt,
ExpiresAt: rec.ExpiresAt,
OrgKey: orgKey,
OrgSecret: secretPlain,
})
}
}
// DeleteOrgKey godoc
//
// @ID DeleteOrgKey
// @Summary Delete org key (owner/admin)
// @Tags Orgs
// @Produce json
// @Param id path string true "Org ID (UUID)"
// @Param key_id path string true "Key ID (UUID)"
// @Success 204 "Deleted"
// @Failure 401 {object} utils.ErrorResponse
// @Router /orgs/{id}/api-keys/{key_id} [delete]
// @ID deleteOrgKey
// @Security BearerAuth
func DeleteOrgKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, ok := mustUser(r)
if !ok {
utils.WriteError(w, 401, "unauthorized", "")
return
}
oid, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, 404, "not_found", "org not found")
return
}
if ok, _ := isOrgRole(db, u.ID, oid, "owner", "admin"); !ok {
utils.WriteError(w, 401, "forbidden", "admin or owner required")
return
}
kid, err := uuid.Parse(chi.URLParam(r, "key_id"))
if err != nil {
utils.WriteError(w, 400, "invalid_key_id", "")
return
}
res := db.Where("id = ? AND org_id = ? AND scope = ?", kid, oid, "org").Delete(&models.APIKey{})
if res.Error != nil {
utils.WriteError(w, 500, "db_error", res.Error.Error())
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, 404, "not_found", "key not found")
return
}
w.WriteHeader(204)
}
}
package handlers
import (
"testing"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/testutil/pgtest"
"github.com/google/uuid"
)
func TestValidStatus(t *testing.T) {
// known-good statuses from servers.go
valid := []string{"pending", "provisioning", "ready", "failed"}
for _, s := range valid {
if !validStatus(s) {
t.Errorf("expected validStatus(%q) = true, got false", s)
}
}
invalid := []string{"foobar", "unknown"}
for _, s := range invalid {
if validStatus(s) {
t.Errorf("expected validStatus(%q) = false, got true", s)
}
}
}
func TestEnsureKeyBelongsToOrg_Success(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "servers-org"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
key := createTestSshKey(t, db, org.ID, "org-key")
if err := ensureKeyBelongsToOrg(org.ID, key.ID, db); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestEnsureKeyBelongsToOrg_WrongOrg(t *testing.T) {
db := pgtest.DB(t)
orgA := models.Organization{Name: "org-a"}
orgB := models.Organization{Name: "org-b"}
if err := db.Create(&orgA).Error; err != nil {
t.Fatalf("create orgA: %v", err)
}
if err := db.Create(&orgB).Error; err != nil {
t.Fatalf("create orgB: %v", err)
}
keyA := createTestSshKey(t, db, orgA.ID, "org-a-key")
// ask for orgB with a key that belongs to orgA β should fail
if err := ensureKeyBelongsToOrg(orgB.ID, keyA.ID, db); err == nil {
t.Fatalf("expected error when ssh key belongs to a different org, got nil")
}
}
func TestEnsureKeyBelongsToOrg_NotFound(t *testing.T) {
db := pgtest.DB(t)
org := models.Organization{Name: "org-nokey"}
if err := db.Create(&org).Error; err != nil {
t.Fatalf("create org: %v", err)
}
// random keyID that doesn't exist
randomKeyID := uuid.New()
if err := ensureKeyBelongsToOrg(org.ID, randomKeyID, db); err == nil {
t.Fatalf("expected error when ssh key does not exist, got nil")
}
}
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListServers godoc
//
// @ID ListServers
// @Summary List servers (org scoped)
// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
// @Tags Servers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param status query string false "Filter by status (pending|provisioning|ready|failed)"
// @Param role query string false "Filter by role"
// @Success 200 {array} dto.ServerResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list servers"
// @Router /servers [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListServers(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
if !validStatus(s) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
q = q.Where("status = ?", strings.ToLower(s))
}
if role := strings.TrimSpace(r.URL.Query().Get("role")); role != "" {
q = q.Where("role = ?", role)
}
var rows []models.Server
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list servers")
return
}
out := make([]dto.ServerResponse, 0, len(rows))
for _, row := range rows {
out = append(out, dto.ServerResponse{
ID: row.ID,
OrganizationID: row.OrganizationID,
Hostname: row.Hostname,
PublicIPAddress: row.PublicIPAddress,
PrivateIPAddress: row.PrivateIPAddress,
SSHUser: row.SSHUser,
SshKeyID: row.SshKeyID,
Role: row.Role,
Status: row.Status,
CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339),
})
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetServer godoc
//
// @ID GetServer
// @Summary Get server by ID (org scoped)
// @Description Returns one server in the given organization.
// @Tags Servers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 200 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /servers/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
var row models.Server
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
return
}
utils.WriteJSON(w, http.StatusOK, row)
}
}
// CreateServer godoc
//
// @ID CreateServer
// @Summary Create server (org scoped)
// @Description Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateServerRequest true "Server payload"
// @Success 201 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /servers [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
req.Status = strings.ToLower(strings.TrimSpace(req.Status))
pub := strings.TrimSpace(req.PublicIPAddress)
if req.PrivateIPAddress == "" || req.SSHUser == "" || req.SshKeyID == "" || req.Role == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "private_ip_address, ssh_user, ssh_key_id and role are required")
return
}
if req.Status != "" && !validStatus(req.Status) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
if req.Role == "bastion" && pub == "" {
utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
return
}
keyID, err := uuid.Parse(req.SshKeyID)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
return
}
if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
return
}
var publicPtr *string
if pub != "" {
publicPtr = &pub
}
s := models.Server{
OrganizationID: orgID,
Hostname: req.Hostname,
PublicIPAddress: publicPtr,
PrivateIPAddress: req.PrivateIPAddress,
SSHUser: req.SSHUser,
SshKeyID: keyID,
Role: req.Role,
Status: "pending",
}
if req.Status != "" {
s.Status = strings.ToLower(req.Status)
}
if err := db.Create(&s).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create server")
return
}
utils.WriteJSON(w, http.StatusCreated, s)
}
}
// UpdateServer godoc
//
// @ID UpdateServer
// @Summary Update server (org scoped)
// @Description Partially update fields; changing ssh_key_id validates ownership.
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Param body body dto.UpdateServerRequest true "Fields to update"
// @Success 200 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid id / invalid json / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /servers/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
var server models.Server
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&server).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
return
}
var req dto.UpdateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
next := server
if req.Hostname != nil {
next.Hostname = *req.Hostname
}
if req.PrivateIPAddress != nil {
next.PrivateIPAddress = *req.PrivateIPAddress
}
if req.PublicIPAddress != nil {
next.PublicIPAddress = req.PublicIPAddress
}
if req.SSHUser != nil {
next.SSHUser = *req.SSHUser
}
if req.Role != nil {
next.Role = *req.Role
}
if req.Status != nil {
st := strings.ToLower(strings.TrimSpace(*req.Status))
if !validStatus(st) {
utils.WriteError(w, http.StatusBadRequest, "status_invalid", "invalid status")
return
}
next.Status = st
}
if req.SshKeyID != nil {
keyID, err := uuid.Parse(*req.SshKeyID)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid ssh_key_id")
return
}
if err := ensureKeyBelongsToOrg(orgID, keyID, db); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid or unauthorized ssh_key_id")
return
}
next.SshKeyID = keyID
}
if strings.EqualFold(next.Role, "bastion") &&
(next.PublicIPAddress == nil || strings.TrimSpace(*next.PublicIPAddress) == "") {
utils.WriteError(w, http.StatusBadRequest, "public_ip_required", "public_ip_address is required for role=bastion")
return
}
if err := db.Save(&next).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to update server")
return
}
utils.WriteJSON(w, http.StatusOK, server)
}
}
// DeleteServer godoc
//
// @ID DeleteServer
// @Summary Delete server (org scoped)
// @Description Permanently deletes the server.
// @Tags Servers
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /servers/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteServer(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&models.Server{}).Error; err != nil {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
if err := db.Where("id = ? AND organization_id = ?", id, orgID).Delete(&models.Server{}).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete server")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// ResetServerHostKey godoc
//
// @ID ResetServerHostKey
// @Summary Reset SSH host key (org scoped)
// @Description Clears the stored SSH host key for this server. The next SSH connection will re-learn the host key (trust-on-first-use).
// @Tags Servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Success 200 {object} dto.ServerResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "reset failed"
// @Router /servers/{id}/reset-hostkey [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ResetServerHostKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "id_invalid", "invalid id")
return
}
var server models.Server
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&server).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "server_not_found", "server not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get server")
return
}
// Clear stored host key so next SSH handshake will TOFU and persist a new one.
server.SSHHostKey = ""
server.SSHHostKeyAlgo = ""
if err := db.Save(&server).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to reset host key")
return
}
utils.WriteJSON(w, http.StatusOK, server)
}
}
// --- Helpers ---
func validStatus(status string) bool {
switch strings.ToLower(status) {
case "pending", "provisioning", "ready", "failed", "":
return true
default:
return false
}
}
func ensureKeyBelongsToOrg(orgID, keyID uuid.UUID, db *gorm.DB) error {
var k models.SshKey
if err := db.Where("id = ? AND organization_id = ?", keyID, orgID).First(&k).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("ssh key not found for this organization")
}
return err
}
return nil
}
package handlers
import (
"archive/zip"
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
)
// ListPublicSshKeys godoc
//
// @ID ListPublicSshKeys
// @Summary List ssh keys (org scoped)
// @Description Returns ssh keys for the organization in X-Org-ID.
// @Tags Ssh
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Success 200 {array} dto.SshResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list keys"
// @Router /ssh [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListPublicSshKeys(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var out []dto.SshResponse
if err := db.
Model(&models.SshKey{}).
Where("organization_id = ?", orgID).
// avoid selecting encrypted columns here
Select("id", "organization_id", "name", "public_key", "fingerprint", "created_at", "updated_at").
Order("created_at DESC").
Scan(&out).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to list ssh keys")
return
}
if out == nil {
out = []dto.SshResponse{}
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateSSHKey
//
// @ID CreateSSHKey
// @Summary Create ssh keypair (org scoped)
// @Description Generates an RSA or ED25519 keypair, saves it, and returns metadata. For RSA you may set bits (2048/3072/4096). Default is 4096. ED25519 ignores bits.
// @Tags Ssh
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateSSHRequest true "Key generation options"
// @Success 201 {object} dto.SshResponse
// @Failure 400 {string} string "invalid json / invalid bits"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "generation/create failed"
// @Router /ssh [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateSSHRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_payload", "invalid JSON payload")
return
}
keyType := "rsa"
if req.Type != nil && strings.TrimSpace(*req.Type) != "" {
keyType = strings.ToLower(strings.TrimSpace(*req.Type))
}
if keyType != "rsa" && keyType != "ed25519" {
utils.WriteError(w, http.StatusBadRequest, "invalid_type", "invalid type (rsa|ed25519)")
return
}
var (
privPEM string
pubAuth string
err error
)
switch keyType {
case "rsa":
bits := 4096
if req.Bits != nil {
if !allowedBits(*req.Bits) {
utils.WriteError(w, http.StatusBadRequest, "invalid_bits", "invalid bits (allowed: 2048, 3072, 4096)")
return
}
bits = *req.Bits
}
privPEM, pubAuth, err = GenerateRSAPEMAndAuthorized(bits, strings.TrimSpace(req.Comment))
case "ed25519":
if req.Bits != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_bits_for_type", "bits is only valid for RSA")
return
}
privPEM, pubAuth, err = GenerateEd25519PEMAndAuthorized(strings.TrimSpace(req.Comment))
}
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "keygen_failure", "key generation failed")
return
}
cipher, iv, tag, err := utils.EncryptForOrg(orgID, []byte(privPEM), db)
if err != nil {
http.Error(w, "encryption failed", http.StatusInternalServerError)
return
}
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "ssh_failure", "ssh public key parsing failed")
return
}
fp := ssh.FingerprintSHA256(parsed)
key := models.SshKey{
AuditFields: common.AuditFields{
OrganizationID: orgID,
},
Name: req.Name,
PublicKey: pubAuth,
EncryptedPrivateKey: cipher,
PrivateIV: iv,
PrivateTag: tag,
Fingerprint: fp,
}
if err := db.Create(&key).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to create ssh key")
return
}
utils.WriteJSON(w, http.StatusCreated, dto.SshResponse{
AuditFields: key.AuditFields,
Name: key.Name,
PublicKey: key.PublicKey,
Fingerprint: key.Fingerprint,
})
}
}
// GetSSHKey godoc
//
// @ID GetSSHKey
// @Summary Get ssh key by ID (org scoped)
// @Description Returns public key fields. Append `?reveal=true` to include the private key PEM.
// @Tags Ssh
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param reveal query bool false "Reveal private key PEM"
// @Success 200 {object} dto.SshResponse
// @Success 200 {object} dto.SshRevealResponse "When reveal=true"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /ssh/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
reveal := strings.EqualFold(r.URL.Query().Get("reveal"), "true")
if !reveal {
var out dto.SshResponse
if err := db.
Model(&models.SshKey{}).
Where("id = ? AND organization_id = ?", id, orgID).
Select("id", "organization_id", "name", "public_key", "fingerprint", "created_at", "updated_at").
Limit(1).
Scan(&out).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
return
}
if out.ID == uuid.Nil {
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
return
}
utils.WriteJSON(w, http.StatusOK, out)
return
}
var secret dto.SshResponse
if err := db.
Model(&models.SshKey{}).
Where("id = ? AND organization_id = ?", id, orgID).
// include the encrypted bits too
Select("id", "organization_id", "name", "public_key", "fingerprint",
"encrypted_private_key", "private_iv", "private_tag",
"created_at", "updated_at").
Limit(1).
Scan(&secret).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
return
}
if secret.ID == uuid.Nil {
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
return
}
plain, err := utils.DecryptForOrg(orgID, secret.EncryptedPrivateKey, secret.PrivateIV, secret.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
utils.WriteJSON(w, http.StatusOK, dto.SshRevealResponse{
SshResponse: dto.SshResponse{
AuditFields: secret.AuditFields,
Name: secret.Name,
PublicKey: secret.PublicKey,
Fingerprint: secret.Fingerprint,
},
PrivateKey: plain,
})
}
}
// DeleteSSHKey godoc
//
// @ID DeleteSSHKey
// @Summary Delete ssh keypair (org scoped)
// @Description Permanently deletes a keypair.
// @Tags Ssh
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /ssh/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
res := db.Where("id = ? AND organization_id = ?", id, orgID).
Delete(&models.SshKey{})
if res.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to delete ssh key")
return
}
if res.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DownloadSSHKey godoc
//
// @ID DownloadSSHKey
// @Summary Download ssh key files by ID (org scoped)
// @Description Download `part=public|private|both` of the keypair. `both` returns a zip file.
// @Tags Ssh
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "SSH Key ID (UUID)"
// @Param part query string true "Which part to download" Enums(public,private,both)
// @Success 200 {string} string "file content"
// @Failure 400 {string} string "invalid id / invalid part"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "download failed"
// @Router /ssh/{id}/download [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DownloadSSHKey(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_key_id", "invalid SSH Key ID")
return
}
var key models.SshKey
if err := db.Where("id = ? AND organization_id = ?", id, orgID).
First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "ssh_key_not_found", "ssh key not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to get ssh key")
return
}
part := strings.ToLower(r.URL.Query().Get("part"))
if part == "" {
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
mode := strings.ToLower(r.URL.Query().Get("mode"))
if mode != "" && mode != "json" {
utils.WriteError(w, http.StatusBadRequest, "invalid_mode", "invalid mode (json|attachment[default])")
return
}
if mode == "json" {
resp := dto.SshMaterialJSON{
ID: key.ID.String(),
Name: key.Name,
Fingerprint: key.Fingerprint,
}
switch part {
case "public":
pub := key.PublicKey
resp.PublicKey = &pub
resp.Filenames = []string{fmt.Sprintf("%s.pub", key.ID.String())}
utils.WriteJSON(w, http.StatusOK, resp)
return
case "private":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
resp.PrivatePEM = &plain
resp.Filenames = []string{fmt.Sprintf("%s.pem", key.ID.String())}
utils.WriteJSON(w, http.StatusOK, resp)
return
case "both":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("%s.pem", key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
b64 := utils.EncodeB64(buf.Bytes())
resp.ZipBase64 = &b64
resp.Filenames = []string{
fmt.Sprintf("%s.zip", key.ID.String()),
fmt.Sprintf("%s.pem", key.ID.String()),
fmt.Sprintf("%s.pub", key.ID.String()),
}
utils.WriteJSON(w, http.StatusOK, resp)
return
default:
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
}
switch part {
case "public":
filename := fmt.Sprintf("%s.pub", key.ID.String())
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(key.PublicKey))
return
case "private":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
filename := fmt.Sprintf("%s.pem", key.ID.String())
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte(plain))
return
case "both":
plain, err := utils.DecryptForOrg(orgID, key.EncryptedPrivateKey, key.PrivateIV, key.PrivateTag, db)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to decrypt ssh key")
return
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
_ = toZipFile(fmt.Sprintf("%s.pem", key.ID.String()), []byte(plain), zw)
_ = toZipFile(fmt.Sprintf("%s.pub", key.ID.String()), []byte(key.PublicKey), zw)
_ = zw.Close()
filename := fmt.Sprintf("ssh_key_%s.zip", key.ID.String())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write(buf.Bytes())
return
default:
utils.WriteError(w, http.StatusBadRequest, "invalid_ssh_part", "invalid part (public|private|both)")
return
}
}
}
// --- Helpers ---
func allowedBits(b int) bool {
return b == 2048 || b == 3072 || b == 4096
}
func GenerateRSA(bits int) (*rsa.PrivateKey, error) {
return rsa.GenerateKey(rand.Reader, bits)
}
func RSAPrivateToPEMAndAuthorized(priv *rsa.PrivateKey, comment string) (privPEM string, authorized string, err error) {
der := x509.MarshalPKCS1PrivateKey(priv)
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}
var buf bytes.Buffer
if err = pem.Encode(&buf, block); err != nil {
return "", "", err
}
pub, err := ssh.NewPublicKey(&priv.PublicKey)
if err != nil {
return "", "", err
}
auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
comment = strings.TrimSpace(comment)
if comment != "" {
auth += " " + comment
}
return buf.String(), auth, nil
}
func GenerateRSAPEMAndAuthorized(bits int, comment string) (string, string, error) {
priv, err := GenerateRSA(bits)
if err != nil {
return "", "", err
}
return RSAPrivateToPEMAndAuthorized(priv, comment)
}
func toZipFile(filename string, content []byte, zw *zip.Writer) error {
f, err := zw.Create(filename)
if err != nil {
return err
}
_, err = f.Write(content)
return err
}
func keyFilenamePrefix(pubAuth string) string {
pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubAuth))
if err != nil {
return "id_key"
}
switch pk.Type() {
case "ssh-ed25519":
return "id_ed25519"
case "ssh-rsa":
return "id_rsa"
default:
return "id_key"
}
}
func GenerateEd25519PEMAndAuthorized(comment string) (privPEM string, authorized string, err error) {
// Generate ed25519 keypair
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return "", "", err
}
// Private: PKCS#8 PEM
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", "", err
}
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
var buf bytes.Buffer
if err := pem.Encode(&buf, block); err != nil {
return "", "", err
}
// Public: OpenSSH authorized_key
sshPub, err := ssh.NewPublicKey(ed25519.PublicKey(pub))
if err != nil {
return "", "", err
}
auth := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
comment = strings.TrimSpace(comment)
if comment != "" {
auth += " " + comment
}
return buf.String(), auth, nil
}
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListTaints godoc
//
// @ID ListTaints
// @Summary List node pool taints (org scoped)
// @Description Returns node taints for the organization in X-Org-ID. Filters: `key`, `value`, and `q` (key contains). Add `include=node_pools` to include linked node pools.
// @Tags Taints
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param key query string false "Exact key"
// @Param value query string false "Exact value"
// @Param q query string false "key contains (case-insensitive)"
// @Success 200 {array} dto.TaintResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list node taints"
// @Router /taints [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListTaints(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
q := db.Where("organization_id = ?", orgID)
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
q = q.Where(`key = ?`, key)
}
if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" {
q = q.Where(`value = ?`, val)
}
if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" {
q = q.Where(`key ILIKE ?`, "%"+needle+"%")
}
var out []dto.TaintResponse
if err := q.Model(&models.Taint{}).Order("created_at DESC").Find(&out).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetTaint godoc
//
// @ID GetTaint
// @Summary Get node taint by ID (org scoped)
// @Tags Taints
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Success 200 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /taints/{id} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var out dto.TaintResponse
if err := db.Model(&models.Taint{}).Where("id = ? AND organization_id = ?", id, orgID).First(&out).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// CreateTaint godoc
//
// @ID CreateTaint
// @Summary Create node taint (org scoped)
// @Description Creates a taint.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param body body dto.CreateTaintRequest true "Taint payload"
// @Success 201 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /taints [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func CreateTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
var req dto.CreateTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
req.Key = strings.TrimSpace(req.Key)
req.Value = strings.TrimSpace(req.Value)
req.Effect = strings.TrimSpace(req.Effect)
if req.Key == "" || req.Value == "" || req.Effect == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing key/value/effect")
return
}
if _, ok := allowedEffects[req.Effect]; !ok {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
return
}
t := models.Taint{
OrganizationID: orgID,
Key: req.Key,
Value: req.Value,
Effect: req.Effect,
}
if err := db.Create(&t).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.TaintResponse{
ID: t.ID,
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
OrganizationID: t.OrganizationID,
CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
}
utils.WriteJSON(w, http.StatusCreated, out)
}
}
// UpdateTaint godoc
//
// @ID UpdateTaint
// @Summary Update node taint (org scoped)
// @Description Partially update taint fields.
// @Tags Taints
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Param body body dto.UpdateTaintRequest true "Fields to update"
// @Success 200 {object} dto.TaintResponse
// @Failure 400 {string} string "invalid id / invalid json"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /taints/{id} [patch]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func UpdateTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var t models.Taint
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var req dto.UpdateTaintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
next := t
if req.Key != nil {
next.Key = strings.TrimSpace(*req.Key)
}
if req.Value != nil {
next.Value = strings.TrimSpace(*req.Value)
}
if req.Effect != nil {
e := strings.TrimSpace(*req.Effect)
if e == "" {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "missing effect")
return
}
if _, ok := allowedEffects[e]; !ok {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "invalid effect")
return
}
next.Effect = e
}
if err := db.Save(&next).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := dto.TaintResponse{
ID: next.ID,
Key: next.Key,
Value: next.Value,
Effect: next.Effect,
OrganizationID: next.OrganizationID,
CreatedAt: next.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: next.UpdatedAt.UTC().Format(time.RFC3339),
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// DeleteTaint godoc
//
// @ID DeleteTaint
// @Summary Delete taint (org scoped)
// @Description Permanently deletes the taint.
// @Tags Taints
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param id path string true "Node Taint ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /taints/{id} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DeleteTaint(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_request", "bad request")
return
}
var row models.Taint
if err := db.Where("id = ? AND organization_id = ?", id, orgID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "not_found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Delete(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// --- Helpers ---
var allowedEffects = map[string]struct{}{
"NoSchedule": {},
"PreferNoSchedule": {},
"NoExecute": {},
}
package handlers
import (
"net/http"
"runtime"
"runtime/debug"
"strconv"
"github.com/glueops/autoglue/internal/utils"
"github.com/glueops/autoglue/internal/version"
)
type VersionResponse struct {
Version string `json:"version" example:"1.4.2"`
Commit string `json:"commit" example:"a1b2c3d"`
Built string `json:"built" example:"2025-11-08T12:34:56Z"`
BuiltBy string `json:"builtBy" example:"ci"`
Go string `json:"go" example:"go1.23.3"`
GOOS string `json:"goOS" example:"linux"`
GOARCH string `json:"goArch" example:"amd64"`
VCS string `json:"vcs,omitempty" example:"git"`
Revision string `json:"revision,omitempty" example:"a1b2c3d4e5f6abcdef"`
CommitTime string `json:"commitTime,omitempty" example:"2025-11-08T12:31:00Z"`
Modified *bool `json:"modified,omitempty" example:"false"`
}
// Version godoc
//
// @Summary Service version information
// @Description Returns build/runtime metadata for the running service.
// @Tags Meta
// @ID Version // operationId
// @Produce json
// @Success 200 {object} VersionResponse
// @Router /version [get]
func Version(w http.ResponseWriter, r *http.Request) {
resp := VersionResponse{
Version: version.Version,
Commit: version.Commit,
Built: version.Date,
BuiltBy: version.BuiltBy,
Go: runtime.Version(),
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
}
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs":
resp.VCS = s.Value
case "vcs.revision":
resp.Revision = s.Value
case "vcs.time":
resp.CommitTime = s.Value
case "vcs.modified":
if b, err := strconv.ParseBool(s.Value); err == nil {
resp.Modified = &b
}
}
}
}
utils.WriteJSON(w, http.StatusOK, resp)
}
package keys
import (
"encoding/base64"
"errors"
"strings"
)
func decode32ByteKey(s string) ([]byte, error) {
try := func(enc *base64.Encoding, v string) ([]byte, bool) {
if b, err := enc.DecodeString(v); err == nil && len(b) == 32 {
return b, true
}
return nil, false
}
// Try raw (no padding) variants first
if b, ok := try(base64.RawURLEncoding, s); ok {
return b, nil
}
if b, ok := try(base64.RawStdEncoding, s); ok {
return b, nil
}
// Try padded variants (add padding if missing)
pad := func(v string) string { return v + strings.Repeat("=", (4-len(v)%4)%4) }
if b, ok := try(base64.URLEncoding, pad(s)); ok {
return b, nil
}
if b, ok := try(base64.StdEncoding, pad(s)); ok {
return b, nil
}
return nil, errors.New("key must be 32 bytes in base64/base64url")
}
package keys
func Decrypt(encKeyB64, enc string) ([]byte, error) {
return decryptAESGCM(encKeyB64, enc)
}
package keys
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
type GenOpts struct {
Alg string // "RS256"|"RS384"|"RS512"|"EdDSA"
Bits int // RSA bits (2048/3072/4096). ignored for EdDSA
KID string // optional; if empty we generate one
NBF *time.Time
EXP *time.Time
}
func GenerateAndStore(db *gorm.DB, encKeyB64 string, opts GenOpts) (*models.SigningKey, error) {
if opts.KID == "" {
opts.KID = uuid.NewString()
}
var pubPEM, privPEM []byte
var alg = opts.Alg
switch alg {
case "RS256", "RS384", "RS512":
if opts.Bits == 0 {
opts.Bits = 3072
}
priv, err := rsa.GenerateKey(rand.Reader, opts.Bits)
if err != nil {
return nil, err
}
privDER := x509.MarshalPKCS1PrivateKey(priv)
privPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER})
pubDER := x509.MarshalPKCS1PublicKey(&priv.PublicKey)
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubDER})
case "EdDSA":
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
privPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})
pubDER, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return nil, err
}
pubPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
default:
return nil, fmt.Errorf("unsupported alg: %s", alg)
}
privateOut := string(privPEM)
if encKeyB64 != "" {
enc, err := encryptAESGCM(encKeyB64, privPEM)
if err != nil {
return nil, err
}
privateOut = enc
}
rec := models.SigningKey{
Kid: opts.KID,
Alg: alg,
Use: "sig",
IsActive: true,
PublicPEM: string(pubPEM),
PrivatePEM: privateOut,
NotBefore: opts.NBF,
ExpiresAt: opts.EXP,
}
if err := db.Create(&rec).Error; err != nil {
return nil, err
}
return &rec, nil
}
func encryptAESGCM(b64 string, plaintext []byte) (string, error) {
key, err := decode32ByteKey(b64)
if err != nil {
return "", err
}
if len(key) != 32 {
return "", errors.New("JWT_PRIVATE_ENC_KEY must be 32 bytes (base64url)")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, aead.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return "", err
}
out := aead.Seal(nonce, nonce, plaintext, nil)
return "enc:aesgcm:" + base64.RawStdEncoding.EncodeToString(out), nil
}
func decryptAESGCM(b64 string, enc string) ([]byte, error) {
if !bytes.HasPrefix([]byte(enc), []byte("enc:aesgcm:")) {
return nil, errors.New("not encrypted")
}
key, err := decode32ByteKey(b64)
if err != nil {
return nil, err
}
blob, err := base64.RawStdEncoding.DecodeString(enc[len("enc:aesgcm:"):])
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := aead.NonceSize()
if len(blob) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ct := blob[:nonceSize], blob[nonceSize:]
return aead.Open(nil, nonce, ct, nil)
}
package mapper
import (
"fmt"
"time"
"github.com/glueops/autoglue/internal/common"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
)
func ClusterToDTO(c models.Cluster) dto.ClusterResponse {
var bastion *dto.ServerResponse
if c.BastionServer != nil {
b := ServerToDTO(*c.BastionServer)
bastion = &b
}
var captainDomain *dto.DomainResponse
if c.CaptainDomainID != nil && c.CaptainDomain.ID != uuid.Nil {
dr := DomainToDTO(c.CaptainDomain)
captainDomain = &dr
}
var controlPlane *dto.RecordSetResponse
if c.ControlPlaneRecordSet != nil {
rr := RecordSetToDTO(*c.ControlPlaneRecordSet)
controlPlane = &rr
}
var cfqdn *string
if captainDomain != nil && controlPlane != nil {
fq := fmt.Sprintf("%s.%s", controlPlane.Name, captainDomain.DomainName)
cfqdn = &fq
}
var appsLB *dto.LoadBalancerResponse
if c.AppsLoadBalancer != nil {
lr := LoadBalancerToDTO(*c.AppsLoadBalancer)
appsLB = &lr
}
var glueOpsLB *dto.LoadBalancerResponse
if c.GlueOpsLoadBalancer != nil {
lr := LoadBalancerToDTO(*c.GlueOpsLoadBalancer)
glueOpsLB = &lr
}
nps := make([]dto.NodePoolResponse, 0, len(c.NodePools))
for _, np := range c.NodePools {
nps = append(nps, NodePoolToDTO(np))
}
return dto.ClusterResponse{
ID: c.ID,
Name: c.Name,
CaptainDomain: captainDomain,
ControlPlaneRecordSet: controlPlane,
ControlPlaneFQDN: cfqdn,
AppsLoadBalancer: appsLB,
GlueOpsLoadBalancer: glueOpsLB,
BastionServer: bastion,
Provider: c.Provider,
Region: c.Region,
Status: c.Status,
LastError: c.LastError,
RandomToken: c.RandomToken,
CertificateKey: c.CertificateKey,
NodePools: nps,
DockerImage: c.DockerImage,
DockerTag: c.DockerTag,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
func NodePoolToDTO(np models.NodePool) dto.NodePoolResponse {
labels := make([]dto.LabelResponse, 0, len(np.Labels))
for _, l := range np.Labels {
labels = append(labels, dto.LabelResponse{
Key: l.Key,
Value: l.Value,
})
}
annotations := make([]dto.AnnotationResponse, 0, len(np.Annotations))
for _, a := range np.Annotations {
annotations = append(annotations, dto.AnnotationResponse{
Key: a.Key,
Value: a.Value,
})
}
taints := make([]dto.TaintResponse, 0, len(np.Taints))
for _, t := range np.Taints {
taints = append(taints, dto.TaintResponse{
Key: t.Key,
Value: t.Value,
Effect: t.Effect,
})
}
servers := make([]dto.ServerResponse, 0, len(np.Servers))
for _, s := range np.Servers {
servers = append(servers, ServerToDTO(s))
}
return dto.NodePoolResponse{
AuditFields: common.AuditFields{
ID: np.ID,
OrganizationID: np.OrganizationID,
CreatedAt: np.CreatedAt,
UpdatedAt: np.UpdatedAt,
},
Name: np.Name,
Role: dto.NodeRole(np.Role),
Labels: labels,
Annotations: annotations,
Taints: taints,
Servers: servers,
}
}
func ServerToDTO(s models.Server) dto.ServerResponse {
return dto.ServerResponse{
ID: s.ID,
Hostname: s.Hostname,
PrivateIPAddress: s.PrivateIPAddress,
PublicIPAddress: s.PublicIPAddress,
Role: s.Role,
Status: s.Status,
SSHUser: s.SSHUser,
SshKeyID: s.SshKeyID,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func DomainToDTO(d models.Domain) dto.DomainResponse {
return dto.DomainResponse{
ID: d.ID.String(),
OrganizationID: d.OrganizationID.String(),
DomainName: d.DomainName,
ZoneID: d.ZoneID,
Status: d.Status,
LastError: d.LastError,
CredentialID: d.CredentialID.String(),
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func RecordSetToDTO(rs models.RecordSet) dto.RecordSetResponse {
return dto.RecordSetResponse{
ID: rs.ID.String(),
DomainID: rs.DomainID.String(),
Name: rs.Name,
Type: rs.Type,
TTL: rs.TTL,
Values: []byte(rs.Values),
Fingerprint: rs.Fingerprint,
Status: rs.Status,
Owner: rs.Owner,
LastError: rs.LastError,
CreatedAt: rs.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: rs.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func LoadBalancerToDTO(lb models.LoadBalancer) dto.LoadBalancerResponse {
return dto.LoadBalancerResponse{
ID: lb.ID,
OrganizationID: lb.OrganizationID,
Name: lb.Name,
Kind: lb.Kind,
PublicIPAddress: lb.PublicIPAddress,
PrivateIPAddress: lb.PrivateIPAddress,
CreatedAt: lb.CreatedAt,
UpdatedAt: lb.UpdatedAt,
}
}
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
)
type Account struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"-"`
Provider string `gorm:"not null" json:"provider"`
Subject string `gorm:"not null" json:"subject"`
Email *string `json:"email,omitempty"`
EmailVerified bool `gorm:"not null;default:false" json:"email_verified"`
Profile datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'" json:"profile"`
SecretHash *string `json:"-"`
CreatedAt time.Time `gorm:"type:timestamptz;column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type Action struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Label string `gorm:"type:varchar(255);not null;uniqueIndex" json:"label"`
Description string `gorm:"type:text;not null" json:"description"`
MakeTarget string `gorm:"type:varchar(255);not null;uniqueIndex" json:"make_target"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
}
package models
import (
"github.com/glueops/autoglue/internal/common"
)
type Annotation struct {
common.AuditFields `gorm:"embedded"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
NodePools []NodePool `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type APIKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
OrgID *uuid.UUID `json:"org_id,omitempty" format:"uuid"`
Scope string `gorm:"not null;default:''" json:"scope"`
Purpose string `json:"purpose"`
ClusterID *uuid.UUID `json:"cluster_id,omitempty"`
IsEphemeral bool `json:"is_ephemeral"`
Name string `gorm:"not null;default:''" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
SecretHash *string `json:"-"`
UserID *uuid.UUID `json:"user_id,omitempty" format:"uuid"`
ExpiresAt *time.Time `json:"expires_at,omitempty" format:"date-time"`
Revoked bool `gorm:"not null;default:false" json:"revoked"`
Prefix *string `json:"prefix,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type Backup struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_credential,priority:1"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Enabled bool `gorm:"not null;default:false" json:"enabled"`
CredentialID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:uniq_org_credential,priority:2" json:"credential_id"`
Credential Credential `gorm:"foreignKey:CredentialID" json:"credential,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}
package models
import (
"time"
"github.com/google/uuid"
)
const (
ClusterRunStatusQueued = "queued"
ClusterRunStatusRunning = "running"
ClusterRunStatusSuccess = "success"
ClusterRunStatusFailed = "failed"
ClusterRunStatusCanceled = "canceled"
)
type ClusterRun struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
ClusterID uuid.UUID `json:"cluster_id" gorm:"type:uuid;index"`
Action string `json:"action" gorm:"type:text;not null"`
Status string `json:"status" gorm:"type:text;not null"`
Error string `json:"error" gorm:"type:text;not null"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
FinishedAt time.Time `json:"finished_at,omitempty" gorm:"type:timestamptz" format:"date-time"`
}
package models
import (
"time"
"github.com/google/uuid"
)
const (
ClusterStatusPrePending = "pre_pending" // needs validation
ClusterStatusIncomplete = "incomplete" // invalid/missing shape
ClusterStatusPending = "pending" // valid shape, waiting for provisioning
ClusterStatusProvisioning = "provisioning"
ClusterStatusReady = "ready"
ClusterStatusFailed = "failed" // provisioning/runtime failure
ClusterStatusBootstrapping = "bootstrapping"
)
type Cluster struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Provider string `json:"provider"`
Region string `json:"region"`
Status string `gorm:"type:varchar(20);not null;default:'pre_pending'" json:"status"`
LastError string `gorm:"type:text;not null;default:''" json:"last_error"`
CaptainDomainID *uuid.UUID `gorm:"type:uuid" json:"captain_domain_id"`
CaptainDomain Domain `gorm:"foreignKey:CaptainDomainID" json:"captain_domain"`
ControlPlaneRecordSetID *uuid.UUID `gorm:"type:uuid" json:"control_plane_record_set_id,omitempty"`
ControlPlaneRecordSet *RecordSet `gorm:"foreignKey:ControlPlaneRecordSetID" json:"control_plane_record_set,omitempty"`
AppsLoadBalancerID *uuid.UUID `gorm:"type:uuid" json:"apps_load_balancer_id,omitempty"`
AppsLoadBalancer *LoadBalancer `gorm:"foreignKey:AppsLoadBalancerID" json:"apps_load_balancer,omitempty"`
GlueOpsLoadBalancerID *uuid.UUID `gorm:"type:uuid" json:"glueops_load_balancer_id,omitempty"`
GlueOpsLoadBalancer *LoadBalancer `gorm:"foreignKey:GlueOpsLoadBalancerID" json:"glueops_load_balancer,omitempty"`
BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"`
BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"`
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
KubeIV string `json:"-"`
KubeTag string `json:"-"`
DockerImage string `json:"docker_image"`
DockerTag string `json:"docker_tag"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
)
type Credential struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_provider_scopekind_scope,priority:1" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Provider string `gorm:"type:varchar(50);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:2;index:idx_provider_kind"`
Kind string `gorm:"type:varchar(50);not null;index:idx_provider_kind;index:idx_kind_scope"`
ScopeKind string `gorm:"type:varchar(20);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:3"`
Scope datatypes.JSON `gorm:"type:jsonb;not null;default:'{}';index:idx_kind_scope"`
ScopeFingerprint string `gorm:"type:char(64);not null;uniqueIndex:uniq_org_provider_scopekind_scope,priority:4;index"`
SchemaVersion int `gorm:"not null;default:1"`
Name string `gorm:"type:varchar(100);not null;default:''"`
ScopeVersion int `gorm:"not null;default:1"`
AccountID string `gorm:"type:varchar(32)"`
Region string `gorm:"type:varchar(32)"`
EncryptedData string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"not null"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
}
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
)
type Domain struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uniq_org_domain,priority:1"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
DomainName string `gorm:"type:varchar(253);not null;uniqueIndex:uniq_org_domain,priority:2"`
ZoneID string `gorm:"type:varchar(128);not null;default:''"` // backfilled for R53 (e.g. "/hostedzone/Z123...")
Status string `gorm:"type:varchar(20);not null;default:'pending'"` // pending, provisioning, ready, failed
LastError string `gorm:"type:text;not null;default:''"`
CredentialID uuid.UUID `gorm:"type:uuid;not null" json:"credential_id"`
Credential Credential `gorm:"foreignKey:CredentialID" json:"credential,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}
type RecordSet struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
DomainID uuid.UUID `gorm:"type:uuid;not null;index"`
Domain Domain `gorm:"foreignKey:DomainID;constraint:OnDelete:CASCADE"`
Name string `gorm:"type:varchar(253);not null"` // e.g. "endpoint" (relative to DomainName)
Type string `gorm:"type:varchar(10);not null;index"` // A, AAAA, CNAME, TXT, MX, SRV, NS, CAA...
TTL *int `gorm:""` // nil for alias targets (Route 53 ignores TTL for alias)
Values datatypes.JSON `gorm:"type:jsonb;not null;default:'[]'"`
Fingerprint string `gorm:"type:char(64);not null;index"` // sha256 of canonical(name,type,ttl,values|alias)
Status string `gorm:"type:varchar(20);not null;default:'pending'"`
Owner string `gorm:"type:varchar(16);not null;default:'unknown'"` // 'autoglue' | 'external' | 'unknown'
LastError string `gorm:"type:text;not null;default:''"`
_ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:1"` // tag holder
_ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:2"`
_ struct{} `gorm:"uniqueIndex:uniq_domain_name_type,priority:3"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}
package models
import (
"time"
"gorm.io/datatypes"
)
type Job struct {
ID string `gorm:"type:varchar;primaryKey" json:"id"` // no default; supply from app
QueueName string `gorm:"type:varchar;not null" json:"queue_name"`
Status string `gorm:"type:varchar;not null" json:"status"`
Arguments datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'"`
Result datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'"`
LastError *string `gorm:"type:varchar"`
RetryCount int `gorm:"not null;default:0"`
MaxRetry int `gorm:"not null;default:0"`
RetryInterval int `gorm:"not null;default:0"`
ScheduledAt time.Time `gorm:"type:timestamptz;default:now();index"`
StartedAt *time.Time `gorm:"type:timestamptz;index"`
CreatedAt time.Time `gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}
package models
import (
"github.com/glueops/autoglue/internal/common"
)
type Label struct {
common.AuditFields
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
NodePools []NodePool `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type LoadBalancer struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
Organization Organization `json:"organization" gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"not null"`
Kind string `json:"kind" gorm:"not null"`
PublicIPAddress string `json:"public_ip_address" gorm:"not null"`
PrivateIPAddress string `json:"private_ip_address" gorm:"not null"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type MasterKey struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
Key string `gorm:"not null"`
IsActive bool `gorm:"default:true"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type Membership struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"-"`
OrganizationID uuid.UUID `gorm:"index;not null" json:"org_id" format:"uuid"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"-"`
Role string `gorm:"not null;default:'member'" json:"role"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}
package models
import (
"github.com/glueops/autoglue/internal/common"
)
type NodePool struct {
common.AuditFields
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Servers []Server `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
Annotations []Annotation `gorm:"many2many:node_annotations;constraint:OnDelete:CASCADE" json:"annotations,omitempty"`
Labels []Label `gorm:"many2many:node_labels;constraint:OnDelete:CASCADE" json:"labels,omitempty"`
Taints []Taint `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"taints,omitempty"`
Clusters []Cluster `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"clusters,omitempty"`
//Topology string `gorm:"not null,default:'stacked'" json:"topology,omitempty"` // stacked or external
Role string `gorm:"not null,default:'worker'" json:"role,omitempty"` // master, worker, or etcd (etcd only if topology = external
}
package models
import (
"time"
"github.com/google/uuid"
)
type OrganizationKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
MasterKeyID uuid.UUID `gorm:"type:uuid;not null"`
MasterKey MasterKey `gorm:"foreignKey:MasterKeyID;constraint:OnDelete:CASCADE" json:"master_key"`
EncryptedKey string `gorm:"not null"`
IV string `gorm:"not null"`
Tag string `gorm:"not null"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type Organization struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Name string `gorm:"not null" json:"name"`
Domain *string `gorm:"index" json:"domain"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type RefreshToken struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id"`
FamilyID uuid.UUID `gorm:"type:uuid;index;not null" json:"family_id"`
TokenHash string `gorm:"uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
}
package models
import (
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Server struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Hostname string `json:"hostname"`
PublicIPAddress *string `json:"public_ip_address,omitempty"`
PrivateIPAddress string `gorm:"not null" json:"private_ip_address"`
SSHUser string `gorm:"not null" json:"ssh_user"`
SshKeyID uuid.UUID `gorm:"type:uuid;not null" json:"ssh_key_id"`
SshKey SshKey `gorm:"foreignKey:SshKeyID" json:"ssh_key"`
Role string `gorm:"not null" json:"role" enums:"master,worker,bastion"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'" json:"status" enums:"pending, provisioning, ready, failed"` // pending, provisioning, ready, failed
NodePools []NodePool `gorm:"many2many:node_servers;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
SSHHostKey string `gorm:"column:ssh_host_key"`
SSHHostKeyAlgo string `gorm:"column:ssh_host_key_algo"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at" format:"date-time"`
}
func (s *Server) BeforeSave(tx *gorm.DB) error {
role := strings.ToLower(strings.TrimSpace(s.Role))
if role == "bastion" {
if s.PublicIPAddress == nil || strings.TrimSpace(*s.PublicIPAddress) == "" {
return errors.New("public_ip_address is required for role=bastion")
}
}
return nil
}
package models
import (
"time"
"github.com/google/uuid"
)
type SigningKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Kid string `gorm:"uniqueIndex;not null" json:"kid"` // key id (header 'kid')
Alg string `gorm:"not null" json:"alg"` // RS256|RS384|RS512|EdDSA
Use string `gorm:"not null;default:'sig'" json:"use"` // "sig"
IsActive bool `gorm:"not null;default:true" json:"is_active"`
PublicPEM string `gorm:"type:text;not null" json:"-"`
PrivatePEM string `gorm:"type:text;not null" json:"-"`
NotBefore *time.Time `json:"-"`
ExpiresAt *time.Time `json:"-"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
RotatedFrom *uuid.UUID `json:"-"` // previous key id, if any
}
package models
import (
"github.com/glueops/autoglue/internal/common"
)
type SshKey struct {
common.AuditFields
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
PublicKey string `gorm:"not null"`
EncryptedPrivateKey string `gorm:"not null"`
PrivateIV string `gorm:"not null"`
PrivateTag string `gorm:"not null"`
Fingerprint string `gorm:"not null;index" json:"fingerprint"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type Taint struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Key string `gorm:"not null" json:"key"`
Value string `gorm:"not null" json:"value"`
Effect string `gorm:"not null" json:"effect"`
NodePools []NodePool `gorm:"many2many:node_taints;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type UserEmail struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
UserID uuid.UUID `gorm:"index;not null" json:"user_id" format:"uuid"`
User User `gorm:"foreignKey:UserID" json:"user"`
Email string `gorm:"not null" json:"email"`
IsVerified bool `gorm:"not null;default:false" json:"is_verified"`
IsPrimary bool `gorm:"not null;default:false" json:"is_primary"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}
package models
import (
"time"
"github.com/google/uuid"
)
type User struct {
// example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
DisplayName *string `json:"display_name,omitempty"`
PrimaryEmail *string `json:"primary_email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
IsDisabled bool `json:"is_disabled"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at" format:"date-time"`
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at" format:"date-time"`
}
package pgtest
import (
"fmt"
"log"
"sync"
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var (
once sync.Once
epg *embeddedpostgres.EmbeddedPostgres
gdb *gorm.DB
initErr error
dsn string
)
// initDB is called once via sync.Once. It starts embedded Postgres,
// opens a GORM connection and runs the same migrations as NewRuntime.
func initDB() {
const port uint32 = 55432
cfg := embeddedpostgres.
DefaultConfig().
Database("autoglue_test").
Username("autoglue").
Password("autoglue").
Port(port).
StartTimeout(30 * time.Second)
epg = embeddedpostgres.NewDatabase(cfg)
if err := epg.Start(); err != nil {
initErr = fmt.Errorf("start embedded postgres: %w", err)
return
}
dsn = fmt.Sprintf(
"host=127.0.0.1 port=%d user=%s password=%s dbname=%s sslmode=disable",
port,
"autoglue",
"autoglue",
"autoglue_test",
)
dbConn, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
initErr = fmt.Errorf("open gorm: %w", err)
return
}
// Use the same model list as app.NewRuntime so schema matches prod
if err := db.Run(
dbConn,
&models.Job{},
&models.MasterKey{},
&models.SigningKey{},
&models.User{},
&models.Organization{},
&models.Account{},
&models.Membership{},
&models.APIKey{},
&models.UserEmail{},
&models.RefreshToken{},
&models.OrganizationKey{},
&models.SshKey{},
&models.Server{},
&models.Taint{},
&models.Label{},
&models.Annotation{},
&models.NodePool{},
&models.Cluster{},
&models.Credential{},
&models.Domain{},
&models.RecordSet{},
); err != nil {
initErr = fmt.Errorf("migrate: %w", err)
return
}
gdb = dbConn
}
// DB returns a lazily-initialized *gorm.DB backed by embedded Postgres.
//
// Call this from any test that needs a real DB. If init fails, the test
// will fail immediately with a clear message.
func DB(t *testing.T) *gorm.DB {
t.Helper()
once.Do(initDB)
if initErr != nil {
t.Fatalf("failed to init embedded postgres: %v", initErr)
}
return gdb
}
// URL returns the DSN for the embedded Postgres instance, useful for code
// that expects a DB URL (e.g. bg.NewJobs).
func URL(t *testing.T) string {
t.Helper()
DB(t) // ensure initialized
return dsn
}
// Stop stops the embedded Postgres process. Call from TestMain in at
// least one package, or let the OS clean it up on process exit.
func Stop() {
if epg != nil {
if err := epg.Stop(); err != nil {
log.Printf("stop embedded postgres: %v", err)
}
}
}
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
)
var (
ErrNoActiveMasterKey = errors.New("no active master key found")
ErrInvalidOrgID = errors.New("invalid organization ID")
ErrCredentialNotFound = errors.New("credential not found")
ErrInvalidMasterKeyLen = errors.New("invalid master key length")
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return nil, fmt.Errorf("rand: %w", err)
}
return b, nil
}
func encryptAESGCM(plaintext, key []byte) (cipherNoTag, iv, tag []byte, _ error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, nil, fmt.Errorf("cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, nil, fmt.Errorf("gcm: %w", err)
}
if gcm.NonceSize() != 12 {
return nil, nil, nil, fmt.Errorf("unexpected nonce size: %d", gcm.NonceSize())
}
iv, err = randomBytes(gcm.NonceSize())
if err != nil {
return nil, nil, nil, err
}
// Goβs GCM returns ciphertext||tag, with 16-byte tag.
cipherWithTag := gcm.Seal(nil, iv, plaintext, nil)
if len(cipherWithTag) < 16 {
return nil, nil, nil, errors.New("ciphertext too short")
}
tagLen := 16
cipherNoTag = cipherWithTag[:len(cipherWithTag)-tagLen]
tag = cipherWithTag[len(cipherWithTag)-tagLen:]
return cipherNoTag, iv, tag, nil
}
func decryptAESGCM(cipherNoTag, key, iv, tag []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
if gcm.NonceSize() != len(iv) {
return nil, fmt.Errorf("bad nonce size: %d", len(iv))
}
// Reattach tag
cipherWithTag := append(append([]byte{}, cipherNoTag...), tag...)
plain, err := gcm.Open(nil, iv, cipherWithTag, nil)
if err != nil {
return nil, fmt.Errorf("gcm open: %w", err)
}
return plain, nil
}
func EncodeB64(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}
func DecodeB64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
package utils
import (
"encoding/json"
"net/http"
)
// ErrorResponse is a simple, reusable error payload.
// swagger:model ErrorResponse
type ErrorResponse struct {
// A machine-readable error code, e.g. "validation_error"
// example: validation_error
Code string `json:"code"`
// Human-readable message
// example: slug is required
Message string `json:"message"`
}
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func WriteError(w http.ResponseWriter, status int, code, msg string) {
WriteJSON(w, status, ErrorResponse{Code: code, Message: msg})
}
package utils
import (
"encoding/base64"
"errors"
"fmt"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func getMasterKey(db *gorm.DB) ([]byte, error) {
var mk models.MasterKey
if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveMasterKey
}
return nil, fmt.Errorf("querying master key: %w", err)
}
keyBytes, err := base64.StdEncoding.DecodeString(mk.Key)
if err != nil {
return nil, fmt.Errorf("decoding master key: %w", err)
}
if len(keyBytes) != 32 {
return nil, fmt.Errorf("%w: got %d, want 32", ErrInvalidMasterKeyLen, len(keyBytes))
}
return keyBytes, nil
}
func getOrCreateTenantKey(orgID string, db *gorm.DB) ([]byte, error) {
var orgKey models.OrganizationKey
err := db.Where("organization_id = ?", orgID).First(&orgKey).Error
if err == nil {
encKeyB64 := orgKey.EncryptedKey
ivB64 := orgKey.IV
tagB64 := orgKey.Tag
encryptedKey, err := DecodeB64(encKeyB64)
if err != nil {
return nil, fmt.Errorf("decode enc key: %w", err)
}
iv, err := DecodeB64(ivB64)
if err != nil {
return nil, fmt.Errorf("decode iv: %w", err)
}
tag, err := DecodeB64(tagB64)
if err != nil {
return nil, fmt.Errorf("decode tag: %w", err)
}
masterKey, err := getMasterKey(db)
if err != nil {
return nil, err
}
return decryptAESGCM(encryptedKey, masterKey, iv, tag)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// Create new tenant key and wrap with the current master key
orgUUID, err := uuid.Parse(orgID)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidOrgID, err)
}
tenantKey, err := randomBytes(32)
if err != nil {
return nil, fmt.Errorf("tenant key gen: %w", err)
}
masterKey, err := getMasterKey(db)
if err != nil {
return nil, err
}
encrypted, iv, tag, err := encryptAESGCM(tenantKey, masterKey)
if err != nil {
return nil, fmt.Errorf("wrap tenant key: %w", err)
}
var mk models.MasterKey
if err := db.Where("is_active = ?", true).Order("created_at DESC").First(&mk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNoActiveMasterKey
}
return nil, fmt.Errorf("querying master key: %w", err)
}
orgKey = models.OrganizationKey{
OrganizationID: orgUUID,
MasterKeyID: mk.ID,
EncryptedKey: EncodeB64(encrypted),
IV: EncodeB64(iv),
Tag: EncodeB64(tag),
}
if err := db.Create(&orgKey).Error; err != nil {
return nil, fmt.Errorf("persist org key: %w", err)
}
return tenantKey, nil
}
package utils
import (
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
)
func EncryptForOrg(orgID uuid.UUID, plaintext []byte, db *gorm.DB) (cipherB64, ivB64, tagB64 string, err error) {
tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
if err != nil {
return "", "", "", err
}
ct, iv, tag, err := encryptAESGCM(plaintext, tenantKey)
if err != nil {
return "", "", "", err
}
return EncodeB64(ct), EncodeB64(iv), EncodeB64(tag), nil
}
func DecryptForOrg(orgID uuid.UUID, cipherB64, ivB64, tagB64 string, db *gorm.DB) (string, error) {
tenantKey, err := getOrCreateTenantKey(orgID.String(), db)
if err != nil {
return "", err
}
ct, err := DecodeB64(cipherB64)
if err != nil {
return "", fmt.Errorf("decode cipher: %w", err)
}
iv, err := DecodeB64(ivB64)
if err != nil {
return "", fmt.Errorf("decode iv: %w", err)
}
tag, err := DecodeB64(tagB64)
if err != nil {
return "", fmt.Errorf("decode tag: %w", err)
}
plain, err := decryptAESGCM(ct, tenantKey, iv, tag)
if err != nil {
return "", err
}
return string(plain), nil
}
package version
import (
"fmt"
"runtime"
"runtime/debug"
)
var (
Version = "dev"
Commit = "none"
Date = "unknown"
BuiltBy = "local"
)
func Info() string {
v := fmt.Sprintf("Version: %s\nCommit: %s\nBuilt: %s\nBuiltBy: %s\nGo: %s %s/%s",
Version, Commit, Date, BuiltBy, runtime.Version(), runtime.GOOS, runtime.GOARCH)
// Include VCS info from embedded build metadata (if available)
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs":
v += fmt.Sprintf("\nVCS: %s", s.Value)
case "vcs.revision":
v += fmt.Sprintf("\nRevision: %s", s.Value)
case "vcs.time":
v += fmt.Sprintf("\nCommitTime: %s", s.Value)
case "vcs.modified":
v += fmt.Sprintf("\nModified: %s", s.Value)
}
}
}
return v
}
package web
import (
"net/http"
"net/http/httputil"
"net/url"
)
func DevProxy(target string) (http.Handler, error) {
u, err := url.Parse(target)
if err != nil {
return nil, err
}
p := httputil.NewSingleHostReverseProxy(u)
return p, nil
}
package web
import (
"embed"
"io"
"io/fs"
"net/http"
"path"
"path/filepath"
"strings"
"time"
)
// NOTE: Vite outputs to web/dist with assets in dist/assets.
// If you add more nested folders in the future, include them here too.
//go:embed dist
var distFS embed.FS
// spaFileSystem serves embedded dist/ files with SPA fallback to index.html
type spaFileSystem struct {
fs fs.FS
}
func (s spaFileSystem) Open(name string) (fs.File, error) {
// Normalize, strip leading slash
if strings.HasPrefix(name, "/") {
name = name[1:]
}
// Try exact file
f, err := s.fs.Open(name)
if err == nil {
return f, nil
}
// If the requested file doesn't exist, fall back to index.html for SPA routes
// BUT only if it's not obviously a static asset extension
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg",
".svg", ".webp", ".gif", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm", ".br", ".gz":
return nil, fs.ErrNotExist
}
return s.fs.Open("index.html")
}
func newDistFS() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// SPAHandler returns an http.Handler that serves the embedded UI (with caching)
func SPAHandler() (http.Handler, error) {
sub, err := newDistFS()
if err != nil {
return nil, err
}
spa := spaFileSystem{fs: sub}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") ||
r.URL.Path == "/api" ||
strings.HasPrefix(r.URL.Path, "/swagger") ||
strings.HasPrefix(r.URL.Path, "/db-studio") ||
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
http.NotFound(w, r)
return
}
raw := strings.TrimSpace(r.URL.Path)
if raw == "" || raw == "/" {
raw = "/index.html"
}
clean := path.Clean("/" + raw) // nosemgrep: autoglue.filesystem.no-path-clean
filePath := strings.TrimPrefix(clean, "/")
if filePath == "" {
filePath = "index.html"
}
// Try compressed variants for assets and HTML
// NOTE: we only change *Content-Encoding*; Content-Type derives from original ext
// Always vary on Accept-Encoding
w.Header().Add("Vary", "Accept-Encoding")
enc := r.Header.Get("Accept-Encoding")
if tryServeCompressed(w, r, spa, filePath, enc) {
return
}
// Fallback: normal open (or SPA fallback)
f, err := spa.Open(filePath)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
if strings.HasSuffix(filePath, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
http.ServeContent(w, r, filePath, modTime, file{f})
}), nil
}
func tryServeCompressed(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, enc string) bool {
wantsBR := strings.Contains(enc, "br")
wantsGZ := strings.Contains(enc, "gzip")
type cand struct {
logical string // MIME/type decision uses this (uncompressed name)
physical string // actual file we open (with .br/.gz)
enc string
}
var cands []cand
// 1) direct compressed variant of requested path (rare for SPA routes, but cheap to try)
if wantsBR {
cands = append(cands, cand{logical: filePath, physical: filePath + ".br", enc: "br"})
}
if wantsGZ {
cands = append(cands, cand{logical: filePath, physical: filePath + ".gz", enc: "gzip"})
}
// 2) SPA route: fall back to compressed index.html
if filepath.Ext(filePath) == "" {
if wantsBR {
cands = append(cands, cand{logical: "index.html", physical: "index.html.br", enc: "br"})
}
if wantsGZ {
cands = append(cands, cand{logical: "index.html", physical: "index.html.gz", enc: "gzip"})
}
}
for _, c := range cands {
f, err := spa.fs.Open(c.physical) // open EXACT path so we don't accidentally get SPA fallback
if err != nil {
continue
}
defer f.Close()
// Cache headers
if strings.HasSuffix(c.logical, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
if ct := mimeByExt(path.Ext(c.logical)); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Content-Encoding", c.enc)
w.Header().Add("Vary", "Accept-Encoding")
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
// Serve the precompressed bytes
http.ServeContent(w, r, c.physical, modTime, file{f})
return true
}
return false
}
func serveIfExists(w http.ResponseWriter, r *http.Request, spa spaFileSystem, filePath, ext, encoding string) bool {
cf := filePath + ext
f, err := spa.Open(cf)
if err != nil {
return false
}
defer f.Close()
// Set caching headers
if strings.HasSuffix(filePath, ".html") {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
// Preserve original content type by extension of *uncompressed* file
if ct := mimeByExt(path.Ext(filePath)); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Content-Encoding", encoding)
info, _ := f.Stat()
modTime := time.Now()
if info != nil {
modTime = info.ModTime()
}
// Serve the compressed bytes as an io.ReadSeeker if possible
http.ServeContent(w, r, cf, modTime, file{f})
return true
}
func mimeByExt(ext string) string {
switch strings.ToLower(ext) {
case ".html":
return "text/html; charset=utf-8"
case ".js":
return "application/javascript"
case ".css":
return "text/css; charset=utf-8"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".ico":
return "image/x-icon"
case ".woff2":
return "font/woff2"
case ".woff":
return "font/woff"
default:
return "" // let Go sniff if empty
}
}
// file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent)
type file struct{ fs.File }
func (f file) Seek(offset int64, whence int) (int64, error) {
if s, ok := f.File.(io.Seeker); ok {
return s.Seek(offset, whence)
}
// Fallback: not seekable
return 0, fs.ErrInvalid
}
export const metaApi = {
footer: async () => {
const res = await fetch("/api/v1/version", { cache: "no-store" })
if (!res.ok) throw new Error("failed to fetch version")
return (await res.json()) as {
built: string
builtBy: string
commit: string
go: string
goArch: string
goOS: string
version: string
}
},
}
// api/with-refresh.ts
import { authStore, type TokenPair } from "@/auth/store.ts"
import { API_BASE } from "@/sdkClient.ts"
let inflightRefresh: Promise | null = null
async function doRefresh(): Promise {
const tokens = authStore.get()
if (!tokens?.refresh_token) return false
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: tokens.refresh_token }),
})
if (!res.ok) return false
const next = (await res.json()) as TokenPair
authStore.set(next)
return true
} catch {
return false
}
}
async function refreshOnce(): Promise {
if (!inflightRefresh) {
inflightRefresh = doRefresh().finally(() => {
inflightRefresh = null
})
}
return inflightRefresh
}
function isUnauthorized(err: any): boolean {
return (
err?.status === 401 ||
err?.cause?.status === 401 ||
err?.response?.status === 401 ||
(err instanceof Response && err.status === 401)
)
}
export async function withRefresh(fn: () => Promise): Promise {
// Optional: attempt a proactive refresh if close to expiry
if (authStore.willExpireSoon?.(30)) {
await refreshOnce()
}
try {
return await fn()
} catch (error) {
if (!isUnauthorized(error)) throw error
const ok = await refreshOnce()
if (!ok) throw error
return await fn()
}
}
const KEY = "autoglue.org"
let cache: string | null = localStorage.getItem(KEY)
export const orgStore = {
get(): string | null {
return cache
},
set(id: string) {
cache = id
localStorage.setItem(KEY, id)
window.dispatchEvent(new CustomEvent("autoglue:org-change", { detail: id }))
},
subscribe(fn: (id: string | null) => void) {
const onCustom = (e: Event) => fn((e as CustomEvent).detail ?? null)
const onStorage = (e: StorageEvent) => {
if (e.key === KEY) {
cache = e.newValue
fn(cache)
}
}
window.addEventListener("autoglue:org-change", onCustom as EventListener)
window.addEventListener("storage", onStorage)
return () => {
window.removeEventListener("autoglue:org-change", onCustom as EventListener)
window.removeEventListener("storage", onStorage)
}
},
}
export type TokenPair = {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
const KEY = "autoglue.tokens"
const EVT = "autoglue.auth-change"
let cache: TokenPair | null = read()
function read(): TokenPair | null {
try {
const raw = localStorage.getItem(KEY)
return raw ? (JSON.parse(raw) as TokenPair) : null
} catch {
return null
}
}
function write(tokens: TokenPair | null) {
if (tokens) localStorage.setItem(KEY, JSON.stringify(tokens))
else localStorage.removeItem(KEY)
}
function emit(tokens: TokenPair | null) {
// include payload for convenience
window.dispatchEvent(new CustomEvent(EVT, { detail: tokens }))
}
export const authStore = {
/** Current tokens (from in-memory cache). */
get(): TokenPair | null {
return cache
},
/** Set tokens; updates memory, localStorage, broadcasts event. */
set(tokens: TokenPair | null) {
cache = tokens
write(tokens)
emit(tokens)
},
/** Fresh read from storage (useful if you suspect out-of-band changes). */
reload(): TokenPair | null {
cache = read()
return cache
},
/** Is there an access token at all? (not checking expiry) */
isAuthed(): boolean {
return !!cache?.access_token
},
/** Convenience accessor */
getAccessToken(): string | null {
return cache?.access_token ?? null
},
/** Decode JWT exp and check expiry (no clock skew handling here). */
isExpired(nowSec = Math.floor(Date.now() / 1000)): boolean {
const exp = decodeExp(cache?.access_token)
return exp !== null ? nowSec >= exp : true
},
/** Will expire within `thresholdSec` (default 60s). */
willExpireSoon(thresholdSec = 60, nowSec = Math.floor(Date.now() / 1000)): boolean {
const exp = decodeExp(cache?.access_token)
return exp !== null ? exp - nowSec <= thresholdSec : true
},
logout() {
authStore.set(null)
},
/** Subscribe to changes (pairs well with useSyncExternalStore). */
subscribe(fn: (tokens: TokenPair | null) => void): () => void {
const onCustom = (e: Event) => fn((e as CustomEvent).detail ?? null)
const onStorage = (e: StorageEvent) => {
if (e.key === KEY) {
cache = read()
fn(cache)
}
}
window.addEventListener(EVT, onCustom as EventListener)
window.addEventListener("storage", onStorage)
return () => {
window.removeEventListener(EVT, onCustom as EventListener)
window.removeEventListener("storage", onStorage)
}
},
}
// --- helpers ---
function decodeExp(jwt?: string): number | null {
if (!jwt) return null
const parts = jwt.split(".")
if (parts.length < 2) return null
try {
const json = JSON.parse(atob(base64urlToBase64(parts[1])))
const exp = typeof json?.exp === "number" ? json.exp : null
return exp ?? null
} catch {
return null
}
}
function base64urlToBase64(s: string) {
return s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice((2 - ((s.length * 3) % 4)) % 4)
}
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({ ...props }: React.ComponentProps) {
return
}
function AccordionItem({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps) {
return (
svg]:rotate-180",
className
)}
{...props}
>
{children}
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps) {
return (
{children}
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({ ...props }: React.ComponentProps) {
return
}
function AlertDialogTrigger({
...props
}: React.ComponentProps) {
return
}
function AlertDialogPortal({ ...props }: React.ComponentProps) {
return
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps) {
return
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps) {
return (
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps) {
return (
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
export { Alert, AlertTitle, AlertDescription }
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({ ...props }: React.ComponentProps) {
return
}
export { AspectRatio }
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({ className, ...props }: React.ComponentProps) {
return (
)
}
function AvatarImage({ className, ...props }: React.ComponentProps) {
return (
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps) {
return (
)
}
export { Avatar, AvatarImage, AvatarFallback }
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return
}
export { Badge, badgeVariants }
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
)
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
svg]:size-3.5", className)}
{...props}
>
{children ?? }
)
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
More
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps) {
return (
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps) {
return (
)
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
)
}
export { Button, buttonVariants }
"use client"
import * as React from "react"
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps & {
buttonVariant?: React.ComponentProps["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return
}
if (orientation === "right") {
return
}
return
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
{children}
|
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
import * as React from "react"
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType[0]
api: ReturnType[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a ")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
{children}
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
)
}
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a ")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
{children}
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
if (!colorConfig.length) {
return null
}
return (