package services import ( "context" "fmt" "os" "os/exec" "strings" "sync" "time" "git.linuxforward.com/byop/byop-engine/clients" "git.linuxforward.com/byop/byop-engine/cloud" "git.linuxforward.com/byop/byop-engine/config" "git.linuxforward.com/byop/byop-engine/dbstore" "git.linuxforward.com/byop/byop-engine/models" "github.com/sirupsen/logrus" ) // RemotePreviewService handles remote VPS preview deployments type RemotePreviewService struct { common *PreviewCommon entry *logrus.Entry ovhProvider cloud.Provider config *config.Config } // NewRemotePreviewService creates a new RemotePreviewService func NewRemotePreviewService(store *dbstore.SQLiteStore, ovhProvider cloud.Provider, cfg *config.Config, registryClient clients.RegistryClient, registryURL, registryUser, registryPass string) *RemotePreviewService { return &RemotePreviewService{ common: NewPreviewCommon(store, registryClient, registryURL, registryUser, registryPass), entry: logrus.WithField("service", "RemotePreviewService"), ovhProvider: ovhProvider, config: cfg, } } // Close cleans up resources func (rps *RemotePreviewService) Close(ctx context.Context) { rps.entry.Info("Cleaning up remote preview service...") rps.cleanupAllPreviewVPS(ctx) rps.common.Close() } // cleanupAllPreviewVPS cleans up all preview VPS instances on server shutdown func (rps *RemotePreviewService) cleanupAllPreviewVPS(ctx context.Context) { rps.entry.Info("Starting cleanup of all preview VPS instances") // Get all VPS instances instances, err := rps.ovhProvider.ListInstances(ctx) if err != nil { rps.entry.WithError(err).Error("Failed to list VPS instances during cleanup") return } var wg sync.WaitGroup cleanupSemaphore := make(chan struct{}, 3) // Limit concurrent cleanup operations for _, instance := range instances { // Only clean up preview VPS instances if strings.Contains(instance.Name, "preview.byop.fr") { wg.Add(1) go func(inst cloud.Instance) { defer wg.Done() cleanupSemaphore <- struct{}{} // Acquire semaphore defer func() { <-cleanupSemaphore }() // Release semaphore taskCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() rps.entry.WithField("vps_id", inst.ID).WithField("vps_name", inst.Name).Info("Cleaning up preview VPS") // Stop all containers and clean up Docker images if inst.IPAddress != "" { rps.cleanupVPSResources(taskCtx, inst.IPAddress, inst.ID) } // Reset/destroy the VPS instance if err := rps.ovhProvider.ResetInstance(taskCtx, inst.ID); err != nil { rps.entry.WithField("vps_id", inst.ID).WithError(err).Error("Failed to reset preview VPS") } else { rps.entry.WithField("vps_id", inst.ID).Info("Successfully reset preview VPS") } }(instance) } } // Wait for all cleanup operations to complete with a timeout done := make(chan struct{}) go func() { wg.Wait() close(done) }() select { case <-done: rps.entry.Info("Successfully completed cleanup of all preview VPS instances") case <-time.After(60 * time.Second): rps.entry.Warn("Timeout waiting for preview VPS cleanup to complete") } } // cleanupVPSResources cleans up all Docker resources on a VPS func (rps *RemotePreviewService) cleanupVPSResources(ctx context.Context, ipAddress, vpsID string) { rps.entry.WithField("vps_id", vpsID).WithField("ip_address", ipAddress).Info("Cleaning up Docker resources on VPS") // Stop all preview containers stopAllCmd := "docker ps -q --filter 'label=byop.preview=true' | xargs -r docker stop" if err := rps.common.executeSSHCommand(ctx, ipAddress, stopAllCmd); err != nil { rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to stop preview containers") } // Remove all preview containers removeAllCmd := "docker ps -aq --filter 'label=byop.preview=true' | xargs -r docker rm -f" if err := rps.common.executeSSHCommand(ctx, ipAddress, removeAllCmd); err != nil { rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to remove preview containers") } // Remove all BYOP preview images removeImagesCmd := "docker images --filter 'reference=byop-preview-*' -q | xargs -r docker rmi -f" if err := rps.common.executeSSHCommand(ctx, ipAddress, removeImagesCmd); err != nil { rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to remove preview images") } // Clean up all project directories cleanupDirsCmd := "rm -rf /home/debian/preview-*" if err := rps.common.executeSSHCommand(ctx, ipAddress, cleanupDirsCmd); err != nil { rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to clean up project directories") } // Clean up temporary tar files cleanupTarCmd := "rm -f /tmp/byop-preview-*.tar" if err := rps.common.executeSSHCommand(ctx, ipAddress, cleanupTarCmd); err != nil { rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to clean up tar files") } // Final cleanup: remove dangling images and volumes pruneCmd := "docker system prune -af --volumes" if err := rps.common.executeSSHCommand(ctx, ipAddress, pruneCmd); err != nil { rps.entry.WithField("vps_id", vpsID).WithError(err).Warn("Failed to prune Docker system") } rps.entry.WithField("vps_id", vpsID).Info("Completed Docker resource cleanup on VPS") } // CreatePreview creates a remote preview environment on a VPS func (rps *RemotePreviewService) CreatePreview(ctx context.Context, appId int) (*models.Preview, error) { // Get app details app, err := rps.common.GetStore().GetAppByID(ctx, appId) if err != nil { return nil, fmt.Errorf("failed to get app by ID %d: %v", appId, err) } // Create preview record preview := models.Preview{ AppID: app.ID, Status: "building", ExpiresAt: time.Now().Add(24 * time.Hour).Format(time.RFC3339), // 24h expiry } previewID, err := rps.common.GetStore().CreatePreview(ctx, &preview) if err != nil { return nil, fmt.Errorf("failed to create preview record: %v", err) } preview.ID = previewID // Start async build and deploy to VPS go rps.buildAndDeployPreview(ctx, preview, app) return &preview, nil } func (rps *RemotePreviewService) buildAndDeployPreview(ctx context.Context, preview models.Preview, app *models.App) { rps.entry.WithField("preview_id", preview.ID).Info("Starting remote preview build and deployment") // Get all components for the app rps.entry.WithField("preview_id", preview.ID).Info("Getting app components") components, err := rps.common.GetAppComponents(ctx, app) if err != nil { rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to get app components: %v", err) rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to get app components: %v", err)) rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview creation failed: %v", err)) return } rps.entry.WithField("preview_id", preview.ID).WithField("component_count", len(components)).Info("Successfully retrieved app components") // Step 1: Build Docker images locally rps.entry.WithField("preview_id", preview.ID).Info("Starting Docker image build phase") imageNames, buildLogs, err := rps.common.BuildComponentImages(ctx, components) if err != nil { rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to build component images: %v", err) rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to build images: %v", err)) rps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs) rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview build failed: %v", err)) return } rps.entry.WithField("preview_id", preview.ID).WithField("image_count", len(imageNames)).Info("Docker image build phase completed successfully") rps.common.UpdatePreviewBuildLogs(ctx, preview.ID, buildLogs) // Step 2: Provision preview VPS rps.entry.WithField("preview_id", preview.ID).Info("Starting VPS provisioning phase") rps.common.UpdatePreviewStatus(ctx, preview.ID, "deploying", "") vps, err := rps.findAvailablePreviewVPS(ctx) if err != nil { rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to find available VPS: %v", err) rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to find available VPS: %v", err)) rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Preview VPS provisioning failed: %v", err)) return } vpsID := vps.ID ipAddress := vps.IPAddress rps.entry.WithField("preview_id", preview.ID).WithField("vps_id", vpsID).WithField("ip_address", ipAddress).Info("VPS provisioning completed") // Generate preview URL with UUID previewUUID := rps.common.GeneratePreviewID() previewTLD := rps.config.PreviewTLD previewURL := fmt.Sprintf("https://%s.%s", previewUUID, previewTLD) rps.entry.WithField("preview_id", preview.ID).WithField("preview_uuid", previewUUID).WithField("preview_url", previewURL).Info("Generated remote preview URL") // Update preview with VPS info if err := rps.common.GetStore().UpdatePreviewVPS(ctx, preview.ID, vpsID, ipAddress, previewURL); err != nil { rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to update preview VPS info: %v", err) } // Step 3: Deploy to VPS rps.entry.WithField("preview_id", preview.ID).Info("Starting remote deployment phase") deployLogs, err := rps.deployToVPS(ctx, ipAddress, imageNames, app, preview.ID, previewUUID) if err != nil { rps.entry.WithField("preview_id", preview.ID).Errorf("Failed to deploy to VPS: %v", err) rps.common.UpdatePreviewStatus(ctx, preview.ID, "failed", fmt.Sprintf("Failed to deploy to VPS: %v", err)) rps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs) rps.common.GetStore().UpdateAppStatus(ctx, app.ID, "failed", fmt.Sprintf("Remote deployment failed: %v", err)) return } rps.entry.WithField("preview_id", preview.ID).Info("Remote deployment completed successfully") rps.common.UpdatePreviewDeployLogs(ctx, preview.ID, deployLogs) rps.common.UpdatePreviewStatus(ctx, preview.ID, "running", "") // Update app status to ready with preview info rps.common.GetStore().UpdateAppPreview(ctx, app.ID, preview.ID, previewURL) rps.entry.WithField("preview_id", preview.ID).WithField("vps_id", vpsID).WithField("preview_url", previewURL).Info("Remote preview deployment completed successfully") } // findAvailablePreviewVPS finds an existing VPS that can accommodate more previews func (rps *RemotePreviewService) findAvailablePreviewVPS(ctx context.Context) (*cloud.Instance, error) { // Get all VPS instances instances, err := rps.ovhProvider.ListInstances(ctx) if err != nil { return nil, fmt.Errorf("failed to list VPS instances: %v", err) } // Count previews per VPS by checking all preview records vpsUsage := make(map[string]int) // Get all preview instances from database and count usage per VPS for _, instance := range instances { if strings.Contains(instance.Name, "preview.byop.fr") { vpsUsage[instance.ID] = 0 // Initialize to 0, will be updated below } } // Simple approach: look for any preview VPS that exists and return it maxPreviewsPerVPS := 5 // Check if any existing VPS has capacity for _, instance := range instances { if strings.Contains(instance.Name, "preview.byop.fr") { currentCount := vpsUsage[instance.ID] if currentCount < maxPreviewsPerVPS { rps.entry.WithField("vps_id", instance.ID).WithField("current_previews", currentCount).Info("Found VPS with available capacity") return &instance, nil } } } return nil, fmt.Errorf("no available VPS with capacity found") } func (rps *RemotePreviewService) deployToVPS(ctx context.Context, ipAddress string, imageNames []string, app *models.App, previewID int, previewUUID string) (string, error) { var logs strings.Builder rps.entry.WithField("ip_address", ipAddress).WithField("app_name", app.Name).WithField("preview_id", previewID).Info("Starting deployment to VPS") // Generate docker-compose.yml for the preview rps.entry.Info("Generating docker-compose.yml for preview") composeContent, err := rps.generatePreviewDockerCompose(imageNames, app, previewID, previewUUID) if err != nil { rps.entry.WithError(err).Error("Failed to generate docker-compose.yml") return logs.String(), err } logs.WriteString("Generated docker-compose.yml\n") // Save images to tar files and transfer to VPS for _, imageName := range imageNames { tarFile := fmt.Sprintf("/tmp/%s.tar", strings.ReplaceAll(imageName, ":", "_")) // Save Docker image rps.entry.WithField("image_name", imageName).WithField("tar_file", tarFile).Info("Saving Docker image to tar file") cmd := exec.CommandContext(ctx, "docker", "save", "-o", tarFile, imageName) if err := cmd.Run(); err != nil { rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to save image to tar") logs.WriteString(fmt.Sprintf("Failed to save image %s: %v\n", imageName, err)) return logs.String(), err } rps.entry.WithField("image_name", imageName).Info("Successfully saved image to tar") // Transfer to VPS rps.entry.WithField("image_name", imageName).WithField("ip_address", ipAddress).Info("Transferring image to VPS") if err := rps.transferFile(ctx, tarFile, fmt.Sprintf("%s:/tmp/", ipAddress)); err != nil { rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to transfer image to VPS") logs.WriteString(fmt.Sprintf("Failed to transfer image %s: %v\n", imageName, err)) return logs.String(), err } rps.entry.WithField("image_name", imageName).Info("Successfully transferred image to VPS") // Load image on VPS loadCmd := fmt.Sprintf("docker load -i /tmp/%s.tar", strings.ReplaceAll(imageName, ":", "_")) rps.entry.WithField("image_name", imageName).WithField("command", loadCmd).Info("Loading image on VPS") if err := rps.common.executeSSHCommand(ctx, ipAddress, loadCmd); err != nil { rps.entry.WithField("image_name", imageName).WithError(err).Error("Failed to load image on VPS") logs.WriteString(fmt.Sprintf("Failed to load image %s on VPS: %v\n", imageName, err)) return logs.String(), err } rps.entry.WithField("image_name", imageName).Info("Successfully loaded image on VPS") // Clean up local tar file os.Remove(tarFile) rps.entry.WithField("tar_file", tarFile).Info("Cleaned up local tar file") } // Create project-specific directory and transfer docker-compose.yml projectName := fmt.Sprintf("preview-%d", previewID) projectDir := fmt.Sprintf("/home/debian/%s", projectName) // Create project directory on VPS rps.entry.WithField("project_dir", projectDir).Info("Creating project directory on VPS") if err := rps.common.executeSSHCommand(ctx, ipAddress, fmt.Sprintf("mkdir -p %s", projectDir)); err != nil { rps.entry.WithError(err).Error("Failed to create project directory on VPS") return logs.String(), err } composeFile := "/tmp/docker-compose-preview.yml" rps.entry.WithField("compose_file", composeFile).Info("Writing docker-compose.yml to temporary file") if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil { rps.entry.WithError(err).Error("Failed to write docker-compose.yml") return logs.String(), err } rps.entry.WithField("ip_address", ipAddress).WithField("project_dir", projectDir).Info("Transferring docker-compose.yml to VPS") if err := rps.transferFile(ctx, composeFile, fmt.Sprintf("%s:%s/docker-compose.yml", ipAddress, projectDir)); err != nil { rps.entry.WithError(err).Error("Failed to transfer docker-compose.yml to VPS") logs.WriteString(fmt.Sprintf("Failed to transfer docker-compose.yml: %v\n", err)) return logs.String(), err } rps.entry.Info("Successfully transferred docker-compose.yml to VPS") // Start services on VPS with project-specific naming rps.entry.WithField("ip_address", ipAddress).WithField("project_name", projectName).Info("Starting services on VPS with docker-compose") startCmd := fmt.Sprintf("cd %s && docker compose -p %s up -d", projectDir, projectName) if err := rps.common.executeSSHCommand(ctx, ipAddress, startCmd); err != nil { rps.entry.WithError(err).Error("Failed to start services on VPS") logs.WriteString(fmt.Sprintf("Failed to start services: %v\n", err)) return logs.String(), err } rps.entry.Info("Successfully started services on VPS") // Validate DNS and certificate setup for debugging previewDomain := fmt.Sprintf("%s.%s", previewUUID, rps.config.PreviewTLD) rps.validateDNSAndCertificate(ctx, previewDomain, ipAddress) rps.entry.WithField("ip_address", ipAddress).WithField("project_name", projectName).Info("Remote preview deployment completed successfully") logs.WriteString("Remote preview deployment completed successfully\n") return logs.String(), nil } func (rps *RemotePreviewService) generatePreviewDockerCompose(imageNames []string, app *models.App, previewID int, previewUUID string) (string, error) { rps.entry.WithField("app_id", app.ID).WithField("preview_id", previewID).WithField("image_count", len(imageNames)).Info("Generating docker-compose content for remote deployment") compose := "services:\n" for i, imageName := range imageNames { serviceName := fmt.Sprintf("service-%d", i) compose += fmt.Sprintf(" %s:\n", serviceName) compose += fmt.Sprintf(" image: %s\n", imageName) compose += " restart: unless-stopped\n" compose += " environment:\n" compose += " - NODE_ENV=preview\n" compose += fmt.Sprintf(" - APP_NAME=%s\n", app.Name) // Add BYOP preview labels for tracking compose += " labels:\n" compose += " - \"byop.preview=true\"\n" compose += fmt.Sprintf(" - \"byop.app.id=%d\"\n", app.ID) compose += fmt.Sprintf(" - \"byop.app.name=%s\"\n", app.Name) compose += fmt.Sprintf(" - \"byop.preview.id=%d\"\n", previewID) // Add Traefik labels for the first service (main entry point) if i == 0 { previewDomain := fmt.Sprintf("%s.%s", previewUUID, rps.config.PreviewTLD) routerName := fmt.Sprintf("preview-%d-%s", previewID, previewUUID) compose += " - \"traefik.enable=true\"\n" compose += fmt.Sprintf(" - \"traefik.http.routers.%s.rule=Host(`%s`)\"\n", routerName, previewDomain) compose += fmt.Sprintf(" - \"traefik.http.routers.%s.entrypoints=websecure\"\n", routerName) compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls=true\"\n", routerName) compose += fmt.Sprintf(" - \"traefik.http.routers.%s.tls.certresolver=tlsresolver\"\n", routerName) compose += " - \"traefik.docker.network=traefik\"\n" } compose += " networks:\n" compose += " - traefik\n" compose += "\n" } // Add networks section compose += "networks:\n" compose += " traefik:\n" compose += " external: true\n" return compose, nil } // DeletePreview deletes a remote preview func (rps *RemotePreviewService) DeletePreview(ctx context.Context, appID int) error { // Get the preview to ensure it exists preview, err := rps.common.GetStore().GetPreviewByAppID(ctx, appID) if err != nil { return fmt.Errorf("failed to get preview by app ID %d: %v", appID, err) } if preview == nil { return fmt.Errorf("preview with app ID %d not found", appID) } rps.entry.WithField("preview_id", preview.ID).Info("Deleting remote preview") // Stop and remove containers on VPS using project-specific naming if preview.IPAddress != "" && preview.IPAddress != "127.0.0.1" { projectName := fmt.Sprintf("preview-%d", preview.ID) projectDir := fmt.Sprintf("/home/debian/%s", projectName) // Stop containers with project-specific naming stopCmd := fmt.Sprintf("cd %s && docker compose -p %s down --remove-orphans", projectDir, projectName) rps.common.executeSSHCommand(ctx, preview.IPAddress, stopCmd) // Remove project directory rmCmd := fmt.Sprintf("rm -rf %s", projectDir) rps.common.executeSSHCommand(ctx, preview.IPAddress, rmCmd) // Cleanup Docker images if err := rps.common.CleanupPreviewImagesForApp(ctx, appID, true, preview.IPAddress); err != nil { rps.entry.WithField("preview_id", preview.ID).WithError(err).Warn("Failed to clean up Docker images") } } // Don't remove the VPS instance - it might be hosting other previews // Only clean up if this is the last preview on the VPS if preview.VPSID != "" && !strings.Contains(preview.VPSID, "byop.local") { // Check if there are other active previews on this VPS otherPreviews, err := rps.getActivePreviewsOnVPS(ctx, preview.VPSID) if err != nil { rps.entry.WithField("vps_id", preview.VPSID).Warnf("Failed to check other previews on VPS: %v", err) } else if len(otherPreviews) <= 1 { // Only this preview remains if err := rps.ovhProvider.ResetInstance(ctx, preview.VPSID); err != nil { rps.entry.WithField("vps_id", preview.VPSID).Errorf("Failed to reset VPS instance: %v", err) } } } // Delete the preview record from the database if err := rps.common.GetStore().DeletePreview(ctx, preview.ID); err != nil { return fmt.Errorf("failed to delete preview from database: %v", err) } rps.entry.WithField("preview_id", preview.ID).Info("Successfully deleted remote preview") return nil } // StopPreview stops a remote preview func (rps *RemotePreviewService) StopPreview(ctx context.Context, previewID int) error { preview, err := rps.common.GetStore().GetPreviewByID(ctx, previewID) if err != nil { return err } if preview == nil { return fmt.Errorf("preview not found") } // Stop containers on VPS projectName := fmt.Sprintf("preview-%d", previewID) projectDir := fmt.Sprintf("/home/debian/%s", projectName) stopCmd := fmt.Sprintf("cd %s && docker compose -p %s down --remove-orphans", projectDir, projectName) rps.common.executeSSHCommand(ctx, preview.IPAddress, stopCmd) // Clean up Docker images before destroying VPS if err := rps.common.CleanupPreviewImagesForApp(ctx, preview.AppID, true, preview.IPAddress); err != nil { rps.entry.WithField("preview_id", previewID).WithError(err).Warn("Failed to clean up Docker images") } // Destroy the VPS if err := rps.ovhProvider.ResetInstance(ctx, preview.VPSID); err != nil { rps.entry.WithField("vps_id", preview.VPSID).Errorf("Failed to destroy preview VPS: %v", err) } return rps.common.GetStore().UpdatePreviewStatus(ctx, previewID, "stopped", "") } // getActivePreviewsOnVPS returns all active previews running on a specific VPS func (rps *RemotePreviewService) getActivePreviewsOnVPS(ctx context.Context, vpsID string) ([]*models.Preview, error) { apps, err := rps.common.GetStore().GetAllApps(ctx) if err != nil { return nil, fmt.Errorf("failed to get apps: %v", err) } var activePreviews []*models.Preview for _, app := range apps { previews, err := rps.common.GetStore().GetPreviewsByAppID(ctx, app.ID) if err != nil { continue } for _, preview := range previews { if preview.VPSID == vpsID && preview.Status == "running" { activePreviews = append(activePreviews, preview) } } } return activePreviews, nil } // Helper methods - using common SSH command execution func (rps *RemotePreviewService) transferFile(ctx context.Context, localPath, remotePath string) error { hostAndPath := strings.SplitN(remotePath, ":", 2) if len(hostAndPath) != 2 { return fmt.Errorf("invalid SCP destination format: %s", remotePath) } remoteHost := hostAndPath[0] remotePath = hostAndPath[1] cmd := exec.CommandContext(ctx, "scp", "-o", "StrictHostKeyChecking=no", localPath, fmt.Sprintf("debian@%s:%s", remoteHost, remotePath)) output, err := cmd.CombinedOutput() if err != nil { rps.entry.WithField("local_path", localPath).WithField("remote_path", remotePath).WithField("output", string(output)).WithError(err).Error("SCP transfer failed") return err } rps.entry.WithField("local_path", localPath).WithField("remote_path", remotePath).Info("Successfully transferred file") return nil } // validateDNSAndCertificate validates DNS resolution and Traefik certificate for debugging func (rps *RemotePreviewService) validateDNSAndCertificate(ctx context.Context, previewDomain, ipAddress string) { rps.entry.WithField("domain", previewDomain).WithField("ip_address", ipAddress).Info("Validating DNS and certificate setup") // Check DNS resolution from the VPS dnsCmd := fmt.Sprintf("nslookup %s", previewDomain) if err := rps.common.executeSSHCommand(ctx, ipAddress, dnsCmd); err != nil { rps.entry.WithField("domain", previewDomain).WithError(err).Warn("DNS resolution failed from VPS") } // Check if Traefik can see the service traefikCmd := "docker ps --filter 'label=traefik.enable=true' --format 'table {{.Names}}\t{{.Labels}}'" if err := rps.common.executeSSHCommand(ctx, ipAddress, traefikCmd); err != nil { rps.entry.WithError(err).Warn("Failed to check Traefik-enabled containers") } // Check Traefik logs for certificate issues logsCmd := "docker logs traefik --tail 20" if err := rps.common.executeSSHCommand(ctx, ipAddress, logsCmd); err != nil { rps.entry.WithError(err).Warn("Failed to get Traefik logs") } // Wait a bit for DNS propagation and certificate generation rps.entry.WithField("domain", previewDomain).Info("Waiting 30 seconds for DNS propagation and certificate generation") time.Sleep(30 * time.Second) }