Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 124 additions & 9 deletions api/internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -499,14 +499,74 @@ func (h *Handler) CreateSession(c *gin.Context) {
// Without a valid template, the session cannot be created
template, err := h.k8sClient.GetTemplate(ctx, h.namespace, templateName)
if err != nil {
// Provide a more helpful error message
errorMsg := fmt.Sprintf("Template not found: %s.", templateName)
// Template is missing - trigger reinstallation if applicationId was provided
if req.ApplicationId != "" {
errorMsg += " The application may still be installing or the Kubernetes controller may not be running."
} else {
errorMsg += " Please ensure the application is properly installed."
// Query application details for reinstall
var (
installID string
catalogTemplateID int
displayName string
description string
category string
iconURL string
manifest string
installedBy string
)
reinstallErr := h.db.DB().QueryRowContext(ctx, `
SELECT
ia.id,
ia.catalog_template_id,
ia.display_name,
COALESCE(ct.description, ''),
COALESCE(ct.category, ''),
COALESCE(ct.icon_url, ''),
COALESCE(ct.manifest, '{}'),
ia.created_by
FROM installed_applications ia
LEFT JOIN catalog_templates ct ON ia.catalog_template_id = ct.id
WHERE ia.id = $1
`, req.ApplicationId).Scan(
&installID, &catalogTemplateID, &displayName, &description,
&category, &iconURL, &manifest, &installedBy,
)

if reinstallErr == nil {
// Publish AppInstallEvent to trigger controller to create template
if err := h.publisher.PublishAppInstall(ctx, &events.AppInstallEvent{
InstallID: installID,
CatalogTemplateID: catalogTemplateID,
TemplateName: templateName,
DisplayName: displayName,
Description: description,
Category: category,
IconURL: iconURL,
Manifest: manifest,
InstalledBy: installedBy,
Platform: h.platform,
}); err != nil {
log.Printf("Failed to publish app reinstall event for %s: %v", templateName, err)
} else {
log.Printf("Triggered reinstall for missing template %s (app: %s)", templateName, installID)
// Update status to creating
h.db.DB().ExecContext(ctx, `
UPDATE installed_applications
SET install_status = 'creating', install_message = 'Reinstalling missing template', updated_at = NOW()
WHERE id = $1
`, installID)
}
}

c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Template reinstalling",
"message": fmt.Sprintf("The template for '%s' was missing and is being reinstalled. Please try again in a few seconds.", displayName),
})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})

// No applicationId provided - provide generic error
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Template not found: %s. Please ensure the application is properly installed.", templateName),
})
return
}

Expand Down Expand Up @@ -645,6 +705,25 @@ func (h *Handler) CreateSession(c *gin.Context) {
return
}

// Cache session in database so status updates can be applied
// This is best-effort - failure doesn't block session creation
dbSession := &db.Session{
ID: sessionName,
UserID: req.User,
TemplateName: templateName,
State: "pending",
Namespace: h.namespace,
Platform: h.platform,
Memory: memory,
CPU: cpu,
PersistentHome: session.PersistentHome,
IdleTimeout: session.IdleTimeout,
MaxSessionDuration: session.MaxSessionDuration,
}
if err := h.sessionDB.CreateSession(ctx, dbSession); err != nil {
log.Printf("Failed to cache session %s in database (non-fatal): %v", sessionName, err)
}

// Return the session info immediately
// The controller will create the actual Kubernetes resources
response := map[string]interface{}{
Expand Down Expand Up @@ -1934,7 +2013,43 @@ func (h *Handler) convertDBSessionsToResponse(sessions []*db.Session) []map[stri
}

// convertDBSessionToResponse converts a database session to API response format.
// If the database doesn't have the session URL, it fetches the status from Kubernetes.
func (h *Handler) convertDBSessionToResponse(session *db.Session) map[string]interface{} {
// Fetch Kubernetes status if database is missing URL or phase is empty
// This handles the case where the controller hasn't yet communicated status back to API
url := session.URL
podName := session.PodName
phase := session.State

if (url == "" || phase == "") && h.k8sClient != nil {
ctx := context.Background()
k8sSession, err := h.k8sClient.GetSession(ctx, h.namespace, session.ID)
if err == nil && k8sSession != nil {
if k8sSession.Status.URL != "" {
url = k8sSession.Status.URL
}
if k8sSession.Status.PodName != "" {
podName = k8sSession.Status.PodName
}
if k8sSession.Status.Phase != "" {
phase = k8sSession.Status.Phase
}
// Also update resources from Kubernetes if missing
if session.Memory == "" && k8sSession.Resources.Memory != "" {
session.Memory = k8sSession.Resources.Memory
}
if session.CPU == "" && k8sSession.Resources.CPU != "" {
session.CPU = k8sSession.Resources.CPU
}
}
}

// Capitalize phase for status.phase (UI expects "Running" not "running")
capitalizedPhase := phase
if len(phase) > 0 {
capitalizedPhase = strings.ToUpper(phase[:1]) + phase[1:]
}

result := map[string]interface{}{
"name": session.ID,
"namespace": session.Namespace,
Expand All @@ -1948,9 +2063,9 @@ func (h *Handler) convertDBSessionToResponse(session *db.Session) map[string]int
"platform": session.Platform,
"activeConnections": session.ActiveConnections,
"status": map[string]interface{}{
"phase": session.State,
"url": session.URL,
"podName": session.PodName,
"phase": capitalizedPhase,
"url": url,
"podName": podName,
},
}

Expand Down
124 changes: 97 additions & 27 deletions api/internal/db/applications.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,70 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/google/uuid"
"github.com/streamspace/streamspace/api/internal/models"
)

// downloadIcon downloads an icon from a URL and returns the binary data and media type.
// Returns empty values if download fails (non-fatal - app can still be installed without icon).
func downloadIcon(iconURL string) ([]byte, string) {
if iconURL == "" {
return nil, ""
}

// Create HTTP client with timeout
client := &http.Client{
Timeout: 30 * time.Second,
}

resp, err := client.Get(iconURL)
if err != nil {
fmt.Printf("Warning: failed to download icon from %s: %v\n", iconURL, err)
return nil, ""
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
fmt.Printf("Warning: failed to download icon from %s: status %d\n", iconURL, resp.StatusCode)
return nil, ""
}

// Read the icon data
data, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Warning: failed to read icon data from %s: %v\n", iconURL, err)
return nil, ""
}

// Determine media type from Content-Type header or file extension
mediaType := resp.Header.Get("Content-Type")
if mediaType == "" || mediaType == "application/octet-stream" {
// Guess from URL extension
if strings.HasSuffix(strings.ToLower(iconURL), ".svg") {
mediaType = "image/svg+xml"
} else if strings.HasSuffix(strings.ToLower(iconURL), ".png") {
mediaType = "image/png"
} else if strings.HasSuffix(strings.ToLower(iconURL), ".jpg") || strings.HasSuffix(strings.ToLower(iconURL), ".jpeg") {
mediaType = "image/jpeg"
} else if strings.HasSuffix(strings.ToLower(iconURL), ".gif") {
mediaType = "image/gif"
} else if strings.HasSuffix(strings.ToLower(iconURL), ".webp") {
mediaType = "image/webp"
} else {
mediaType = "image/png" // Default assumption
}
}

return data, mediaType
}

// ApplicationDB handles database operations for installed applications
type ApplicationDB struct {
db *sql.DB
Expand All @@ -77,16 +133,31 @@ func NewApplicationDB(db *sql.DB) *ApplicationDB {
return &ApplicationDB{db: db}
}

// InstallApplicationParams contains all data needed to install an application
type InstallApplicationParams struct {
CatalogTemplateID int
DisplayName string
Description string
Category string
IconURL string
IconData []byte
IconMediaType string
Manifest string
Configuration map[string]interface{}
}

// InstallApplication installs a new application from the catalog
func (a *ApplicationDB) InstallApplication(ctx context.Context, req *models.InstallApplicationRequest, userID string) (*models.InstalledApplication, error) {
appID := uuid.New().String()
guidSuffix := uuid.New().String()[:8]

// Get template info for default name
var templateName, templateDisplayName string
// Get full template info including manifest
var templateName, templateDisplayName, description, category, iconURL, manifest string
err := a.db.QueryRowContext(ctx, `
SELECT name, display_name FROM catalog_templates WHERE id = $1
`, req.CatalogTemplateID).Scan(&templateName, &templateDisplayName)
SELECT name, display_name, COALESCE(description, ''), COALESCE(category, ''),
COALESCE(icon_url, ''), COALESCE(manifest::text, '{}')
FROM catalog_templates WHERE id = $1
`, req.CatalogTemplateID).Scan(&templateName, &templateDisplayName, &description, &category, &iconURL, &manifest)
if err != nil {
return nil, fmt.Errorf("failed to get template: %w", err)
}
Expand All @@ -110,11 +181,24 @@ func (a *ApplicationDB) InstallApplication(ctx context.Context, req *models.Inst
}
}

// Download icon if URL is provided
var iconData []byte
var iconMediaType string
if iconURL != "" {
iconData, iconMediaType = downloadIcon(iconURL)
}

app := &models.InstalledApplication{
ID: appID,
CatalogTemplateID: req.CatalogTemplateID,
Name: name,
DisplayName: displayName,
Description: description,
Category: category,
IconURL: iconURL,
IconData: iconData,
IconMediaType: iconMediaType,
Manifest: manifest,
FolderPath: folderPath,
Enabled: true,
Configuration: req.Configuration,
Expand All @@ -125,14 +209,16 @@ func (a *ApplicationDB) InstallApplication(ctx context.Context, req *models.Inst

query := `
INSERT INTO installed_applications (
id, catalog_template_id, name, display_name, folder_path,
id, catalog_template_id, name, display_name, description, category,
icon_url, icon_data, icon_media_type, manifest, folder_path,
enabled, configuration, created_by, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
`

_, err = a.db.ExecContext(ctx, query,
app.ID, app.CatalogTemplateID, app.Name, app.DisplayName, app.FolderPath,
app.ID, app.CatalogTemplateID, app.Name, app.DisplayName, app.Description, app.Category,
app.IconURL, app.IconData, app.IconMediaType, app.Manifest, app.FolderPath,
app.Enabled, string(configJSON), app.CreatedBy, app.CreatedAt, app.UpdatedAt,
)
if err != nil {
Expand Down Expand Up @@ -230,12 +316,6 @@ func (a *ApplicationDB) ListApplications(ctx context.Context, enabledOnly bool)
}
defer rows.Close()

// Get base path for folder checks
basePath := os.Getenv("APPS_BASE_PATH")
if basePath == "" {
basePath = "/app"
}

apps := []*models.InstalledApplication{}
for rows.Next() {
app := &models.InstalledApplication{}
Expand Down Expand Up @@ -264,20 +344,10 @@ func (a *ApplicationDB) ListApplications(ctx context.Context, enabledOnly bool)
json.Unmarshal(configJSON, &app.Configuration)
}

// Check if folder exists - if not and app is enabled, auto-disable it
if app.Enabled && app.FolderPath != "" {
fullPath := filepath.Join(basePath, app.FolderPath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
// Folder doesn't exist, disable the application
_, updateErr := a.db.ExecContext(ctx,
"UPDATE installed_applications SET enabled = false, updated_at = $1 WHERE id = $2",
time.Now(), app.ID)
if updateErr == nil {
app.Enabled = false
fmt.Printf("Auto-disabled application %s: folder %s does not exist\n", app.DisplayName, fullPath)
}
}
}
// Note: We no longer auto-disable applications when folders are missing.
// Instead, the controller sync mechanism will recreate missing resources.
// Applications remain enabled in the database and the controller will
// receive AppInstallEvents during sync to recreate any missing templates.

apps = append(apps, app)
}
Expand Down
16 changes: 16 additions & 0 deletions api/internal/db/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,9 +498,15 @@ func (d *Database) Migrate() error {
catalog_template_id INT REFERENCES catalog_templates(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100),
folder_path VARCHAR(255),
enabled BOOLEAN DEFAULT true,
configuration JSONB DEFAULT '{}',
icon_url TEXT,
icon_data BYTEA,
icon_media_type VARCHAR(100),
manifest JSONB,
created_by VARCHAR(255) REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
Expand Down Expand Up @@ -2061,9 +2067,19 @@ func (d *Database) Migrate() error {
`ALTER TABLE installed_applications ADD COLUMN IF NOT EXISTS install_message TEXT`,
`ALTER TABLE installed_applications ADD COLUMN IF NOT EXISTS platform VARCHAR(50) DEFAULT 'kubernetes'`,

// Add icon and metadata columns to installed_applications for persistence
// Icons are downloaded when app is installed, enabling offline/air-gapped deployments
`ALTER TABLE installed_applications ADD COLUMN IF NOT EXISTS description TEXT`,
`ALTER TABLE installed_applications ADD COLUMN IF NOT EXISTS category VARCHAR(100)`,
`ALTER TABLE installed_applications ADD COLUMN IF NOT EXISTS icon_url TEXT`,
`ALTER TABLE installed_applications ADD COLUMN IF NOT EXISTS icon_data BYTEA`,
`ALTER TABLE installed_applications ADD COLUMN IF NOT EXISTS icon_media_type VARCHAR(100)`,
`ALTER TABLE installed_applications ADD COLUMN IF NOT EXISTS manifest JSONB`,

// Create index for install status
`CREATE INDEX IF NOT EXISTS idx_installed_applications_status ON installed_applications(install_status)`,
`CREATE INDEX IF NOT EXISTS idx_installed_applications_platform ON installed_applications(platform)`,
`CREATE INDEX IF NOT EXISTS idx_installed_applications_category ON installed_applications(category)`,

// Add platform fields to sessions for multi-platform support
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS platform VARCHAR(50) DEFAULT 'kubernetes'`,
Expand Down
Loading
Loading