From 4a9a017f6ba75e1162ff01663cd7f8ea1d7606cd Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Tue, 9 Sep 2025 21:08:30 -0700 Subject: [PATCH 1/8] Fixed pods regex --- internal/cloning/pods.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cloning/pods.go b/internal/cloning/pods.go index e97b1b7..3f56da9 100644 --- a/internal/cloning/pods.go +++ b/internal/cloning/pods.go @@ -22,7 +22,8 @@ func (cs *CloningService) GetPods(username string) ([]Pod, error) { } // Build regex pattern to match username or any of their group names - regexPattern := fmt.Sprintf(`1[0-9]{3}_.*_(%s|%s)`, username, strings.Join(groups, "|")) + groupsWithUser := append(groups, username) + regexPattern := fmt.Sprintf(`1[0-9]{3}_.*_(%s)`, strings.Join(groupsWithUser, "|")) // Get pods based on regex pattern pods, err := cs.MapVirtualResourcesToPods(regexPattern) From 6b1ab6b0c6701a442c9f15d5b3f7cbf3c26ea168 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Thu, 18 Sep 2025 22:37:56 -0700 Subject: [PATCH 2/8] Improved template publish logic. Also added dedicated endpoint for user dashboard. Lastly, general cleanup --- internal/api/handlers/cloning_handler.go | 28 ++++ internal/api/handlers/dashboard_handler.go | 60 ++++++++- internal/api/routes/admin_routes.go | 7 +- internal/api/routes/private_routes.go | 3 +- internal/api/routes/public_routes.go | 1 + internal/api/routes/routes.go | 7 +- internal/cloning/cloning_service.go | 20 +-- internal/cloning/templates.go | 68 ++++++++-- internal/ldap/types.go | 1 + internal/ldap/users.go | 59 +++++++++ internal/proxmox/types.go | 16 ++- internal/proxmox/vms.go | 142 ++++++--------------- 12 files changed, 271 insertions(+), 141 deletions(-) diff --git a/internal/api/handlers/cloning_handler.go b/internal/api/handlers/cloning_handler.go index f765213..d4ef37d 100644 --- a/internal/api/handlers/cloning_handler.go +++ b/internal/api/handlers/cloning_handler.go @@ -60,6 +60,34 @@ func (ch *CloningHandler) CloneTemplateHandler(c *gin.Context) { log.Printf("User %s requested cloning of template %s", username, req.Template) + publishedTemplates, err := ch.Service.DatabaseService.GetPublishedTemplates() + if err != nil { + log.Printf("Error fetching published templates: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to fetch published templates", + "details": err.Error(), + }) + return + } + + // Check if the requested template is in the list of published templates + templateFound := false + for _, tmpl := range publishedTemplates { + if tmpl.Name == req.Template { + templateFound = true + break + } + } + + if !templateFound { + log.Printf("Template %s not found or not published", req.Template) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Template not found or not published", + "details": fmt.Sprintf("Template %s is not available for cloning", req.Template), + }) + return + } + // Create the cloning request using the new format cloneReq := cloning.CloneRequest{ Template: req.Template, diff --git a/internal/api/handlers/dashboard_handler.go b/internal/api/handlers/dashboard_handler.go index 9a058a1..8571840 100644 --- a/internal/api/handlers/dashboard_handler.go +++ b/internal/api/handlers/dashboard_handler.go @@ -1,8 +1,12 @@ package handlers import ( + "fmt" + "log" "net/http" + "strings" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -16,7 +20,7 @@ func NewDashboardHandler(authHandler *AuthHandler, proxmoxHandler *ProxmoxHandle } // ADMIN: GetDashboardStatsHandler retrieves all dashboard statistics in a single request -func (dh *DashboardHandler) GetDashboardStatsHandler(c *gin.Context) { +func (dh *DashboardHandler) GetAdminDashboardStatsHandler(c *gin.Context) { stats := DashboardStats{} // Get user count @@ -71,3 +75,57 @@ func (dh *DashboardHandler) GetDashboardStatsHandler(c *gin.Context) { "stats": stats, }) } + +// PRIVATE: GetUserDashboardStatsHandler retrieves all user dashboard statistics in a single request +func (dh *DashboardHandler) GetUserDashboardStatsHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + + // Get user's deployed pods + pods, err := dh.cloningHandler.Service.GetPods(username) + if err != nil { + log.Printf("Error retrieving pods for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods", "details": err.Error()}) + return + } + + // Loop through the user's deployed pods and add template information + for i := range pods { + templateName := strings.Replace(pods[i].Name[5:], fmt.Sprintf("_%s", username), "", 1) + templateInfo, err := dh.cloningHandler.Service.DatabaseService.GetTemplateInfo(templateName) + if err != nil { + log.Printf("Error retrieving template info for pod %s: %v", pods[i].Name, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve template info for pod", "details": err.Error()}) + return + } + pods[i].Template = templateInfo + } + + // Get user's information + userInfo, err := dh.authHandler.ldapService.GetUser(username) + if err != nil { + log.Printf("Error retrieving user info for %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to retrieve user info", + "details": err.Error(), + }) + return + } + + // Get public pod templates + templates, err := dh.cloningHandler.Service.DatabaseService.GetTemplates() + if err != nil { + log.Printf("Error retrieving templates: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to retrieve templates", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "pods": pods, + "user_info": userInfo, + "templates": templates, + }) +} diff --git a/internal/api/routes/admin_routes.go b/internal/api/routes/admin_routes.go index 1bb4eb7..a97629d 100644 --- a/internal/api/routes/admin_routes.go +++ b/internal/api/routes/admin_routes.go @@ -6,12 +6,9 @@ import ( ) // registerAdminRoutes defines all routes accessible to admin users -func registerAdminRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, proxmoxHandler *handlers.ProxmoxHandler, cloningHandler *handlers.CloningHandler) { - // Create dashboard handler - dashboardHandler := handlers.NewDashboardHandler(authHandler, proxmoxHandler, cloningHandler) - +func registerAdminRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, proxmoxHandler *handlers.ProxmoxHandler, cloningHandler *handlers.CloningHandler, dashboardHandler *handlers.DashboardHandler) { // GET Requests - g.GET("/dashboard", dashboardHandler.GetDashboardStatsHandler) + g.GET("/dashboard", dashboardHandler.GetAdminDashboardStatsHandler) g.GET("/cluster", proxmoxHandler.GetClusterResourceUsageHandler) g.GET("/users", authHandler.GetUsersHandler) g.GET("/groups", authHandler.GetGroupsHandler) diff --git a/internal/api/routes/private_routes.go b/internal/api/routes/private_routes.go index d9eac10..2961cbc 100644 --- a/internal/api/routes/private_routes.go +++ b/internal/api/routes/private_routes.go @@ -6,8 +6,9 @@ import ( ) // registerPrivateRoutes defines all routes accessible to authenticated users -func registerPrivateRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, proxmoxHandler *handlers.ProxmoxHandler, cloningHandler *handlers.CloningHandler) { +func registerPrivateRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, cloningHandler *handlers.CloningHandler, dashboardHandler *handlers.DashboardHandler) { // GET Requests + g.GET("/dashboard", dashboardHandler.GetUserDashboardStatsHandler) g.GET("/session", authHandler.SessionHandler) g.GET("/pods", cloningHandler.GetPodsHandler) g.GET("/templates", cloningHandler.GetTemplatesHandler) diff --git a/internal/api/routes/public_routes.go b/internal/api/routes/public_routes.go index ea72952..4260a3b 100644 --- a/internal/api/routes/public_routes.go +++ b/internal/api/routes/public_routes.go @@ -10,4 +10,5 @@ func registerPublicRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, // GET Requests g.GET("/health", handlers.HealthCheckHandler(authHandler, cloningHandler)) g.POST("/login", authHandler.LoginHandler) + // g.POST("/register", authHandler.RegisterHandler) } diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index f29a73d..28b7631 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -8,6 +8,9 @@ import ( // RegisterRoutes sets up all API routes with their respective middleware and handlers func RegisterRoutes(r *gin.Engine, authHandler *handlers.AuthHandler, proxmoxHandler *handlers.ProxmoxHandler, cloningHandler *handlers.CloningHandler) { + // Create centralized dashboard handler + dashboardHandler := handlers.NewDashboardHandler(authHandler, proxmoxHandler, cloningHandler) + // Public routes (no authentication required) public := r.Group("/api/v1") registerPublicRoutes(public, authHandler, cloningHandler) @@ -15,10 +18,10 @@ func RegisterRoutes(r *gin.Engine, authHandler *handlers.AuthHandler, proxmoxHan // Private routes (authentication required) private := r.Group("/api/v1") private.Use(middleware.AuthRequired) - registerPrivateRoutes(private, authHandler, proxmoxHandler, cloningHandler) + registerPrivateRoutes(private, authHandler, cloningHandler, dashboardHandler) // Admin routes (authentication + admin privileges required) admin := r.Group("/api/v1/admin") admin.Use(middleware.AdminRequired) - registerAdminRoutes(admin, authHandler, proxmoxHandler, cloningHandler) + registerAdminRoutes(admin, authHandler, proxmoxHandler, cloningHandler, dashboardHandler) } diff --git a/internal/cloning/cloning_service.go b/internal/cloning/cloning_service.go index 9073004..edb7862 100644 --- a/internal/cloning/cloning_service.go +++ b/internal/cloning/cloning_service.go @@ -169,7 +169,7 @@ func (cs *CloningService) CloneTemplate(req CloneRequest) error { NewVMID: target.VMIDs[0], TargetNode: bestNode, } - err = cs.ProxmoxService.CloneVMWithConfig(routerCloneReq) + err = cs.ProxmoxService.CloneVM(routerCloneReq) if err != nil { errors = append(errors, fmt.Sprintf("failed to clone router VM for %s: %v", target.Name, err)) } else { @@ -191,7 +191,7 @@ func (cs *CloningService) CloneTemplate(req CloneRequest) error { NewVMID: target.VMIDs[i+1], TargetNode: bestNode, } - err := cs.ProxmoxService.CloneVMWithConfig(vmCloneReq) + err := cs.ProxmoxService.CloneVM(vmCloneReq) if err != nil { errors = append(errors, fmt.Sprintf("failed to clone VM %s for %s: %v", vm.Name, target.Name, err)) } @@ -254,12 +254,8 @@ func (cs *CloningService) CloneTemplate(req CloneRequest) error { } // Wait for router to be running - routerVM := proxmox.VM{ - Node: routerInfo.Node, - VMID: routerInfo.VMID, - } log.Printf("Waiting for router VM to be running for %s (VMID: %d)", routerInfo.TargetName, routerInfo.VMID) - err = cs.ProxmoxService.WaitForRunning(routerVM) + err = cs.ProxmoxService.WaitForRunning(routerInfo.Node, routerInfo.VMID) if err != nil { errors = append(errors, fmt.Sprintf("failed to start router VM for %s: %v", routerInfo.TargetName, err)) } @@ -268,14 +264,8 @@ func (cs *CloningService) CloneTemplate(req CloneRequest) error { // 11. Configure all pod routers (separate step after all routers are running) log.Printf("Configuring %d pod routers", len(clonedRouters)) for _, routerInfo := range clonedRouters { - // Only configure routers that successfully started - routerVM := proxmox.VM{ - Node: routerInfo.Node, - VMID: routerInfo.VMID, - } - // Double-check that router is still running before configuration - err = cs.ProxmoxService.WaitForRunning(routerVM) + err = cs.ProxmoxService.WaitForRunning(routerInfo.Node, routerInfo.VMID) if err != nil { errors = append(errors, fmt.Sprintf("router not running before configuration for %s: %v", routerInfo.TargetName, err)) continue @@ -360,7 +350,7 @@ func (cs *CloningService) DeletePod(pod string) error { // Wait for all previously running VMs to be stopped if len(runningVMs) > 0 { for _, vm := range runningVMs { - if err := cs.ProxmoxService.WaitForStopped(vm); err != nil { + if err := cs.ProxmoxService.WaitForStopped(vm.Node, vm.VMID); err != nil { // Continue with deletion even if we can't confirm the VM is stopped } } diff --git a/internal/cloning/templates.go b/internal/cloning/templates.go index c895b2e..ee10e9a 100644 --- a/internal/cloning/templates.go +++ b/internal/cloning/templates.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" + "github.com/cpp-cyber/proclone/internal/proxmox" "github.com/gin-gonic/gin" "github.com/google/uuid" ) @@ -217,27 +218,78 @@ func (cs *CloningService) GetUnpublishedTemplates() ([]string, error) { return unpublished, nil } +// Before publishing we try to convert as many VMs to templates to speed up cloning process func (cs *CloningService) PublishTemplate(template KaminoTemplate) error { - // Insert template information into database - if err := cs.DatabaseService.InsertTemplate(template); err != nil { - return fmt.Errorf("failed to publish template: %w", err) - } - - // Get all VMs in pool + // 1. Get all VMs in pool + // If this fails, the function will error out vms, err := cs.ProxmoxService.GetPoolVMs("kamino_template_" + template.Name) if err != nil { log.Printf("Error retrieving VMs in pool: %v", err) return fmt.Errorf("failed to get VMs in pool: %w", err) } - // Convert all VMs to templates + // 2. Shutdown all running VMs in pool + // If a VM cannot be shutdown, this function will error out + runningVMs := []proxmox.VirtualResource{} + for _, vm := range vms { + if vm.RunningStatus != "stopped" { + runningVMs = append(runningVMs, vm) + if err := cs.ProxmoxService.ShutdownVM(vm.NodeName, vm.VmId); err != nil { + log.Printf("Error shutting down VM %d: %v", vm.VmId, err) + return fmt.Errorf("failed to shutdown VM %d: %w", vm.VmId, err) + } + } + } + + // 3. Wait for running VMs to be stopped + // If a VM cannot be verified as stopped, this function will error out + for _, vm := range runningVMs { + if err := cs.ProxmoxService.WaitForStopped(vm.NodeName, vm.VmId); err != nil { + log.Printf("Error waiting for VM %d to stop: %v", vm.VmId, err) + return fmt.Errorf("failed to confirm VM %d is stopped: %w", vm.VmId, err) + } + } + + // 4. Detect if any VMs have snapshots and remove them + // If a snapshot cannot be removed, it will skip the VM since and automatically fall back to full clone + for _, vm := range vms { + snapshots, err := cs.ProxmoxService.GetVMSnapshots(vm.NodeName, vm.VmId) + if err != nil { + log.Printf("Error getting snapshots for VM %d: %v", vm.VmId, err) + continue + } + + if snapshots == nil { + continue // No snapshots to delete + } + + for _, snapshot := range snapshots { + if err := cs.ProxmoxService.DeleteVMSnapshot(vm.NodeName, vm.VmId, snapshot.Name); err != nil { + // Break out of snapshot loop on error and leave it to full clone + log.Printf("Error deleting snapshot %s for VM %d: %v", snapshot.Name, vm.VmId, err) + break + } + } + } + + // 5. Attempt to convert all VMs to templates + // If a VM cannot be converted, it will skip the VM since it will automatically + // full clone if not a template or it is already a template for _, vm := range vms { if err := cs.ProxmoxService.ConvertVMToTemplate(vm.NodeName, vm.VmId); err != nil { + // Skip VM since it will automatically full clone if not a template or it is already a template log.Printf("Error converting VM %d to template: %v", vm.VmId, err) - return fmt.Errorf("failed to convert VM to template: %w", err) + continue } } + // 6. Insert template information into database + // If this fails, the function will error out + if err := cs.DatabaseService.InsertTemplate(template); err != nil { + log.Printf("Error inserting template into database: %v", err) + return fmt.Errorf("failed to publish to database: %w", err) + } + return nil } diff --git a/internal/ldap/types.go b/internal/ldap/types.go index 5de4949..79d447d 100644 --- a/internal/ldap/types.go +++ b/internal/ldap/types.go @@ -13,6 +13,7 @@ import ( type Service interface { // User Management GetUsers() ([]User, error) + GetUser(username string) (*User, error) CreateAndRegisterUser(userInfo UserRegistrationInfo) error DeleteUser(username string) error AddUserToGroup(username string, groupName string) error diff --git a/internal/ldap/users.go b/internal/ldap/users.go index c20587b..f38d781 100644 --- a/internal/ldap/users.go +++ b/internal/ldap/users.go @@ -75,6 +75,65 @@ func (s *LDAPService) GetUsers() ([]User, error) { return users, nil } +func (s *LDAPService) GetUser(username string) (*User, error) { + kaminoUsersGroupDN := "CN=KaminoUsers,OU=KaminoGroups," + s.client.config.BaseDN + searchRequest := ldapv3.NewSearchRequest( + s.client.config.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s)(memberOf=%s))", username, kaminoUsersGroupDN), // Filter for specific user in KaminoUsers group + []string{"sAMAccountName", "dn", "whenCreated", "memberOf", "userAccountControl"}, // Attributes to retrieve + nil, + ) + + searchResult, err := s.client.Search(searchRequest) + if err != nil { + return nil, fmt.Errorf("failed to search for user: %v", err) + } + + if len(searchResult.Entries) == 0 { + return nil, fmt.Errorf("user '%s' not found", username) + } + + entry := searchResult.Entries[0] + user := User{ + Name: entry.GetAttributeValue("sAMAccountName"), + } + + whenCreated := entry.GetAttributeValue("whenCreated") + if whenCreated != "" { + // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z + if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { + user.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") + } + } + + // Check if user is enabled + userAccountControl := entry.GetAttributeValue("userAccountControl") + if userAccountControl != "" { + uac, err := strconv.Atoi(userAccountControl) + if err == nil { + // UF_ACCOUNTDISABLE = 0x02 + user.Enabled = (uac & 0x02) == 0 + } + } + + // Check if user is admin + memberOfValues := entry.GetAttributeValues("memberOf") + for _, memberOf := range memberOfValues { + if strings.Contains(memberOf, s.client.config.AdminGroupDN) { + user.IsAdmin = true + break + } + } + + // Get user groups + groups, err := getUserGroupsFromMemberOf(memberOfValues) + if err == nil { + user.Groups = groups + } + + return &user, nil +} + func (s *LDAPService) CreateUser(userInfo UserRegistrationInfo) (string, error) { // Create DN for new user in Users container // TODO: Static diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go index 6fbcaec..fd34785 100644 --- a/internal/proxmox/types.go +++ b/internal/proxmox/types.go @@ -43,12 +43,13 @@ type Service interface { RebootVM(node string, vmID int) error StopVM(node string, vmID int) error DeleteVM(node string, vmID int) error + GetVMSnapshots(node string, vmID int) ([]VMSnapshot, error) + DeleteVMSnapshot(node string, vmID int, snapshotName string) error ConvertVMToTemplate(node string, vmID int) error - CloneVMWithConfig(req VMCloneRequest) error - WaitForCloneCompletion(vm *VM, timeout time.Duration) error - WaitForDisk(node string, vmid int, maxWait time.Duration) error - WaitForRunning(vm VM) error - WaitForStopped(vm VM) error + CloneVM(req VMCloneRequest) error + WaitForDisk(node string, vmID int, maxWait time.Duration) error + WaitForRunning(node string, vmiID int) error + WaitForStopped(node string, vmiID int) error // Pool Management GetPoolVMs(poolName string) ([]VirtualResource, error) @@ -113,9 +114,14 @@ type VMCloneRequest struct { PoolName string PodID string NewVMID int + Full int TargetNode string } +type VMSnapshot struct { + Name string `json:"name"` +} + type VirtualResource struct { CPU float64 `json:"cpu,omitempty"` MaxCPU int `json:"maxcpu,omitempty"` diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index a5322d9..a76ab5e 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -3,8 +3,6 @@ package proxmox import ( "fmt" "log" - "math" - "strconv" "strings" "time" @@ -18,7 +16,7 @@ import ( func (s *ProxmoxService) GetVMs() ([]VirtualResource, error) { vms, err := s.GetClusterResources("type=vm") if err != nil { - return nil, err + return []VirtualResource{}, err } return vms, nil } @@ -57,90 +55,61 @@ func (s *ProxmoxService) DeleteVM(node string, vmID int) error { return nil } -func (s *ProxmoxService) ConvertVMToTemplate(node string, vmID int) error { - if err := s.validateVMID(vmID); err != nil { - return err - } - +func (s *ProxmoxService) GetVMSnapshots(node string, vmID int) ([]VMSnapshot, error) { req := tools.ProxmoxAPIRequest{ - Method: "POST", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/template", node, vmID), + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/snapshot", node, vmID), } - _, err := s.RequestHelper.MakeRequest(req) - if err != nil { - if !strings.Contains(err.Error(), "you can't convert a template to a template") { - return fmt.Errorf("failed to convert VM to template: %w", err) - } + var snapshots []VMSnapshot + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &snapshots); err != nil { + return nil, fmt.Errorf("failed to get snapshots for VMID %d on node %s: %w", vmID, node, err) } - return nil + return snapshots, nil } -func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { - // Get next available VMID +func (s *ProxmoxService) DeleteVMSnapshot(node string, vmID int, snapshotName string) error { req := tools.ProxmoxAPIRequest{ - Method: "GET", - Endpoint: "/cluster/nextid", - } - - var nextIDStr string - if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &nextIDStr); err != nil { - return nil, fmt.Errorf("failed to get next VMID: %w", err) - } - - newVMID, err := strconv.Atoi(nextIDStr) - if err != nil { - return nil, fmt.Errorf("invalid VMID received: %w", err) + Method: "DELETE", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/snapshot/%s", node, vmID, snapshotName), } - // Find best node for cloning - bestNode, err := s.FindBestNode() + _, err := s.RequestHelper.MakeRequest(req) if err != nil { - return nil, fmt.Errorf("failed to find best node: %w", err) + return fmt.Errorf("failed to delete snapshot %s for VMID %d on node %s: %w", snapshotName, vmID, node, err) } - // Clone VM - cloneBody := map[string]any{ - "newid": newVMID, - "name": sourceVM.Name, - "pool": newPoolName, - "full": 0, // Linked clone - "target": bestNode, - } - - cloneReq := tools.ProxmoxAPIRequest{ - Method: "POST", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/clone", sourceVM.Node, sourceVM.VMID), - RequestBody: cloneBody, - } + return nil +} - _, err = s.RequestHelper.MakeRequest(cloneReq) - if err != nil { - return nil, fmt.Errorf("failed to initiate VM clone: %w", err) +func (s *ProxmoxService) ConvertVMToTemplate(node string, vmID int) error { + if err := s.validateVMID(vmID); err != nil { + return err } - // Wait for clone to complete - newVM := &VM{ - Node: bestNode, - VMID: newVMID, + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/template", node, vmID), } - err = s.WaitForCloneCompletion(newVM, 5*time.Minute) // CLONE_TIMEOUT + _, err := s.RequestHelper.MakeRequest(req) if err != nil { - return nil, fmt.Errorf("clone operation failed: %w", err) + if !strings.Contains(err.Error(), "you can't convert a template to a template") { + return fmt.Errorf("failed to convert VM to template: %w", err) + } } - return newVM, nil + return nil } -func (s *ProxmoxService) CloneVMWithConfig(req VMCloneRequest) error { +func (s *ProxmoxService) CloneVM(req VMCloneRequest) error { // Clone VM cloneBody := map[string]any{ "newid": req.NewVMID, "name": req.SourceVM.Name, "pool": req.PoolName, - "full": 0, // Linked clone + "full": req.Full, "target": req.TargetNode, } @@ -158,48 +127,13 @@ func (s *ProxmoxService) CloneVMWithConfig(req VMCloneRequest) error { return nil } -func (s *ProxmoxService) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { - start := time.Now() - backoff := time.Second - maxBackoff := 30 * time.Second - - for time.Since(start) < timeout { - // Check VM status - status, err := s.getVMStatus(vm.Node, vm.VMID) - if err != nil { - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - continue - } - - if status == "running" || status == "stopped" { - // Check if VM is locked (clone in progress) - configResp, err := s.getVMConfig(vm.Node, vm.VMID) - if err != nil { - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - continue - } - - if configResp.Lock == "" { - return nil // Clone is complete and VM is not locked - } - } - - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - } - - return fmt.Errorf("clone operation timed out after %v", timeout) -} - -func (s *ProxmoxService) WaitForDisk(node string, vmid int, maxWait time.Duration) error { +func (s *ProxmoxService) WaitForDisk(node string, vmID int, maxWait time.Duration) error { start := time.Now() for time.Since(start) < maxWait { time.Sleep(2 * time.Second) - configResp, err := s.getVMConfig(node, vmid) + configResp, err := s.getVMConfig(node, vmID) if err != nil { continue } @@ -207,13 +141,13 @@ func (s *ProxmoxService) WaitForDisk(node string, vmid int, maxWait time.Duratio if configResp.HardDisk != "" { pendingReq := tools.ProxmoxAPIRequest{ Method: "GET", - Endpoint: fmt.Sprintf("/nodes/%s/storage/%s/content?vmid=%d", node, s.Config.StorageID, vmid), + Endpoint: fmt.Sprintf("/nodes/%s/storage/%s/content?vmid=%d", node, s.Config.StorageID, vmID), } var diskResponse []PendingDiskResponse err := s.RequestHelper.MakeRequestAndUnmarshal(pendingReq, &diskResponse) if err != nil || len(diskResponse) == 0 { - log.Printf("Error retrieving pending disk info for VMID %d on node %s: %v", vmid, node, err) + log.Printf("Error retrieving pending disk info for VMID %d on node %s: %v", vmID, node, err) continue } @@ -235,12 +169,12 @@ func (s *ProxmoxService) WaitForDisk(node string, vmid int, maxWait time.Duratio return fmt.Errorf("timeout waiting for VM disks to become available") } -func (s *ProxmoxService) WaitForStopped(vm VM) error { - return s.waitForStatus("stopped", vm) +func (s *ProxmoxService) WaitForStopped(node string, vmID int) error { + return s.waitForStatus("stopped", node, vmID) } -func (s *ProxmoxService) WaitForRunning(vm VM) error { - return s.waitForStatus("running", vm) +func (s *ProxmoxService) WaitForRunning(node string, vmID int) error { + return s.waitForStatus("running", node, vmID) } func (s *ProxmoxService) GetNextVMIDs(num int) ([]int, error) { @@ -289,12 +223,12 @@ func (s *ProxmoxService) vmAction(action string, node string, vmID int) error { return nil } -func (s *ProxmoxService) waitForStatus(targetStatus string, vm VM) error { +func (s *ProxmoxService) waitForStatus(targetStatus string, node string, vmID int) error { timeout := 2 * time.Minute start := time.Now() for time.Since(start) < timeout { - currentStatus, err := s.getVMStatus(vm.Node, vm.VMID) + currentStatus, err := s.getVMStatus(node, vmID) if err != nil { time.Sleep(5 * time.Second) continue From b807e42ae6de0bc51dbf7bd44216de603f99c610 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Thu, 18 Sep 2025 23:07:12 -0700 Subject: [PATCH 3/8] Fixed attempting to delete current snapshot when publishing --- internal/cloning/templates.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/cloning/templates.go b/internal/cloning/templates.go index ee10e9a..0de9dee 100644 --- a/internal/cloning/templates.go +++ b/internal/cloning/templates.go @@ -264,6 +264,10 @@ func (cs *CloningService) PublishTemplate(template KaminoTemplate) error { } for _, snapshot := range snapshots { + if snapshot.Name == "current" { + continue // Skip the "current" snapshot as it cannot be deleted + } + if err := cs.ProxmoxService.DeleteVMSnapshot(vm.NodeName, vm.VmId, snapshot.Name); err != nil { // Break out of snapshot loop on error and leave it to full clone log.Printf("Error deleting snapshot %s for VM %d: %v", snapshot.Name, vm.VmId, err) From 9567ba8e1f970d9db3425345c84ea69760ef826f Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sat, 20 Sep 2025 00:07:44 -0700 Subject: [PATCH 4/8] Implemented a check to limit the total number of pods a single user can deploy at once (5) --- internal/cloning/cloning_service.go | 8 ++++---- internal/cloning/pods.go | 20 +++++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/internal/cloning/cloning_service.go b/internal/cloning/cloning_service.go index edb7862..2adcbc4 100644 --- a/internal/cloning/cloning_service.go +++ b/internal/cloning/cloning_service.go @@ -72,12 +72,12 @@ func (cs *CloningService) CloneTemplate(req CloneRequest) error { if req.CheckExistingDeployments { for _, target := range req.Targets { targetPoolName := fmt.Sprintf("%s_%s", req.Template, target.Name) - isDeployed, err := cs.IsDeployed(targetPoolName) + isValid, err := cs.ValidateCloneRequest(targetPoolName, target.Name) if err != nil { - return fmt.Errorf("failed to check if template is deployed for %s: %w", target.Name, err) + return fmt.Errorf("failed to validate the deployment of template for %s: %w", target.Name, err) } - if isDeployed { - return fmt.Errorf("template %s is already or in the process of being deployed for %s", req.Template, target.Name) + if !isValid { + return fmt.Errorf("template %s is already deployed for %s or they have exceeded the maximum of 5 deployed pods", req.Template, target.Name) } } } diff --git a/internal/cloning/pods.go b/internal/cloning/pods.go index 3f56da9..dad1518 100644 --- a/internal/cloning/pods.go +++ b/internal/cloning/pods.go @@ -76,18 +76,28 @@ func (cs *CloningService) MapVirtualResourcesToPods(regex string) ([]Pod, error) return pods, nil } -func (cs *CloningService) IsDeployed(templateName string) (bool, error) { +func (cs *CloningService) ValidateCloneRequest(templateName string, username string) (bool, error) { podPools, err := cs.AdminGetPods() if err != nil { - return false, fmt.Errorf("failed to get pod pools: %w", err) + return false, fmt.Errorf("failed to get deployed pods: %w", err) } + var alreadyDeployed = false + var numDeployments = 0 + for _, pod := range podPools { // Remove the Pod ID number and _ to compare - if pod.Name[5:] == templateName { - return true, nil + if !alreadyDeployed && pod.Name[5:] == templateName { + alreadyDeployed = true + } + + if strings.Contains(pod.Name, username) { + numDeployments++ } } - return false, nil + // Valid if not already deployed and user has less than 5 deployments + var isValidCloneRequest = !alreadyDeployed && numDeployments < 5 + + return isValidCloneRequest, nil } From 46587457bc2eb58072ba36f83ba0dfd6a722bdfd Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sat, 20 Sep 2025 00:29:23 -0700 Subject: [PATCH 5/8] Wait for all locks (mainly snapshot-delete) to disappear before converting VMs to templates when publishing --- internal/cloning/templates.go | 8 +++++++- internal/proxmox/types.go | 7 ++++--- internal/proxmox/vms.go | 23 +++++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/cloning/templates.go b/internal/cloning/templates.go index 0de9dee..450327d 100644 --- a/internal/cloning/templates.go +++ b/internal/cloning/templates.go @@ -280,8 +280,14 @@ func (cs *CloningService) PublishTemplate(template KaminoTemplate) error { // If a VM cannot be converted, it will skip the VM since it will automatically // full clone if not a template or it is already a template for _, vm := range vms { + // Wait for any locks to clear before converting + if err := cs.ProxmoxService.WaitForLock(vm.NodeName, vm.VmId); err != nil { + log.Printf("Error waiting for lock to clear on VM %d: %v", vm.VmId, err) + continue + } + + // Attempt to convert to template if err := cs.ProxmoxService.ConvertVMToTemplate(vm.NodeName, vm.VmId); err != nil { - // Skip VM since it will automatically full clone if not a template or it is already a template log.Printf("Error converting VM %d to template: %v", vm.VmId, err) continue } diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go index fd34785..4600ea7 100644 --- a/internal/proxmox/types.go +++ b/internal/proxmox/types.go @@ -48,8 +48,9 @@ type Service interface { ConvertVMToTemplate(node string, vmID int) error CloneVM(req VMCloneRequest) error WaitForDisk(node string, vmID int, maxWait time.Duration) error - WaitForRunning(node string, vmiID int) error - WaitForStopped(node string, vmiID int) error + WaitForLock(node string, vmID int) error + WaitForRunning(node string, vmID int) error + WaitForStopped(node string, vmID int) error // Pool Management GetPoolVMs(poolName string) ([]VirtualResource, error) @@ -90,7 +91,7 @@ type ProxmoxNodeStatus struct { type VirtualResourceConfig struct { HardDisk string `json:"scsi0"` - Lock string `json:"lock,omitempty"` + Lock string `json:"lock"` Net0 string `json:"net0"` Net1 string `json:"net1,omitempty"` } diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index a76ab5e..620bdb6 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -201,6 +201,29 @@ func (s *ProxmoxService) GetNextVMIDs(num int) ([]int, error) { return vmIDs, nil } +func (s *ProxmoxService) WaitForLock(node string, vmID int) error { + timeout := 1 * time.Minute + start := time.Now() + + for time.Since(start) < timeout { + config, err := s.getVMConfig(node, vmID) + if err != nil { + time.Sleep(5 * time.Second) + continue + } + + log.Printf("VM %d lock status: '%s'", vmID, config.Lock) + + if config.Lock == "" { + return nil // No lock + } + + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("timeout waiting for VM lock to be cleared") +} + // ================================================= // Private Functions // ================================================= From 41569b4034ceb0ceda04ae9afcb97e4a6b7f577e Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Mon, 22 Sep 2025 00:21:12 -0700 Subject: [PATCH 6/8] Changed VMID calculation to use smallest available range based on request. Also added mutex locking for requesting VMIDs to avoid race condition. --- internal/api/handlers/cloning_handler.go | 1 + internal/api/handlers/types.go | 7 ++++--- internal/cloning/cloning_service.go | 22 +++++++++++++++++++--- internal/cloning/types.go | 3 +++ internal/proxmox/vms.go | 16 ++++++++++------ 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/internal/api/handlers/cloning_handler.go b/internal/api/handlers/cloning_handler.go index d4ef37d..30d8a43 100644 --- a/internal/api/handlers/cloning_handler.go +++ b/internal/api/handlers/cloning_handler.go @@ -149,6 +149,7 @@ func (ch *CloningHandler) AdminCloneTemplateHandler(c *gin.Context) { Template: req.Template, Targets: targets, CheckExistingDeployments: false, + StartingVMID: req.StartingVMID, } // Perform clone operation diff --git a/internal/api/handlers/types.go b/internal/api/handlers/types.go index 2fc47a0..7f8a5e0 100644 --- a/internal/api/handlers/types.go +++ b/internal/api/handlers/types.go @@ -66,9 +66,10 @@ type GroupsRequest struct { } type AdminCloneRequest struct { - Template string `json:"template" binding:"required,min=1,max=100" validate:"alphanum,ascii"` - Usernames []string `json:"usernames" binding:"omitempty,dive,min=1,max=100" validate:"dive,alphanum,ascii"` - Groups []string `json:"groups" binding:"omitempty,dive,min=1,max=100" validate:"dive,alphanum,ascii"` + Template string `json:"template" binding:"required,min=1,max=100" validate:"alphanum,ascii"` + Usernames []string `json:"usernames" binding:"omitempty,dive,min=1,max=100" validate:"dive,alphanum,ascii"` + Groups []string `json:"groups" binding:"omitempty,dive,min=1,max=100" validate:"dive,alphanum,ascii"` + StartingVMID int `json:"starting_vmid" binding:"omitempty,min=100,max=999900"` } type DeletePodRequest struct { diff --git a/internal/cloning/cloning_service.go b/internal/cloning/cloning_service.go index 2adcbc4..31c3d5d 100644 --- a/internal/cloning/cloning_service.go +++ b/internal/cloning/cloning_service.go @@ -127,9 +127,22 @@ func (cs *CloningService) CloneTemplate(req CloneRequest) error { return fmt.Errorf("failed to get next pod IDs: %w", err) } - vmIDs, err := cs.ProxmoxService.GetNextVMIDs(len(req.Targets) * numVMsPerTarget) - if err != nil { - return fmt.Errorf("failed to get next VM IDs: %w", err) + // Lock the vmid allocation mutex to prevent race conditions during vmid allocation + cs.vmidMutex.Lock() + + // Use StartingVMID from request if provided, otherwise get next available VMIDs + var vmIDs []int + numVMs := len(req.Targets) * numVMsPerTarget + if req.StartingVMID != 0 { + log.Printf("Starting VMID allocation from specified starting VMID: %d", req.StartingVMID) + for i := range numVMs { + vmIDs = append(vmIDs, req.StartingVMID+i) + } + } else { + vmIDs, err = cs.ProxmoxService.GetNextVMIDs(numVMs) + if err != nil { + return fmt.Errorf("failed to get next VM IDs: %w", err) + } } for i := range req.Targets { @@ -223,6 +236,9 @@ func (cs *CloningService) CloneTemplate(req CloneRequest) error { } } + // Release the vmid allocation mutex now that all of the VMs are cloned on proxmox + cs.vmidMutex.Unlock() + // 9. Configure VNet of all VMs log.Printf("Configuring VNets for %d targets", len(req.Targets)) for _, target := range req.Targets { diff --git a/internal/cloning/types.go b/internal/cloning/types.go index ae88cc7..50a2a45 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -2,6 +2,7 @@ package cloning import ( "database/sql" + "sync" "time" "github.com/cpp-cyber/proclone/internal/ldap" @@ -80,6 +81,7 @@ type CloningService struct { DatabaseService DatabaseService LDAPService ldap.Service Config *Config + vmidMutex sync.Mutex // Protects resource allocation operations (Pod IDs and VM IDs) } // PodResponse represents the response structure for pod operations @@ -113,6 +115,7 @@ type CloneRequest struct { Template string Targets []CloneTarget CheckExistingDeployments bool // Whether to check if templates are already deployed + StartingVMID int // Optional starting VMID for admin clones } type RouterInfo struct { diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index 620bdb6..e3e6e12 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -184,18 +184,22 @@ func (s *ProxmoxService) GetNextVMIDs(num int) ([]int, error) { return nil, fmt.Errorf("failed to get cluster resources: %w", err) } - // Iterate thought and find the highest VMID under 4000 - highestID := 100 - for _, res := range resources { - if res.VmId > highestID && res.VmId < 4000 { - highestID = res.VmId + // Iterate through and find the lowest available VMID range that has enough space based on num + lowestID := resources[len(resources)-1].VmId // Set to highest existing VMID by default + prevID := resources[0].VmId // Start at the lowest existing VMID + for _, vm := range resources[1 : len(resources)-1] { + if (vm.VmId - prevID) >= num { + log.Printf("Found available VMID range between %d and %d", prevID, vm.VmId) + lowestID = prevID + break } + prevID = vm.VmId } // Generate the next num VMIDs var vmIDs []int for i := 1; i <= num; i++ { - vmIDs = append(vmIDs, highestID+i) + vmIDs = append(vmIDs, lowestID+i) } return vmIDs, nil From 70be1536c1fcd96eb2952654130275cd85bab785 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Mon, 22 Sep 2025 00:49:39 -0700 Subject: [PATCH 7/8] Accounted for VMs being out of order due to multiple nodes when calculating VMID ranges. --- internal/proxmox/vms.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index e3e6e12..a0b9c82 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -3,6 +3,7 @@ package proxmox import ( "fmt" "log" + "slices" "strings" "time" @@ -184,16 +185,23 @@ func (s *ProxmoxService) GetNextVMIDs(num int) ([]int, error) { return nil, fmt.Errorf("failed to get cluster resources: %w", err) } + var usedVMIDs []int + for _, vm := range resources { + usedVMIDs = append(usedVMIDs, vm.VmId) + } + // Sort VMIDs from lowest to highest + slices.Sort(usedVMIDs) + // Iterate through and find the lowest available VMID range that has enough space based on num - lowestID := resources[len(resources)-1].VmId // Set to highest existing VMID by default - prevID := resources[0].VmId // Start at the lowest existing VMID - for _, vm := range resources[1 : len(resources)-1] { - if (vm.VmId - prevID) >= num { - log.Printf("Found available VMID range between %d and %d", prevID, vm.VmId) + lowestID := usedVMIDs[len(usedVMIDs)-1] // Set to highest existing VMID by default + prevID := usedVMIDs[0] // Start at the lowest existing VMID + for _, vmID := range usedVMIDs[1 : len(usedVMIDs)-1] { + if (vmID - prevID) >= num { + log.Printf("Found available VMID range between %d and %d", prevID, vmID) lowestID = prevID break } - prevID = vm.VmId + prevID = vmID } // Generate the next num VMIDs From a0ff9e94ef66fcf7d08bb9cda23eb9ea7fde0cbe Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Mon, 22 Sep 2025 01:06:05 -0700 Subject: [PATCH 8/8] Fixed error that would not allow final vm to clone properly in pod. --- internal/proxmox/vms.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index a0b9c82..6b2f3be 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -196,7 +196,7 @@ func (s *ProxmoxService) GetNextVMIDs(num int) ([]int, error) { lowestID := usedVMIDs[len(usedVMIDs)-1] // Set to highest existing VMID by default prevID := usedVMIDs[0] // Start at the lowest existing VMID for _, vmID := range usedVMIDs[1 : len(usedVMIDs)-1] { - if (vmID - prevID) >= num { + if (vmID - prevID) > num { log.Printf("Found available VMID range between %d and %d", prevID, vmID) lowestID = prevID break