|  | 
|  | 1 | +package handler | 
|  | 2 | + | 
|  | 3 | +import ( | 
|  | 4 | +	"bytes" | 
|  | 5 | +	"context" | 
|  | 6 | +	"fmt" | 
|  | 7 | +	"html/template" | 
|  | 8 | +	"net/http" | 
|  | 9 | +	"time" | 
|  | 10 | + | 
|  | 11 | +	"github.com/kedacore/http-add-on/operator/apis/http/v1alpha1" | 
|  | 12 | +	"github.com/kedacore/http-add-on/pkg/routing" | 
|  | 13 | +	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | 
|  | 14 | +	"k8s.io/client-go/kubernetes" | 
|  | 15 | +) | 
|  | 16 | + | 
|  | 17 | +const defaultPlaceholderTemplate = `<!DOCTYPE html> | 
|  | 18 | +<html> | 
|  | 19 | +<head> | 
|  | 20 | +    <title>Service Starting</title> | 
|  | 21 | +    <meta http-equiv="refresh" content="{{.RefreshInterval}}"> | 
|  | 22 | +    <meta charset="utf-8"> | 
|  | 23 | +    <style> | 
|  | 24 | +        body { | 
|  | 25 | +            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | 
|  | 26 | +            display: flex; | 
|  | 27 | +            align-items: center; | 
|  | 28 | +            justify-content: center; | 
|  | 29 | +            min-height: 100vh; | 
|  | 30 | +            margin: 0; | 
|  | 31 | +            background: #f5f5f5; | 
|  | 32 | +        } | 
|  | 33 | +        .container { | 
|  | 34 | +            text-align: center; | 
|  | 35 | +            padding: 2rem; | 
|  | 36 | +            background: white; | 
|  | 37 | +            border-radius: 8px; | 
|  | 38 | +            box-shadow: 0 2px 10px rgba(0,0,0,0.1); | 
|  | 39 | +            max-width: 400px; | 
|  | 40 | +        } | 
|  | 41 | +        h1 { | 
|  | 42 | +            color: #333; | 
|  | 43 | +            margin-bottom: 1rem; | 
|  | 44 | +            font-size: 1.5rem; | 
|  | 45 | +        } | 
|  | 46 | +        .spinner { | 
|  | 47 | +            width: 40px; | 
|  | 48 | +            height: 40px; | 
|  | 49 | +            margin: 1.5rem auto; | 
|  | 50 | +            border: 4px solid #f3f3f3; | 
|  | 51 | +            border-top: 4px solid #3498db; | 
|  | 52 | +            border-radius: 50%; | 
|  | 53 | +            animation: spin 1s linear infinite; | 
|  | 54 | +        } | 
|  | 55 | +        @keyframes spin { | 
|  | 56 | +            0% { transform: rotate(0deg); } | 
|  | 57 | +            100% { transform: rotate(360deg); } | 
|  | 58 | +        } | 
|  | 59 | +        p { | 
|  | 60 | +            color: #666; | 
|  | 61 | +            margin: 0.5rem 0; | 
|  | 62 | +        } | 
|  | 63 | +        .small { | 
|  | 64 | +            font-size: 0.875rem; | 
|  | 65 | +            color: #999; | 
|  | 66 | +        } | 
|  | 67 | +    </style> | 
|  | 68 | +</head> | 
|  | 69 | +<body> | 
|  | 70 | +    <div class="container"> | 
|  | 71 | +        <h1>{{.ServiceName}} is starting up...</h1> | 
|  | 72 | +        <div class="spinner"></div> | 
|  | 73 | +        <p>Please wait while we prepare your service.</p> | 
|  | 74 | +        <p class="small">This page will refresh automatically every {{.RefreshInterval}} seconds.</p> | 
|  | 75 | +    </div> | 
|  | 76 | +</body> | 
|  | 77 | +</html>` | 
|  | 78 | + | 
|  | 79 | +// PlaceholderHandler handles serving placeholder pages during scale-from-zero | 
|  | 80 | +type PlaceholderHandler struct { | 
|  | 81 | +	k8sClient     kubernetes.Interface | 
|  | 82 | +	routingTable  routing.Table | 
|  | 83 | +	templateCache map[string]*template.Template | 
|  | 84 | +	defaultTmpl   *template.Template | 
|  | 85 | +} | 
|  | 86 | + | 
|  | 87 | +// PlaceholderData contains data for rendering placeholder templates | 
|  | 88 | +type PlaceholderData struct { | 
|  | 89 | +	ServiceName     string | 
|  | 90 | +	Namespace       string | 
|  | 91 | +	RefreshInterval int32 | 
|  | 92 | +	RequestID       string | 
|  | 93 | +	Timestamp       string | 
|  | 94 | +} | 
|  | 95 | + | 
|  | 96 | +// NewPlaceholderHandler creates a new placeholder handler | 
|  | 97 | +func NewPlaceholderHandler(k8sClient kubernetes.Interface, routingTable routing.Table) (*PlaceholderHandler, error) { | 
|  | 98 | +	defaultTmpl, err := template.New("default").Parse(defaultPlaceholderTemplate) | 
|  | 99 | +	if err != nil { | 
|  | 100 | +		return nil, fmt.Errorf("failed to parse default template: %w", err) | 
|  | 101 | +	} | 
|  | 102 | + | 
|  | 103 | +	return &PlaceholderHandler{ | 
|  | 104 | +		k8sClient:     k8sClient, | 
|  | 105 | +		routingTable:  routingTable, | 
|  | 106 | +		templateCache: make(map[string]*template.Template), | 
|  | 107 | +		defaultTmpl:   defaultTmpl, | 
|  | 108 | +	}, nil | 
|  | 109 | +} | 
|  | 110 | + | 
|  | 111 | +// ServePlaceholder serves a placeholder page based on the HTTPScaledObject configuration | 
|  | 112 | +func (h *PlaceholderHandler) ServePlaceholder(w http.ResponseWriter, r *http.Request, hso *v1alpha1.HTTPScaledObject) error { | 
|  | 113 | +	if hso.Spec.PlaceholderConfig == nil || !hso.Spec.PlaceholderConfig.Enabled { | 
|  | 114 | +		http.Error(w, "Service temporarily unavailable", http.StatusServiceUnavailable) | 
|  | 115 | +		return nil | 
|  | 116 | +	} | 
|  | 117 | + | 
|  | 118 | +	config := hso.Spec.PlaceholderConfig | 
|  | 119 | + | 
|  | 120 | +	statusCode := int(config.StatusCode) | 
|  | 121 | +	if statusCode == 0 { | 
|  | 122 | +		statusCode = http.StatusServiceUnavailable | 
|  | 123 | +	} | 
|  | 124 | + | 
|  | 125 | +	for k, v := range config.Headers { | 
|  | 126 | +		w.Header().Set(k, v) | 
|  | 127 | +	} | 
|  | 128 | + | 
|  | 129 | +	w.Header().Set("Content-Type", "text/html; charset=utf-8") | 
|  | 130 | +	w.Header().Set("X-KEDA-HTTP-Placeholder-Served", "true") | 
|  | 131 | +	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") | 
|  | 132 | + | 
|  | 133 | +	tmpl, err := h.getTemplate(r.Context(), hso) | 
|  | 134 | +	if err != nil { | 
|  | 135 | +		w.WriteHeader(statusCode) | 
|  | 136 | +		fmt.Fprintf(w, "<h1>%s is starting up...</h1><meta http-equiv='refresh' content='%d'>", | 
|  | 137 | +			hso.Spec.ScaleTargetRef.Service, config.RefreshInterval) | 
|  | 138 | +		return nil | 
|  | 139 | +	} | 
|  | 140 | + | 
|  | 141 | +	data := PlaceholderData{ | 
|  | 142 | +		ServiceName:     hso.Spec.ScaleTargetRef.Service, | 
|  | 143 | +		Namespace:       hso.Namespace, | 
|  | 144 | +		RefreshInterval: config.RefreshInterval, | 
|  | 145 | +		RequestID:       r.Header.Get("X-Request-ID"), | 
|  | 146 | +		Timestamp:       time.Now().Format(time.RFC3339), | 
|  | 147 | +	} | 
|  | 148 | + | 
|  | 149 | +	var buf bytes.Buffer | 
|  | 150 | +	if err := tmpl.Execute(&buf, data); err != nil { | 
|  | 151 | +		w.WriteHeader(statusCode) | 
|  | 152 | +		fmt.Fprintf(w, "<h1>%s is starting up...</h1><meta http-equiv='refresh' content='%d'>", | 
|  | 153 | +			hso.Spec.ScaleTargetRef.Service, config.RefreshInterval) | 
|  | 154 | +		return nil | 
|  | 155 | +	} | 
|  | 156 | + | 
|  | 157 | +	w.WriteHeader(statusCode) | 
|  | 158 | +	_, err = w.Write(buf.Bytes()) | 
|  | 159 | +	return err | 
|  | 160 | +} | 
|  | 161 | + | 
|  | 162 | +// getTemplate retrieves the template for the given HTTPScaledObject | 
|  | 163 | +func (h *PlaceholderHandler) getTemplate(ctx context.Context, hso *v1alpha1.HTTPScaledObject) (*template.Template, error) { | 
|  | 164 | +	config := hso.Spec.PlaceholderConfig | 
|  | 165 | + | 
|  | 166 | +	if config.Content != "" { | 
|  | 167 | +		cacheKey := fmt.Sprintf("%s/%s/inline", hso.Namespace, hso.Name) | 
|  | 168 | +		if tmpl, ok := h.templateCache[cacheKey]; ok { | 
|  | 169 | +			return tmpl, nil | 
|  | 170 | +		} | 
|  | 171 | + | 
|  | 172 | +		tmpl, err := template.New("inline").Parse(config.Content) | 
|  | 173 | +		if err != nil { | 
|  | 174 | +			return nil, err | 
|  | 175 | +		} | 
|  | 176 | +		h.templateCache[cacheKey] = tmpl | 
|  | 177 | +		return tmpl, nil | 
|  | 178 | +	} | 
|  | 179 | + | 
|  | 180 | +	if config.ContentConfigMap != "" { | 
|  | 181 | +		cacheKey := fmt.Sprintf("%s/%s/cm/%s", hso.Namespace, hso.Name, config.ContentConfigMap) | 
|  | 182 | +		if tmpl, ok := h.templateCache[cacheKey]; ok { | 
|  | 183 | +			return tmpl, nil | 
|  | 184 | +		} | 
|  | 185 | + | 
|  | 186 | +		cm, err := h.k8sClient.CoreV1().ConfigMaps(hso.Namespace).Get(ctx, config.ContentConfigMap, metav1.GetOptions{}) | 
|  | 187 | +		if err != nil { | 
|  | 188 | +			return nil, fmt.Errorf("failed to get ConfigMap %s: %w", config.ContentConfigMap, err) | 
|  | 189 | +		} | 
|  | 190 | + | 
|  | 191 | +		key := config.ContentConfigMapKey | 
|  | 192 | +		if key == "" { | 
|  | 193 | +			key = "template.html" | 
|  | 194 | +		} | 
|  | 195 | + | 
|  | 196 | +		content, ok := cm.Data[key] | 
|  | 197 | +		if !ok { | 
|  | 198 | +			return nil, fmt.Errorf("key %s not found in ConfigMap %s", key, config.ContentConfigMap) | 
|  | 199 | +		} | 
|  | 200 | + | 
|  | 201 | +		tmpl, err := template.New("configmap").Parse(content) | 
|  | 202 | +		if err != nil { | 
|  | 203 | +			return nil, err | 
|  | 204 | +		} | 
|  | 205 | +		h.templateCache[cacheKey] = tmpl | 
|  | 206 | +		return tmpl, nil | 
|  | 207 | +	} | 
|  | 208 | + | 
|  | 209 | +	return h.defaultTmpl, nil | 
|  | 210 | +} | 
|  | 211 | + | 
|  | 212 | +// ClearCache clears the template cache | 
|  | 213 | +func (h *PlaceholderHandler) ClearCache() { | 
|  | 214 | +	h.templateCache = make(map[string]*template.Template) | 
|  | 215 | +} | 
0 commit comments