diff --git a/internal/api/dto/response.go b/internal/api/dto/response.go new file mode 100644 index 0000000..8c40427 --- /dev/null +++ b/internal/api/dto/response.go @@ -0,0 +1,42 @@ +package dto + +// App is the response shape for a single app. +type App struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// AppList is the response shape for list apps. +type AppList struct { + NextCursor string `json:"next_cursor"` + Items []App `json:"items"` +} + +// Server is the response shape for a server within an app listing. +type Server struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + NumCPU int `json:"num_cpu"` + TimestampUTC string `json:"timestamp_utc"` +} + +// ServerList is the response shape for listing servers in an app. +type ServerList struct { + Total int64 `json:"total"` + NextCursor string `json:"next_cursor"` + Items []Server `json:"items"` +} + +// Status is a generic OK/ERR style response. +type Status struct { + Status string `json:"status"` +} + +// StatusCount is used where we also want to return a count. +type StatusCount struct { + Status string `json:"status"` + Count int `json:"count"` +} diff --git a/internal/api/handlers/apps.go b/internal/api/handlers/apps.go new file mode 100644 index 0000000..6fe32e2 --- /dev/null +++ b/internal/api/handlers/apps.go @@ -0,0 +1,278 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "replicator/internal/api/dto" + mw "replicator/internal/api/middleware" + "replicator/internal/models" + "replicator/internal/storage" +) + +type createAppReq struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type addServersReq struct { + ServerIDs []string `json:"metadata_ids"` +} + +// POST /api/apps +func CreateAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("CreateAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + var req createAppReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Error("CreateAppHandler: decode failed", "error", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + + var cnt int64 + if err := store.DB.Model(&models.App{}).Where("name = ?", name).Count(&cnt).Error; err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if cnt > 0 { + http.Error(w, "name already exists", http.StatusConflict) + return + } + + app, err := store.CreateApp(storage.AppCreate{ + ID: uuid.NewString(), + Name: name, + Description: req.Description, + }) + if err != nil { + http.Error(w, "create failed", http.StatusInternalServerError) + return + } + + resp := dto.App{ + ID: app.ID, + Name: app.Name, + Description: app.Description, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// DELETE /api/apps/{id} +func DeleteAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil || store.DB == nil { + log.Error("DeleteAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + id := chi.URLParam(r, "id") + if strings.TrimSpace(id) == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + + if err := store.DeleteApp(storage.AppSelector{ID: &id}); err != nil { + log.Error("DeleteAppHandler: db error", "error", err.Error()) + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dto.Status{Status: "ok"}) +} + +// GET /api/apps +func ListAppsHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("ListAppsHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + afterID := r.URL.Query().Get("after_id") + limit := 50 + if lq := r.URL.Query().Get("limit"); lq != "" { + if v, err := strconv.Atoi(lq); err == nil && v > 0 && v <= 500 { + limit = v + } + } + + items, next, err := store.ListApps(afterID, limit) + if err != nil { + http.Error(w, "list failed", http.StatusInternalServerError) + return + } + + out := dto.AppList{ + NextCursor: next, + Items: make([]dto.App, 0, len(items)), + } + for i := range items { + out.Items = append(out.Items, dto.App{ + ID: items[i].ID, + Name: items[i].Name, + Description: items[i].Description, + }) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +// GET /api/apps/{id} +func GetAppByIDHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("GetAppByIDHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + id := chi.URLParam(r, "id") + app, err := store.FindApp(storage.AppSelector{ID: &id}) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + resp := dto.App{ + ID: app.ID, + Name: app.Name, + Description: app.Description, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// POST /api/apps/{appID}/servers +func AddServersToAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("AddServersToAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + appID := chi.URLParam(r, "appID") + + var req addServersReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Error("AddServersToAppHandler: decode failed", "error", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(req.ServerIDs) == 0 { + http.Error(w, "metadata_ids required", http.StatusBadRequest) + return + } + + if err := store.ModifyAppServers( + storage.AppSelector{ID: &appID}, + req.ServerIDs, + storage.MembershipAdd); err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + + resp := dto.StatusCount{Status: "ok", Count: len(req.ServerIDs)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// DELETE /api/apps/{appID}/servers/{serverID} +func RemoveServerFromAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("RemoveServerFromAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + appID := chi.URLParam(r, "appID") + serverID := chi.URLParam(r, "serverID") + + if err := store.ModifyAppServers( + storage.AppSelector{ID: &appID}, + []string{serverID}, + storage.MembershipRemove); err != nil { + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dto.Status{Status: "ok"}) +} + +// GET /api/apps/{appID}/servers +func ListServersForAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("ListServersForAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + appID := chi.URLParam(r, "appID") + afterID := r.URL.Query().Get("after_id") + limit := 50 + if lq := r.URL.Query().Get("limit"); lq != "" { + if v, err := strconv.Atoi(lq); err == nil && v > 0 && v <= 500 { + limit = v + } + } + + servers, total, next, err := store.ListAppServers( + storage.AppSelector{ID: &appID}, + storage.Cursor{AfterID: afterID, Limit: limit}, + ) + if err != nil { + http.Error(w, "list failed", http.StatusInternalServerError) + return + } + + out := dto.ServerList{ + Total: total, + NextCursor: next, + Items: make([]dto.Server, 0, len(servers)), + } + for i := range servers { + out.Items = append(out.Items, dto.Server{ + ID: servers[i].ID, + Hostname: servers[i].Hostname, + OS: servers[i].OS, + Arch: servers[i].Arch, + NumCPU: servers[i].NumCPU, + TimestampUTC: servers[i].TimestampUTC, + }) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} diff --git a/internal/api/handlers/debug.go b/internal/api/handlers/debug.go new file mode 100644 index 0000000..24ce16f --- /dev/null +++ b/internal/api/handlers/debug.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + mw "replicator/internal/api/middleware" +) + +func SeedHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + if err := store.SeedSampleData(r.Context()); err != nil { + log.Error("SeedHandler failed", "error", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "msg": "Sample data inserted", + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index 9754287..b1e13b6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -26,6 +26,20 @@ func NewRouter(store *storage.Store, logger *slog.Logger) http.Handler { r.Post("/discover", handlers.DiscoverHandler) r.Get("/servers", handlers.ListServersHandler) r.Get("/servers/{id}", handlers.GetServerHandler) + + r.Route("/apps", func(r chi.Router) { + r.Post("/", handlers.CreateAppHandler) + r.Get("/", handlers.ListAppsHandler) + r.Get("/{id}", handlers.GetAppByIDHandler) + r.Post("/{appID}/servers", handlers.AddServersToAppHandler) + r.Delete("/{appID}/servers/{serverID}", handlers.RemoveServerFromAppHandler) + r.Get("/{appID}/servers", handlers.ListServersForAppHandler) + r.Delete("/{id}", handlers.DeleteAppHandler) + }) + + // debug seed route — IMPORTANT: stays inside this block + r.Post("/debug/seed", handlers.SeedHandler) + }) // UI routes diff --git a/internal/models/apps.go b/internal/models/apps.go new file mode 100644 index 0000000..e208f5a --- /dev/null +++ b/internal/models/apps.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// --- apps --- +type App struct { + ID string `json:"id" gorm:"primaryKey;size:64;not null"` + Name string `json:"name" gorm:"size:255;not null;uniqueIndex"` + Description string `json:"description" gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time + + Servers []Metadata `json:"servers" gorm:"many2many:app_servers"` +} + +type AppServer struct { + AppID string `json:"app_id" gorm:"size:64;not null;primaryKey;column:app_id"` + MetadataID string `json:"metadata_id" gorm:"size:64;not null;primaryKey;column:metadata_id"` + CreatedAt time.Time + + // Optional FKs (good for cascades) + App App `gorm:"foreignKey:AppID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + Metadata Metadata `gorm:"foreignKey:MetadataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} diff --git a/internal/models/metadata.go b/internal/models/metadata.go index 73c21d7..d218485 100644 --- a/internal/models/metadata.go +++ b/internal/models/metadata.go @@ -2,6 +2,7 @@ package models import "time" +// --- servers (metadata) --- type Metadata struct { ID string `json:"id" gorm:"primaryKey;Size:64;not null"` Hostname string `json:"hostname"` @@ -16,4 +17,7 @@ type Metadata struct { TimestampUTC string `json:"timestamp_utc" gorm:"index"` CreatedAt time.Time UpdatedAt time.Time + + // Apps []App `json:"apps" gorm:"many2many:app_servers;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + Apps []App `json:"apps" gorm:"many2many:app_servers"` } diff --git a/internal/storage/app_store.go b/internal/storage/app_store.go new file mode 100644 index 0000000..1f6e7af --- /dev/null +++ b/internal/storage/app_store.go @@ -0,0 +1,172 @@ +// storage/apps_store.go +package storage + +import ( + "errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "time" + + "replicator/internal/models" +) + +func (s *Store) CreateApp(in AppCreate) (*models.App, error) { + app := &models.App{ + ID: in.ID, + Name: in.Name, + Description: in.Description, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.DB.Create(app).Error; err != nil { + return nil, err + } + return app, nil +} + +// DeleteApp deletes the app row and detaches all related servers (rows in app_servers). +func (s *Store) DeleteApp(sel AppSelector) error { + app, err := s.FindApp(sel) + if err != nil { + return err + } + + return s.DB.Transaction(func(tx *gorm.DB) error { + // detach memberships + if err := tx.Where("app_id = ?", app.ID).Delete(&models.AppServer{}).Error; err != nil { + return err + } + // delete the app itself + res := tx.Delete(&models.App{}, "id = ?", app.ID) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func (s *Store) FindApp(sel AppSelector) (*models.App, error) { + var app models.App + tx := s.DB.Model(&models.App{}) + switch { + case sel.ID != nil && *sel.ID != "": + if err := tx.First(&app, "id = ?", *sel.ID).Error; err != nil { + return nil, err + } + default: + return nil, errors.New("empty selector") + } + return &app, nil +} + +func (s *Store) ListApps(afterID string, limit int) ([]models.App, string, error) { + if limit <= 0 || limit > 500 { + limit = 50 + } + q := s.DB.Model(&models.App{}) + if afterID != "" { + q = q.Where("id > ?", afterID) + } + var apps []models.App + if err := q.Order("id ASC").Limit(limit).Find(&apps).Error; err != nil { + return nil, "", err + } + var next string + if len(apps) == limit { + next = apps[len(apps)-1].ID + } + return apps, next, nil +} + +func (s *Store) ModifyAppServers(sel AppSelector, serverIDs []string, op MembershipOp) error { + app, err := s.FindApp(sel) + if err != nil { + return err + } + switch op { + case MembershipAdd: + return s.addAppServers(app.ID, serverIDs) + case MembershipRemove: + return s.removeAppServers(app.ID, serverIDs) + default: + return errors.New("invalid membership op") + } +} + +func (s *Store) ListAppServers(sel AppSelector, cur Cursor) ([]models.Metadata, int64, string, error) { + app, err := s.FindApp(sel) + if err != nil { + return nil, 0, "", err + } + if cur.Limit <= 0 || cur.Limit > 500 { + cur.Limit = 50 + } + var total int64 + if err := s.DB.Model(&models.AppServer{}).Where("app_id = ?", app.ID).Count(&total).Error; err != nil { + return nil, 0, "", err + } + sub := s.DB.Model(&models.AppServer{}).Select("metadata_id").Where("app_id = ?", app.ID) + q := s.DB.Model(&models.Metadata{}).Where("id IN (?)", sub) + if cur.AfterID != "" { + q = q.Where("id > ?", cur.AfterID) + } + var servers []models.Metadata + if err := q.Order("id ASC").Limit(cur.Limit).Find(&servers).Error; err != nil { + return nil, 0, "", err + } + var next string + if len(servers) == cur.Limit { + next = servers[len(servers)-1].ID + } + return servers, total, next, nil +} + +func (s *Store) addAppServers(appID string, serverIDs []string) error { + if len(serverIDs) == 0 { + return nil + } + serverIDs = unique(serverIDs) + var existing []string + if err := s.DB.Model(&models.Metadata{}).Where("id IN ?", serverIDs).Pluck("id", &existing).Error; err != nil { + return err + } + if len(existing) == 0 { + return nil + } + now := time.Now() + links := make([]models.AppServer, 0, len(existing)) + for _, sid := range existing { + links = append(links, models.AppServer{AppID: appID, MetadataID: sid, CreatedAt: now}) + } + return s.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "app_id"}, {Name: "metadata_id"}}, + DoNothing: true, + }).CreateInBatches(&links, 500).Error +} + +func (s *Store) removeAppServers(appID string, serverIDs []string) error { + if len(serverIDs) == 0 { + return nil + } + serverIDs = unique(serverIDs) + return s.DB.Where("app_id = ? AND metadata_id IN ?", appID, serverIDs).Delete(&models.AppServer{}).Error +} + +func unique(in []string) []string { + m := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + if v == "" { + continue + } + if _, ok := m[v]; ok { + continue + } + m[v] = struct{}{} + out = append(out, v) + } + return out +} diff --git a/internal/storage/dto.go b/internal/storage/dto.go new file mode 100644 index 0000000..424084a --- /dev/null +++ b/internal/storage/dto.go @@ -0,0 +1,25 @@ +// storage/dto.go +package storage + +type AppCreate struct { + ID string + Name string + Description string +} + +type AppSelector struct { + ID *string +} + +type Cursor struct { + AfterID string + Limit int +} + +type MembershipOp string + +const ( + MembershipAdd MembershipOp = "add" + MembershipRemove MembershipOp = "remove" + MembershipReplace MembershipOp = "replace" +) diff --git a/internal/storage/seed.go b/internal/storage/seed.go new file mode 100644 index 0000000..fe93205 --- /dev/null +++ b/internal/storage/seed.go @@ -0,0 +1,82 @@ +package storage + +import ( + "context" + "time" + + "github.com/google/uuid" + "replicator/internal/models" +) + +// SeedSampleData inserts sample apps and servers for testing. +func (s *Store) SeedSampleData(ctx context.Context) error { + now := time.Now() + + // Sample servers + servers := []models.Metadata{ + { + ID: uuid.NewString(), + Hostname: "srv-payments-01", + OS: "linux", + Arch: "amd64", + NumCPU: 4, + Kernel: "5.15.0", + Uptime: "12h", + TotalMemoryMB: 8192, + TotalDiskSizeGB: "100", + MountedCount: 3, + TimestampUTC: now.UTC().Format(time.RFC3339), + }, + { + ID: uuid.NewString(), + Hostname: "srv-analytics-01", + OS: "linux", + Arch: "amd64", + NumCPU: 8, + Kernel: "5.15.0", + Uptime: "3h", + TotalMemoryMB: 16384, + TotalDiskSizeGB: "200", + MountedCount: 4, + TimestampUTC: now.UTC().Format(time.RFC3339), + }, + } + + // Insert servers + if err := s.DB.Create(&servers).Error; err != nil { + return err + } + + // Sample apps + apps := []models.App{ + { + ID: uuid.NewString(), + Name: "Payments", + Description: "Prod payment service", + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: uuid.NewString(), + Name: "Analytics", + Description: "Analytics and BI service", + CreatedAt: now, + UpdatedAt: now, + }, + } + + if err := s.DB.Create(&apps).Error; err != nil { + return err + } + + // Link first server to Payments app + appServer := models.AppServer{ + AppID: apps[0].ID, + MetadataID: servers[0].ID, + } + if err := s.DB.Create(&appServer).Error; err != nil { + return err + } + + return nil +} diff --git a/internal/storage/store.go b/internal/storage/store.go index 6aaa9ee..8dc3b0e 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -21,7 +21,19 @@ func Init(dbUrl string) (*Store, error) { if err != nil { return nil, err } - if err := db.AutoMigrate(&models.Metadata{}); err != nil { + + if err := db.AutoMigrate( + &models.Metadata{}, + &models.App{}, + &models.AppServer{}, + ); err != nil { + return nil, err + } + + if err := db.SetupJoinTable(&models.App{}, "Servers", &models.AppServer{}); err != nil { + return nil, err + } + if err := db.SetupJoinTable(&models.Metadata{}, "Apps", &models.AppServer{}); err != nil { return nil, err }