diff --git a/.github/workflows/ephemeral.yml b/.github/workflows/ephemeral.yml index 9915ce0..d5c459f 100644 --- a/.github/workflows/ephemeral.yml +++ b/.github/workflows/ephemeral.yml @@ -140,7 +140,7 @@ jobs: run: | cd infra make ephemeral-init - make ephemeral-apply WORKSPACE=${{ steps.cluster-info.outputs.cluster_name }} + make ephemeral-apply WORKSPACE=${{ steps.cluster-info.outputs.cluster_name }} MINIMAL=true # ==================== BUILD AND DEPLOY (PR opened/updated) ==================== diff --git a/Dockerfile b/Dockerfile index 37f47ad..ba636b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,11 +21,7 @@ FROM alpine:3.19 RUN apk --no-cache add \ ca-certificates \ curl \ - kubectl \ - openssh \ - && wget -O /usr/local/bin/virtctl \ - https://github.com/kubevirt/kubevirt/releases/download/v1.5.1/virtctl-v1.5.1-linux-amd64 \ - && chmod +x /usr/local/bin/virtctl + kubectl RUN addgroup -g 1001 -S appgroup && \ adduser -u 1001 -S appuser -G appgroup @@ -41,10 +37,6 @@ RUN chown -R appuser:appgroup /app USER appuser -RUN mkdir -p /home/appuser/.ssh && \ - chown -R appuser:appgroup /home/appuser/.ssh && \ - chmod 700 /home/appuser/.ssh - EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ diff --git a/kustomize/base/configmap.yaml b/kustomize/base/configmap.yaml index ac10a1f..afbe09d 100644 --- a/kustomize/base/configmap.yaml +++ b/kustomize/base/configmap.yaml @@ -22,5 +22,6 @@ data: GOLDEN_IMAGE_NAME: new-golden-image-1-33-0 GOLDEN_IMAGE_NAMESPACE: vm-templates VALIDATE_GOLDEN_IMAGE: true + TERMINAL_MGMT_URL: https://terminal.cks.fullstack.pw TEMPLATE_PATH: /app/templates SCENARIOS_PATH: /app/scenarios diff --git a/kustomize/ephemeral-base/configmap.yaml b/kustomize/ephemeral-base/configmap.yaml index ac10a1f..afbe09d 100644 --- a/kustomize/ephemeral-base/configmap.yaml +++ b/kustomize/ephemeral-base/configmap.yaml @@ -22,5 +22,6 @@ data: GOLDEN_IMAGE_NAME: new-golden-image-1-33-0 GOLDEN_IMAGE_NAMESPACE: vm-templates VALIDATE_GOLDEN_IMAGE: true + TERMINAL_MGMT_URL: https://terminal.cks.fullstack.pw TEMPLATE_PATH: /app/templates SCENARIOS_PATH: /app/scenarios diff --git a/src/cmd/server/main.go b/src/cmd/server/main.go index f11c677..c9d24ef 100644 --- a/src/cmd/server/main.go +++ b/src/cmd/server/main.go @@ -25,7 +25,6 @@ import ( "github.com/fullstack-pw/cks/backend/internal/scenarios" "github.com/fullstack-pw/cks/backend/internal/services" "github.com/fullstack-pw/cks/backend/internal/sessions" - "github.com/fullstack-pw/cks/backend/internal/terminal" "github.com/fullstack-pw/cks/backend/internal/validation" ) @@ -194,13 +193,8 @@ func main() { logger.WithError(err).Fatal("Failed to create kubevirt client") } - // Rest of the main function remains the same... - // Create unified validator (ADD THIS) unifiedValidator := validation.NewUnifiedValidator(kubevirtClient, logger) - // Create terminal manager (existing) - terminalManager := terminal.NewManager(kubeClient, kubevirtClient, k8sConfig, logger) - // Create scenario manager first scenarioManager, err := scenarios.NewScenarioManager(cfg.ScenariosPath, logger) if err != nil { @@ -221,9 +215,8 @@ func main() { // Create service layer implementations sessionService := services.NewSessionService(sessionManager) - terminalService := services.NewTerminalService(terminalManager) + terminalService := services.NewTerminalService(kubevirtClient, cfg) scenarioService := services.NewScenarioService(scenarioManager) - sessionManager.SetTerminalCleanupFunc(terminalService.CleanupSessionSSH) // Create and register controllers sessionController := controllers.NewSessionController(sessionService, scenarioService, logger, unifiedValidator) @@ -243,7 +236,7 @@ func main() { Addr: fmt.Sprintf("%s:%d", cfg.ServerHost, cfg.ServerPort), Handler: router, ReadTimeout: 15 * time.Second, - WriteTimeout: 300 * time.Second, // Longer timeout for WebSockets + WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } diff --git a/src/go.mod b/src/go.mod index 8b79c37..d3bcd02 100644 --- a/src/go.mod +++ b/src/go.mod @@ -5,14 +5,11 @@ go 1.24.0 toolchain go1.24.1 require ( - github.com/creack/pty v1.1.24 github.com/gin-contrib/cors v1.7.5 github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/prometheus/client_golang v1.19.1 github.com/sirupsen/logrus v1.9.3 - github.com/stretchr/testify v1.10.0 gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.31.8 k8s.io/apimachinery v0.31.8 @@ -50,6 +47,7 @@ require ( github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -66,7 +64,6 @@ require ( github.com/openshift/client-go v0.0.0-20210112165513-ebc401615f47 // indirect github.com/openshift/custom-resource-status v1.1.2 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.68.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect diff --git a/src/go.sum b/src/go.sum index bd3e3ac..b68b6ed 100644 --- a/src/go.sum +++ b/src/go.sum @@ -72,8 +72,6 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/containernetworking/cni v0.7.1/go.mod h1:LGwApLUm2FpoOfxTDEeq8T9ipbpZ61X79hmU3w8FmsY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/src/internal/config/config.go b/src/internal/config/config.go index d9be4a2..0cdec58 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -40,6 +40,9 @@ type Config struct { GoldenImageNamespace string // Namespace where golden images are stored ValidateGoldenImage bool // Whether to validate image exists before VM creation + // Terminal management + TerminalMgmtURL string + // Scenario settings ScenariosPath string } @@ -77,6 +80,9 @@ func LoadConfig() (*Config, error) { GoldenImageNamespace: getEnv("GOLDEN_IMAGE_NAMESPACE", "vm-templates"), ValidateGoldenImage: getEnvAsBool("VALIDATE_GOLDEN_IMAGE", true), + // Terminal management + TerminalMgmtURL: getEnv("TERMINAL_MGMT_URL", "https://terminal.cks.fullstack.pw"), + // Scenario defaults ScenariosPath: getEnv("SCENARIOS_PATH", "scenarios"), } diff --git a/src/internal/controllers/terminal_controller.go b/src/internal/controllers/terminal_controller.go index e66b1c6..719730f 100644 --- a/src/internal/controllers/terminal_controller.go +++ b/src/internal/controllers/terminal_controller.go @@ -1,5 +1,3 @@ -// backend/internal/controllers/terminal_controller.go - package controllers import ( @@ -13,14 +11,12 @@ import ( "github.com/fullstack-pw/cks/backend/internal/services" ) -// TerminalController handles HTTP requests related to terminal sessions type TerminalController struct { terminalService services.TerminalService sessionService services.SessionService logger *logrus.Logger } -// NewTerminalController creates a new terminal controller func NewTerminalController( terminalService services.TerminalService, sessionService services.SessionService, @@ -33,20 +29,10 @@ func NewTerminalController( } } -// RegisterRoutes registers terminal-related routes func (tc *TerminalController) RegisterRoutes(router *gin.Engine) { - // Terminal routes router.POST("/api/v1/sessions/:id/terminals", tc.CreateTerminal) - - terminals := router.Group("/api/v1/terminals") - { - terminals.GET("/:id/attach", tc.AttachTerminal) - terminals.POST("/:id/resize", tc.ResizeTerminal) - terminals.DELETE("/:id", tc.CloseTerminal) - } } -// CreateTerminal creates a new terminal session or reuses existing one func (tc *TerminalController) CreateTerminal(c *gin.Context) { sessionID := c.Param("id") @@ -57,7 +43,6 @@ func (tc *TerminalController) CreateTerminal(c *gin.Context) { return } - // Check if session exists session, err := tc.sessionService.GetSession(sessionID) if err != nil { tc.logger.WithError(err).WithField("sessionID", sessionID).Error("Session not found") @@ -65,7 +50,6 @@ func (tc *TerminalController) CreateTerminal(c *gin.Context) { return } - // Check if session is in running state if session.Status != models.SessionStatusRunning { tc.logger.WithFields(logrus.Fields{ "sessionID": sessionID, @@ -77,7 +61,6 @@ func (tc *TerminalController) CreateTerminal(c *gin.Context) { return } - // Validate target targetVM := "" switch request.Target { case "control-plane": @@ -90,117 +73,20 @@ func (tc *TerminalController) CreateTerminal(c *gin.Context) { return } - // Always create or get terminal session - terminalID, err := tc.terminalService.CreateSession(sessionID, session.Namespace, targetVM) + terminalURL, err := tc.terminalService.GetTerminalURL(c.Request.Context(), session.Namespace, targetVM) if err != nil { - tc.logger.WithError(err).Error("Failed to create terminal session") - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create terminal: %v", err)}) + tc.logger.WithError(err).Error("Failed to get terminal URL") + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get terminal URL: %v", err)}) return } - // Store terminal info in session - err = tc.sessionService.StoreTerminalSession(sessionID, terminalID, request.Target) - if err != nil { - tc.logger.WithError(err).Error("Failed to store terminal session info") - // Continue anyway, don't fail the request - } - tc.logger.WithFields(logrus.Fields{ - "sessionID": sessionID, - "terminalID": terminalID, - "target": request.Target, - }).Info("Terminal session created/retrieved") + "sessionID": sessionID, + "target": request.Target, + "terminalURL": terminalURL, + }).Info("Terminal URL generated") c.JSON(http.StatusOK, models.CreateTerminalResponse{ - TerminalID: terminalID, + TerminalURL: terminalURL, }) } - -// AttachTerminal handles WebSocket connection to a terminal -func (tc *TerminalController) AttachTerminal(c *gin.Context) { - terminalID := c.Param("id") - - tc.logger.WithField("terminalID", terminalID).Info("Attaching to terminal session") - - // Add CORS headers for WebSocket connections - c.Header("Access-Control-Allow-Origin", "*") - c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept") - c.Header("Access-Control-Allow-Credentials", "true") - - // Handle WebSocket using the service - tc.terminalService.HandleTerminal(c.Writer, c.Request, terminalID) -} - -// ResizeTerminal handles terminal resize events -func (tc *TerminalController) ResizeTerminal(c *gin.Context) { - terminalID := c.Param("id") - - var request models.ResizeTerminalRequest - if err := c.ShouldBindJSON(&request); err != nil { - tc.logger.WithError(err).Error("Invalid resize request") - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid resize request"}) - return - } - - // Validate dimensions - if request.Rows == 0 || request.Cols == 0 { - tc.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "rows": request.Rows, - "cols": request.Cols, - }).Error("Invalid terminal dimensions") - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid terminal dimensions"}) - return - } - - // Resize terminal using the service - err := tc.terminalService.ResizeTerminal(terminalID, request.Rows, request.Cols) - if err != nil { - tc.logger.WithError(err).WithField("terminalID", terminalID).Error("Failed to resize terminal") - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to resize terminal: %v", err)}) - return - } - - tc.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "rows": request.Rows, - "cols": request.Cols, - }).Debug("Terminal resized") - - c.JSON(http.StatusOK, gin.H{"message": "Terminal resized"}) -} - -// CloseTerminal closes a terminal session -func (tc *TerminalController) CloseTerminal(c *gin.Context) { - terminalID := c.Param("id") - - // Close terminal session using the service - err := tc.terminalService.CloseSession(terminalID) - if err != nil { - tc.logger.WithError(err).WithField("terminalID", terminalID).Error("Failed to close terminal") - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close terminal: %v", err)}) - return - } - - // Unregister terminal session from all sessions - // This needs to be refactored to be more service-oriented - sessions := tc.sessionService.ListSessions() - for _, session := range sessions { - for id := range session.TerminalSessions { - if id == terminalID { - unregErr := tc.sessionService.UnregisterTerminalSession(session.ID, terminalID) - if unregErr != nil { - tc.logger.WithError(unregErr).WithFields(logrus.Fields{ - "sessionID": session.ID, - "terminalID": terminalID, - }).Warn("Failed to unregister terminal session, continuing anyway") - } - break - } - } - } - - tc.logger.WithField("terminalID", terminalID).Info("Terminal session closed") - c.JSON(http.StatusOK, gin.H{"message": "Terminal closed"}) -} diff --git a/src/internal/kubevirt/client.go b/src/internal/kubevirt/client.go index 05472ef..4d6b3cf 100644 --- a/src/internal/kubevirt/client.go +++ b/src/internal/kubevirt/client.go @@ -339,8 +339,8 @@ func (c *Client) CreateCluster(ctx context.Context, namespace, controlPlaneName, return c.createCloudInitSecret(ctx, namespace, workerNodeName, "worker", map[string]string{ "JOIN_COMMAND": joinCommand, "JOIN": joinCommand, - "CONTROL_PLANE_ENDPOINT": fmt.Sprintf("%s.%s.pod.cluster.local", strings.ReplaceAll(c.getVMIP(ctx, namespace, controlPlaneName), ".", "-"), namespace), - "CONTROL_PLANE_IP": c.getVMIP(ctx, namespace, controlPlaneName), + "CONTROL_PLANE_ENDPOINT": fmt.Sprintf("%s.%s.pod.cluster.local", strings.ReplaceAll(c.GetVMIP(ctx, namespace, controlPlaneName), ".", "-"), namespace), + "CONTROL_PLANE_IP": c.GetVMIP(ctx, namespace, controlPlaneName), "CONTROL_PLANE_VM_NAME": controlPlaneName, }) }) @@ -695,8 +695,8 @@ func (c *Client) getJoinCommand(ctx context.Context, namespace, controlPlaneName return joinCommand, nil } -// getVMIP gets the IP address of a VM -func (c *Client) getVMIP(ctx context.Context, namespace, vmName string) string { +// GetVMIP gets the IP address of a VM +func (c *Client) GetVMIP(ctx context.Context, namespace, vmName string) string { var ip string err := wait.PollImmediate(5*time.Second, 2*time.Minute, func() (bool, error) { // Get VM instance diff --git a/src/internal/models/models.go b/src/internal/models/models.go index 858b793..4eaf88a 100644 --- a/src/internal/models/models.go +++ b/src/internal/models/models.go @@ -181,13 +181,7 @@ type CreateTerminalRequest struct { // CreateTerminalResponse represents a response to a create terminal request type CreateTerminalResponse struct { - TerminalID string `json:"terminalId"` -} - -// ResizeTerminalRequest represents a request to resize a terminal -type ResizeTerminalRequest struct { - Rows uint16 `json:"rows"` - Cols uint16 `json:"cols"` + TerminalURL string `json:"terminalUrl"` } type SetupCondition struct { diff --git a/src/internal/services/interfaces.go b/src/internal/services/interfaces.go index 82f935e..f8aaeac 100644 --- a/src/internal/services/interfaces.go +++ b/src/internal/services/interfaces.go @@ -4,7 +4,6 @@ package services import ( "context" - "net/http" "time" "github.com/fullstack-pw/cks/backend/internal/models" @@ -31,11 +30,7 @@ type SessionService interface { // TerminalService defines the interface for terminal-related operations type TerminalService interface { - CreateSession(sessionID, namespace, target string) (string, error) - HandleTerminal(w http.ResponseWriter, r *http.Request, terminalID string) - ResizeTerminal(terminalID string, rows, cols uint16) error - CloseSession(terminalID string) error - CleanupSessionSSH(sessionID string) // Add this method + GetTerminalURL(ctx context.Context, namespace, vmName string) (string, error) } // ScenarioService defines the interface for scenario-related operations diff --git a/src/internal/services/terminal_service.go b/src/internal/services/terminal_service.go index 69b91dc..44b31a4 100644 --- a/src/internal/services/terminal_service.go +++ b/src/internal/services/terminal_service.go @@ -1,46 +1,29 @@ -// backend/internal/services/terminal_service.go - package services import ( - "net/http" + "context" + "fmt" - "github.com/fullstack-pw/cks/backend/internal/terminal" + "github.com/fullstack-pw/cks/backend/internal/config" + "github.com/fullstack-pw/cks/backend/internal/kubevirt" ) -// TerminalServiceImpl implements the TerminalService interface type TerminalServiceImpl struct { - terminalManager *terminal.Manager + kubevirtClient *kubevirt.Client + terminalMgmtURL string } -// NewTerminalService creates a new terminal service -func NewTerminalService(terminalManager *terminal.Manager) TerminalService { +func NewTerminalService(kubevirtClient *kubevirt.Client, cfg *config.Config) TerminalService { return &TerminalServiceImpl{ - terminalManager: terminalManager, + kubevirtClient: kubevirtClient, + terminalMgmtURL: cfg.TerminalMgmtURL, } } -// CreateSession creates a new terminal session -func (t *TerminalServiceImpl) CreateSession(sessionID, namespace, target string) (string, error) { - return t.terminalManager.CreateSession(sessionID, namespace, target) -} - -// HandleTerminal handles a terminal connection -func (t *TerminalServiceImpl) HandleTerminal(w http.ResponseWriter, r *http.Request, terminalID string) { - t.terminalManager.HandleTerminal(w, r, terminalID) -} - -// ResizeTerminal resizes a terminal -func (t *TerminalServiceImpl) ResizeTerminal(terminalID string, rows, cols uint16) error { - return t.terminalManager.ResizeTerminal(terminalID, rows, cols) -} - -// CloseSession closes a terminal session -func (t *TerminalServiceImpl) CloseSession(terminalID string) error { - return t.terminalManager.CloseSession(terminalID) -} - -// CleanupSessionSSH cleans up persistent SSH connections for a session -func (t *TerminalServiceImpl) CleanupSessionSSH(sessionID string) { - t.terminalManager.CleanupSessionSSH(sessionID) +func (t *TerminalServiceImpl) GetTerminalURL(ctx context.Context, namespace, vmName string) (string, error) { + vmIP := t.kubevirtClient.GetVMIP(ctx, namespace, vmName) + if vmIP == "" || vmIP == "0.0.0.0" { + return "", fmt.Errorf("failed to resolve IP for VM %s in namespace %s", vmName, namespace) + } + return fmt.Sprintf("%s/terminal?vmIP=%s", t.terminalMgmtURL, vmIP), nil } diff --git a/src/internal/sessions/session_manager.go b/src/internal/sessions/session_manager.go index c4d8b47..fd7f0fa 100644 --- a/src/internal/sessions/session_manager.go +++ b/src/internal/sessions/session_manager.go @@ -35,7 +35,6 @@ type SessionManager struct { stopCh chan struct{} scenarioManager *scenarios.ScenarioManager clusterPool *clusterpool.Manager - terminalCleanupFunc func(sessionID string) } func NewSessionManager( @@ -68,11 +67,6 @@ func NewSessionManager( return sm, nil } -// SetTerminalCleanupFunc sets the callback for cleaning up terminal connections -func (sm *SessionManager) SetTerminalCleanupFunc(cleanupFunc func(sessionID string)) { - sm.terminalCleanupFunc = cleanupFunc -} - // CreateSession creates a new session using cluster pool assignment func (sm *SessionManager) CreateSession(ctx context.Context, scenarioID string) (*models.Session, error) { sm.lock.Lock() @@ -231,11 +225,6 @@ func (sm *SessionManager) DeleteSession(ctx context.Context, sessionID string) e }).Error("Failed to release cluster") } } - // Clean up persistent terminal connections - if sm.terminalCleanupFunc != nil { - sm.terminalCleanupFunc(sessionID) - sm.logger.WithField("sessionID", sessionID).Info("Cleaned up persistent terminal connections for deleted session") - } return nil } diff --git a/src/internal/terminal/terminal_manager.go b/src/internal/terminal/terminal_manager.go deleted file mode 100644 index f60348e..0000000 --- a/src/internal/terminal/terminal_manager.go +++ /dev/null @@ -1,957 +0,0 @@ -// backend/internal/terminal/terminal_manager.go - Terminal session management - -package terminal - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "regexp" - "strings" - "sync" - "syscall" - "time" - - "github.com/creack/pty" - "github.com/gorilla/websocket" - "github.com/sirupsen/logrus" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - - "github.com/fullstack-pw/cks/backend/internal/kubevirt" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type PersistentSSHConnection struct { - ID string - SessionID string - Target string - Namespace string - Command *exec.Cmd - PTY *os.File - Created time.Time - LastUsed time.Time - ActiveConns int // Number of active WebSocket connections - Mutex sync.Mutex -} - -type Manager struct { - sessions map[string]*Session - persistentSSH map[string]*PersistentSSHConnection // Key: sessionID-target - lock sync.RWMutex - persistentSSHLock sync.RWMutex - kubeClient kubernetes.Interface - kubevirtClient *kubevirt.Client - config *rest.Config - sessionExpiry time.Duration - logger *logrus.Logger - kubernetesContext string // Kubernetes context for virtctl commands - tempKubeconfigPath string // Cached path to temporary kubeconfig with correct context -} - -type Session struct { - ID string - SessionID string - Target string // VM name - Namespace string - Created time.Time - LastUsed time.Time - ActiveConnection bool - ConnectionMutex sync.Mutex -} - -func NewManager(kubeClient kubernetes.Interface, kubevirtClient *kubevirt.Client, config *rest.Config, logger *logrus.Logger) *Manager { - tm := &Manager{ - sessions: make(map[string]*Session), - persistentSSH: make(map[string]*PersistentSSHConnection), - kubeClient: kubeClient, - kubevirtClient: kubevirtClient, - config: config, - sessionExpiry: 30 * time.Minute, - logger: logger, - kubernetesContext: os.Getenv("KUBERNETES_CONTEXT"), // Get context from environment - } - - // Start cleanup goroutine - go tm.cleanupExpiredSessions() - - return tm -} - -// CreateSession creates a new terminal session or reuses existing one -func (tm *Manager) CreateSession(sessionID, namespace, target string) (string, error) { - tm.lock.Lock() - defer tm.lock.Unlock() - - // Generate deterministic terminal ID based on session and target - // Normalize target names to be consistent - normalizedTarget := target - if strings.HasPrefix(target, "cp-") { - normalizedTarget = "control-plane" - } else if strings.HasPrefix(target, "wk-") { - normalizedTarget = "worker-node" - } - terminalID := fmt.Sprintf("%s-%s", sessionID, normalizedTarget) - // Check if terminal session already exists - if existingSession, exists := tm.sessions[terminalID]; exists { - // Update last used time - existingSession.LastUsed = time.Now() - - tm.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "sessionID": sessionID, - "target": target, - }).Info("Reusing existing terminal session") - - return terminalID, nil - } - - // Create new session - session := &Session{ - ID: terminalID, - SessionID: sessionID, - Target: target, - Namespace: namespace, - Created: time.Now(), - LastUsed: time.Now(), - ActiveConnection: false, - } - - // Store session - tm.sessions[terminalID] = session - tm.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "namespace": namespace, - "target": target, - }).Info("New terminal session created with deterministic ID") - - return terminalID, nil -} - -// GetSession retrieves a terminal session or recreates it if it matches the expected pattern -func (tm *Manager) GetSession(terminalID string) (*Session, error) { - tm.lock.RLock() - session, exists := tm.sessions[terminalID] - tm.lock.RUnlock() - - if exists { - // Update last used time - session.LastUsed = time.Now() - return session, nil - } - - // Check if this is a valid terminal ID pattern (sessionID-target) - // Expected format: "xxxxxxxx-control-plane" or "xxxxxxxx-worker-node" - if !tm.isValidTerminalID(terminalID) { - return nil, fmt.Errorf("terminal session not found: %s", terminalID) - } - - // Extract sessionID and target from terminalID - parts := strings.Split(terminalID, "-") - if len(parts) < 2 { - return nil, fmt.Errorf("invalid terminal ID format: %s", terminalID) - } - - sessionID := parts[0] - target := strings.Join(parts[1:], "-") // Handle "control-plane" and "worker-node" - - tm.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "sessionID": sessionID, - "target": target, - }).Info("Auto-creating terminal session for reconnection") - - // We need namespace info, but we can derive it from the pattern - // For cluster pool, namespace is "cluster1", "cluster2", or "cluster3" - // We'll need to get this from somewhere... for now, let's add a method to find it - namespace := tm.findNamespaceForSession(sessionID) - if namespace == "" { - return nil, fmt.Errorf("cannot determine namespace for session: %s", sessionID) - } - - // Create the session - tm.lock.Lock() - defer tm.lock.Unlock() - - // Double-check it wasn't created while we were waiting for the lock - if existingSession, exists := tm.sessions[terminalID]; exists { - existingSession.LastUsed = time.Now() - return existingSession, nil - } - - // Create new session - session = &Session{ - ID: terminalID, - SessionID: sessionID, - Target: target, - Namespace: namespace, - Created: time.Now(), - LastUsed: time.Now(), - ActiveConnection: false, - } - - tm.sessions[terminalID] = session - tm.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "namespace": namespace, - "target": target, - }).Info("Terminal session auto-created for reconnection") - - return session, nil -} - -// isValidTerminalID validates terminal ID format -func (tm *Manager) isValidTerminalID(terminalID string) bool { - // Must match pattern: 8chars-target where target is "control-plane" or "worker-node" - pattern := `^[a-f0-9]{8}-(control-plane|worker-node)$` - matched, _ := regexp.MatchString(pattern, terminalID) - return matched -} - -// Add helper method to find namespace for a session -func (tm *Manager) findNamespaceForSession(sessionID string) string { - // For cluster pool implementation, we need to check which cluster the session is assigned to - // This is a simplified version - in production, you'd query the session service - - // Try cluster1, cluster2, cluster3 (for cluster pool) - namespaces := []string{"cluster1", "cluster2", "cluster3"} - - // Also try the session-based namespace pattern - namespaces = append(namespaces, fmt.Sprintf("cks-%s", sessionID)) - - // Check if any VMs exist in these namespaces - for _, ns := range namespaces { - // Quick check if namespace exists and has VMs - vms, err := tm.kubevirtClient.VirtClient().VirtualMachine(ns).List(context.Background(), metav1.ListOptions{}) - if err == nil && len(vms.Items) > 0 { - tm.logger.WithFields(logrus.Fields{ - "sessionID": sessionID, - "namespace": ns, - }).Debug("Found namespace for session") - return ns - } - } - - return "" -} - -// CloseSession closes a terminal session -func (tm *Manager) CloseSession(terminalID string) error { - tm.lock.Lock() - defer tm.lock.Unlock() - - _, ok := tm.sessions[terminalID] - if !ok { - return fmt.Errorf("terminal session not found: %s", terminalID) - } - - // Remove session - delete(tm.sessions, terminalID) - tm.logger.WithField("terminalID", terminalID).Info("Terminal session closed") - - return nil -} - -func (tm *Manager) HandleTerminal(w http.ResponseWriter, r *http.Request, terminalID string) { - // Get session - session, err := tm.GetSession(terminalID) - if err != nil { - tm.logger.WithError(err).WithField("terminalID", terminalID).Error("Terminal session not found") - http.Error(w, "Terminal session not found", http.StatusNotFound) - return - } - - // Check if there's already an active connection - session.ConnectionMutex.Lock() - if session.ActiveConnection { - session.ConnectionMutex.Unlock() - tm.logger.WithField("terminalID", terminalID).Info("Existing connection found, allowing persistent reconnection") - // Allow reconnection - don't reject, just proceed - } else { - session.ActiveConnection = true - session.ConnectionMutex.Unlock() - } - - // Set up websocket - upgrader := websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return true // Allow all origins in development; restrict in production - }, - } - - // Upgrade connection to websocket - ws, err := upgrader.Upgrade(w, r, nil) - if err != nil { - tm.logger.WithError(err).Error("Failed to upgrade to WebSocket connection") - session.ConnectionMutex.Lock() - session.ActiveConnection = false - session.ConnectionMutex.Unlock() - return - } - defer func() { - ws.Close() - session.ConnectionMutex.Lock() - session.ActiveConnection = false - session.ConnectionMutex.Unlock() - }() - - tm.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "vmName": session.Target, - "namespace": session.Namespace, - }).Info("Handling persistent terminal connection") - - // Get or create persistent SSH connection - tm.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "sessionID": session.SessionID, - "namespace": session.Namespace, - "target": session.Target, - }).Info("Attempting to establish persistent SSH connection") - - sshConn, err := tm.GetOrCreatePersistentSSH(session.SessionID, session.Namespace, session.Target) - if err != nil { - tm.logger.WithError(err).WithFields(logrus.Fields{ - "terminalID": terminalID, - "sessionID": session.SessionID, - "namespace": session.Namespace, - "target": session.Target, - }).Error("Failed to get persistent SSH connection") - - // Send more informative error message to client - errorMsg := fmt.Sprintf("Failed to create terminal connection: %v\n\nThis could be due to:\n- VM not ready yet\n- Network connectivity issues\n- SSH service not running in VM", err) - ws.WriteMessage(websocket.TextMessage, []byte(errorMsg)) - return - } - - tm.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "connectionKey": sshConn.ID, - "activeConns": sshConn.ActiveConns, - }).Info("Successfully established persistent SSH connection") - - // Attach WebSocket to persistent SSH connection - err = tm.AttachToPersistentSSH(sshConn, ws) - if err != nil { - tm.logger.WithError(err).Error("Failed to attach to persistent SSH connection") - ws.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("Failed to attach to terminal: %v", err))) - return - } - - tm.logger.WithField("terminalID", terminalID).Info("Persistent terminal session ended") -} - -// ResizeTerminal resizes a terminal session -func (tm *Manager) ResizeTerminal(terminalID string, rows, cols uint16) error { - // This functionality will be handled through WebSocket messages - tm.logger.WithFields(logrus.Fields{ - "terminalID": terminalID, - "rows": rows, - "cols": cols, - }).Debug("Resize request received") - - return nil -} - -func (tm *Manager) cleanupExpiredSessions() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for { - <-ticker.C - - tm.lock.Lock() - expireTime := time.Now().Add(-tm.sessionExpiry) - - // Find expired sessions - expiredIDs := make([]string, 0) - for id, session := range tm.sessions { - if session.LastUsed.Before(expireTime) { - expiredIDs = append(expiredIDs, id) - } - } - - // Remove expired sessions - for _, id := range expiredIDs { - delete(tm.sessions, id) - tm.logger.WithField("terminalID", id).Info("Terminal session expired and removed") - } - - tm.lock.Unlock() - - // Clean up persistent SSH connections for expired sessions - tm.cleanupExpiredPersistentSSH() - } -} - -// cleanupExpiredPersistentSSH cleans up persistent SSH connections for expired sessions -func (tm *Manager) cleanupExpiredPersistentSSH() { - tm.persistentSSHLock.Lock() - defer tm.persistentSSHLock.Unlock() - - expireTime := time.Now().Add(-tm.sessionExpiry) - expiredConnections := make([]string, 0) - - // Find expired persistent SSH connections - for connectionKey, conn := range tm.persistentSSH { - // Check if connection hasn't been used recently - if conn.LastUsed.Before(expireTime) { - // Also check if there are no active WebSocket connections - conn.Mutex.Lock() - activeConns := conn.ActiveConns - conn.Mutex.Unlock() - - if activeConns == 0 { - expiredConnections = append(expiredConnections, connectionKey) - } else { - tm.logger.WithFields(logrus.Fields{ - "connectionKey": connectionKey, - "activeConns": activeConns, - }).Debug("Persistent SSH connection has active connections, keeping alive") - } - } - } - - // Clean up expired connections - for _, connectionKey := range expiredConnections { - if conn, exists := tm.persistentSSH[connectionKey]; exists { - tm.logger.WithField("connectionKey", connectionKey).Info("Cleaning up expired persistent SSH connection") - tm.cleanupDeadSSHConnection(conn) - delete(tm.persistentSSH, connectionKey) - } - } - - if len(expiredConnections) > 0 { - tm.logger.WithField("cleanedUp", len(expiredConnections)).Info("Cleaned up expired persistent SSH connections") - } -} - -// CleanupSessionSSH cleans up all persistent SSH connections for a session -func (tm *Manager) CleanupSessionSSH(sessionID string) { - tm.persistentSSHLock.Lock() - defer tm.persistentSSHLock.Unlock() - - connectionsToCleanup := make([]*PersistentSSHConnection, 0) - keysToDelete := make([]string, 0) - - // Find all connections for this session - for connectionKey, conn := range tm.persistentSSH { - if conn.SessionID == sessionID { - connectionsToCleanup = append(connectionsToCleanup, conn) - keysToDelete = append(keysToDelete, connectionKey) - } - } - - // Clean up the connections - for i, conn := range connectionsToCleanup { - connectionKey := keysToDelete[i] - tm.logger.WithFields(logrus.Fields{ - "sessionID": sessionID, - "connectionKey": connectionKey, - }).Info("Cleaning up persistent SSH connection for deleted session") - - tm.cleanupDeadSSHConnection(conn) - delete(tm.persistentSSH, connectionKey) - } - - if len(connectionsToCleanup) > 0 { - tm.logger.WithFields(logrus.Fields{ - "sessionID": sessionID, - "cleanedUp": len(connectionsToCleanup), - }).Info("Cleaned up persistent SSH connections for session") - } -} - -// GetOrCreatePersistentSSH gets existing or creates new persistent SSH connection -func (tm *Manager) GetOrCreatePersistentSSH(sessionID, namespace, target string) (*PersistentSSHConnection, error) { - // Support both control-plane and worker nodes - isValidTarget := target == "control-plane" || target == "worker-node" || - strings.HasPrefix(target, "cp-") || strings.HasPrefix(target, "wk-") - if !isValidTarget { - return nil, fmt.Errorf("unsupported target type: %s", target) - } - - connectionKey := fmt.Sprintf("%s-%s", sessionID, target) - - tm.persistentSSHLock.Lock() - defer tm.persistentSSHLock.Unlock() - - // Check if connection already exists - if conn, exists := tm.persistentSSH[connectionKey]; exists { - // Verify the SSH process is still alive - if tm.isSSHProcessAlive(conn) { - conn.LastUsed = time.Now() - tm.logger.WithFields(logrus.Fields{ - "connectionKey": connectionKey, - "sessionID": sessionID, - "target": target, - }).Info("Reusing existing persistent SSH connection") - return conn, nil - } else { - // Process died, clean it up and create new one - tm.logger.WithField("connectionKey", connectionKey).Warn("Persistent SSH process died, recreating") - tm.cleanupDeadSSHConnection(conn) - delete(tm.persistentSSH, connectionKey) - } - } - - // Create new persistent SSH connection - conn, err := tm.createPersistentSSHConnection(sessionID, namespace, target, connectionKey) - if err != nil { - return nil, fmt.Errorf("failed to create persistent SSH connection: %w", err) - } - - tm.persistentSSH[connectionKey] = conn - tm.logger.WithFields(logrus.Fields{ - "connectionKey": connectionKey, - "sessionID": sessionID, - "target": target, - }).Info("Created new persistent SSH connection") - - return conn, nil -} - -// getOrCreateTempKubeconfig creates a temporary kubeconfig file with the specified context set as current-context -// This is necessary because virtctl ssh does NOT respect the --context flag and always uses current-context -// The temp file is cached and reused for subsequent calls -func (tm *Manager) getOrCreateTempKubeconfig() (string, error) { - // Return cached path if already created - if tm.tempKubeconfigPath != "" { - return tm.tempKubeconfigPath, nil - } - - originalPath := os.Getenv("KUBECONFIG") - if originalPath == "" || tm.kubernetesContext == "" { - return originalPath, nil // No need for temp file if no context switching needed - } - - // Read the original kubeconfig - data, err := os.ReadFile(originalPath) - if err != nil { - return "", fmt.Errorf("failed to read kubeconfig: %w", err) - } - - // Create a temporary file - tmpFile, err := os.CreateTemp("/tmp", "kubeconfig-terminal-*.yaml") - if err != nil { - return "", fmt.Errorf("failed to create temp kubeconfig: %w", err) - } - defer tmpFile.Close() - - tmpPath := tmpFile.Name() - - // Write the content - if err := os.WriteFile(tmpPath, data, 0600); err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("failed to write temp kubeconfig: %w", err) - } - - // Use kubectl to set the current-context - cmd := exec.Command("kubectl", "config", "use-context", tm.kubernetesContext, "--kubeconfig="+tmpPath) - if err := cmd.Run(); err != nil { - os.Remove(tmpPath) - return "", fmt.Errorf("failed to set context in temp kubeconfig: %w", err) - } - - tm.logger.WithFields(logrus.Fields{ - "tempKubeconfig": tmpPath, - "context": tm.kubernetesContext, - }).Debug("Created temporary kubeconfig with context set for terminal manager") - - // Cache the path for future calls - tm.tempKubeconfigPath = tmpPath - - return tmpPath, nil -} - -// buildVirtctlSSHArgs builds standardized virtctl ssh arguments for terminal connections -// Note: virtctl ssh does NOT respect --context flag, so we use a temp kubeconfig with correct current-context -func (tm *Manager) buildVirtctlSSHArgs(namespace, vmName, username string) []string { - args := []string{} - - // Create temp kubeconfig with correct current-context (virtctl doesn't respect --context flag) - if kubeconfigPath := os.Getenv("KUBECONFIG"); kubeconfigPath != "" { - if tempPath, err := tm.getOrCreateTempKubeconfig(); err == nil && tempPath != "" { - args = append(args, "--kubeconfig="+tempPath) - } else { - // Fallback to original path if temp creation fails - tm.logger.WithError(err).Warn("Failed to create temp kubeconfig for terminal, using original") - args = append(args, "--kubeconfig="+kubeconfigPath) - if tm.kubernetesContext != "" { - args = append(args, "--context="+tm.kubernetesContext) - } - } - } - - // Then add the ssh subcommand and its arguments - args = append(args, - "ssh", - fmt.Sprintf("vmi/%s", vmName), - "--namespace="+namespace, - "--username="+username, - "--local-ssh-opts=-o StrictHostKeyChecking=no", - "--local-ssh-opts=-o UserKnownHostsFile=/dev/null", - "--local-ssh-opts=-o LogLevel=ERROR", - "--local-ssh-opts=-i /home/appuser/.ssh/id_ed25519", - ) - - return args -} - -// createPersistentSSHConnection creates a new persistent SSH connection -func (tm *Manager) createPersistentSSHConnection(sessionID, namespace, target, connectionKey string) (*PersistentSSHConnection, error) { - // Get the actual VM name for the target - vmName, err := tm.getVMNameForTarget(sessionID, namespace, target) - if err != nil { - return nil, fmt.Errorf("failed to get VM name: %w", err) - } - - // Validate inputs - if sessionID == "" { - return nil, fmt.Errorf("sessionID cannot be empty") - } - if namespace == "" { - return nil, fmt.Errorf("namespace cannot be empty") - } - if vmName == "" { - return nil, fmt.Errorf("vmName cannot be empty") - } - - // Test SSH connection before creating persistent connection - testCtx, cancelTest := context.WithTimeout(context.Background(), 60*time.Second) - defer cancelTest() - - tm.logger.WithFields(logrus.Fields{ - "connectionKey": connectionKey, - "vmName": vmName, - "namespace": namespace, - }).Info("Testing SSH connectivity before creating persistent connection") - - err = tm.testSSHConnection(testCtx, namespace, vmName) - if err != nil { - tm.logger.WithError(err).WithFields(logrus.Fields{ - "connectionKey": connectionKey, - "vmName": vmName, - "namespace": namespace, - }).Error("SSH connectivity test failed - VM may not be ready") - return nil, fmt.Errorf("SSH connectivity test failed for VM %s: %w", vmName, err) - } - - tm.logger.WithField("vmName", vmName).Info("SSH connectivity test passed, proceeding with persistent connection") - - // Create the virtctl ssh command with context support - args := tm.buildVirtctlSSHArgs(namespace, vmName, "suporte") - - tm.logger.WithFields(logrus.Fields{ - "command": "virtctl", - "args": args, - "connectionKey": connectionKey, - "vmName": vmName, - "namespace": namespace, - }).Debug("Creating persistent SSH connection with context-aware arguments") - - // Create the command - cmd := exec.Command("virtctl", args...) - - // IMPORTANT: virtctl ignores --kubeconfig flag, so we override KUBECONFIG env var - var envWithCustomKubeconfig []string - tempKubeconfigPath, _ := tm.getOrCreateTempKubeconfig() - for _, env := range os.Environ() { - if !strings.HasPrefix(env, "KUBECONFIG=") { - envWithCustomKubeconfig = append(envWithCustomKubeconfig, env) - } - } - if tempKubeconfigPath != "" { - envWithCustomKubeconfig = append(envWithCustomKubeconfig, "KUBECONFIG="+tempKubeconfigPath) - } - cmd.Env = envWithCustomKubeconfig - - // Rest of the function remains the same... - // Create a pty for the command - ptmx, err := pty.Start(cmd) - if err != nil { - tm.logger.WithError(err).WithFields(logrus.Fields{ - "connectionKey": connectionKey, - "vmName": vmName, - "namespace": namespace, - "command": cmd.String(), - }).Error("Failed to start pty for persistent SSH connection") - - // Check for common errors - if strings.Contains(err.Error(), "executable file not found") { - return nil, fmt.Errorf("virtctl command not found in PATH - ensure virtctl is installed") - } - - return nil, fmt.Errorf("failed to start pty for persistent SSH to VM %s: %w", vmName, err) - } - - // Set up initial terminal size - if err := pty.Setsize(ptmx, &pty.Winsize{ - Rows: 24, - Cols: 80, - X: 0, - Y: 0, - }); err != nil { - tm.logger.WithError(err).Warn("Failed to set initial terminal size for persistent SSH") - } - - conn := &PersistentSSHConnection{ - ID: connectionKey, - SessionID: sessionID, - Target: target, - Namespace: namespace, - Command: cmd, - PTY: ptmx, - Created: time.Now(), - LastUsed: time.Now(), - ActiveConns: 0, - } - - return conn, nil -} - -// getVMNameForTarget gets the actual VM name for a target -func (tm *Manager) getVMNameForTarget(sessionID, namespace, target string) (string, error) { - // If target is already a VM name (starts with cp- or wk-), use it directly - if strings.HasPrefix(target, "cp-") || strings.HasPrefix(target, "wk-") { - return target, nil - } - - // Handle generic target names - var vmPrefix string - switch target { - case "control-plane": - vmPrefix = "cp-" - case "worker-node": - vmPrefix = "wk-" - default: - return "", fmt.Errorf("unknown target type: %s", target) - } - - // Try cluster pool patterns first: cp-cluster1, cp-cluster2, cp-cluster3 - clusterPatterns := []string{ - vmPrefix + "cluster1", - vmPrefix + "cluster2", - vmPrefix + "cluster3", - } - - for _, vmName := range clusterPatterns { - // Check if VM exists in this namespace - _, err := tm.kubevirtClient.VirtClient().VirtualMachine(namespace).Get(context.Background(), vmName, metav1.GetOptions{}) - if err == nil { - return vmName, nil - } - } - - // Fallback: try session-based naming - vmName := fmt.Sprintf("%s%s", vmPrefix, sessionID) - return vmName, nil -} - -// isSSHProcessAlive checks if the SSH process is still running -func (tm *Manager) isSSHProcessAlive(conn *PersistentSSHConnection) bool { - if conn.Command == nil || conn.Command.Process == nil { - return false - } - - // Check if process is still running - err := conn.Command.Process.Signal(os.Signal(syscall.Signal(0))) - return err == nil -} - -// AttachToPersistentSSH attaches a WebSocket to existing SSH connection -func (tm *Manager) AttachToPersistentSSH(sshConn *PersistentSSHConnection, ws *websocket.Conn) error { - sshConn.Mutex.Lock() - sshConn.ActiveConns++ - sshConn.LastUsed = time.Now() - activeConns := sshConn.ActiveConns - sshConn.Mutex.Unlock() - - tm.logger.WithFields(logrus.Fields{ - "connectionID": sshConn.ID, - "activeConns": activeConns, - }).Info("WebSocket attached to persistent SSH") - - // Set up communication between WebSocket and SSH - return tm.bridgeWebSocketToSSH(sshConn, ws) -} - -// DetachFromPersistentSSH detaches WebSocket from SSH connection -func (tm *Manager) DetachFromPersistentSSH(sshConn *PersistentSSHConnection) { - sshConn.Mutex.Lock() - if sshConn.ActiveConns > 0 { - sshConn.ActiveConns-- - } - activeConns := sshConn.ActiveConns - sshConn.Mutex.Unlock() - - tm.logger.WithFields(logrus.Fields{ - "connectionID": sshConn.ID, - "activeConns": activeConns, - }).Info("WebSocket detached from persistent SSH") -} - -// CleanupPersistentSSH closes SSH connection when session ends -func (tm *Manager) CleanupPersistentSSH(sessionID, target string) error { - connectionKey := fmt.Sprintf("%s-%s", sessionID, target) - - tm.persistentSSHLock.Lock() - defer tm.persistentSSHLock.Unlock() - - conn, exists := tm.persistentSSH[connectionKey] - if !exists { - return nil // Already cleaned up - } - - tm.logger.WithField("connectionKey", connectionKey).Info("Cleaning up persistent SSH connection") - - tm.cleanupDeadSSHConnection(conn) - delete(tm.persistentSSH, connectionKey) - - return nil -} - -// cleanupDeadSSHConnection cleans up resources for a dead SSH connection -func (tm *Manager) cleanupDeadSSHConnection(conn *PersistentSSHConnection) { - if conn.PTY != nil { - conn.PTY.Close() - } - - if conn.Command != nil && conn.Command.Process != nil { - conn.Command.Process.Kill() - conn.Command.Wait() // Wait for process to finish - } -} - -// bridgeWebSocketToSSH handles communication between WebSocket and SSH -func (tm *Manager) bridgeWebSocketToSSH(sshConn *PersistentSSHConnection, ws *websocket.Conn) error { - // Create a channel to signal when the connection is done - done := make(chan struct{}) - defer close(done) - - // Ensure we detach when done - defer tm.DetachFromPersistentSSH(sshConn) - - // Set up a goroutine to handle reading from the SSH pty - go func() { - buffer := make([]byte, 4096) - for { - select { - case <-done: - return - default: - n, err := sshConn.PTY.Read(buffer) - if err != nil { - if err != io.EOF { - tm.logger.WithError(err).Debug("Error reading from persistent SSH pty") - } - return - } - - if n > 0 { - if err := ws.WriteMessage(websocket.BinaryMessage, buffer[:n]); err != nil { - tm.logger.WithError(err).Warn("Error writing to WebSocket from persistent SSH") - return - } - } - } - } - }() - - // Handle reading from the WebSocket - for { - messageType, p, err := ws.ReadMessage() - if err != nil { - tm.logger.WithError(err).Debug("WebSocket read error in persistent SSH bridge") - return nil - } - - // Handle terminal resize messages - if messageType == websocket.BinaryMessage && len(p) >= 5 && p[0] == 1 { - width := uint16(p[1])<<8 | uint16(p[2]) - height := uint16(p[3])<<8 | uint16(p[4]) - - tm.logger.WithFields(logrus.Fields{ - "width": width, - "height": height, - }).Debug("Terminal resize request for persistent SSH") - - // Resize the pty - if err := pty.Setsize(sshConn.PTY, &pty.Winsize{ - Rows: height, - Cols: width, - X: 0, - Y: 0, - }); err != nil { - tm.logger.WithError(err).Warn("Failed to resize persistent SSH terminal") - } - continue - } - - // Write data to pty - if _, err := sshConn.PTY.Write(p); err != nil { - tm.logger.WithError(err).Warn("Error writing to persistent SSH pty") - return nil - } - } -} - -// testSSHConnection tests if SSH connection to a VM is working -func (tm *Manager) testSSHConnection(ctx context.Context, namespace, vmName string) error { - tm.logger.WithFields(logrus.Fields{ - "namespace": namespace, - "vmName": vmName, - }).Debug("Testing SSH connection to VM") - - // Create a simple test command with context support - args := tm.buildVirtctlSSHArgs(namespace, vmName, "suporte") - args = append(args, "--command=echo 'SSH connection test successful'") - - // Create context with timeout for the test - testCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - cmd := exec.CommandContext(testCtx, "virtctl", args...) - - // IMPORTANT: virtctl ignores --kubeconfig flag, so we override KUBECONFIG env var - var envWithCustomKubeconfig []string - tempKubeconfigPath, _ := tm.getOrCreateTempKubeconfig() - for _, env := range os.Environ() { - if !strings.HasPrefix(env, "KUBECONFIG=") { - envWithCustomKubeconfig = append(envWithCustomKubeconfig, env) - } - } - if tempKubeconfigPath != "" { - envWithCustomKubeconfig = append(envWithCustomKubeconfig, "KUBECONFIG="+tempKubeconfigPath) - } - cmd.Env = envWithCustomKubeconfig - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - if err != nil { - tm.logger.WithError(err).WithFields(logrus.Fields{ - "namespace": namespace, - "vmName": vmName, - "stderr": stderr.String(), - "stdout": stdout.String(), - }).Warn("SSH connection test failed") - return fmt.Errorf("SSH connection test failed for VM %s: %w", vmName, err) - } - - output := strings.TrimSpace(stdout.String()) - if !strings.Contains(output, "SSH connection test successful") { - return fmt.Errorf("SSH connection test returned unexpected output: %s", output) - } - - tm.logger.WithField("vmName", vmName).Debug("SSH connection test successful") - return nil -}