Skip to content

Commit d95a60b

Browse files
committed
refactor: Address maintainer feedback on placeholder pages
- Move default templates to configuration via environment variables - Add ConfigMap for default placeholder templates - Support multiple content types (HTML, JSON, XML, plain text) - Remove ConfigMap fields from HTTPScaledObject CRD - Add error handling for endpoint cache failures - Update tests to reflect all changes This addresses all review comments: - Defaults configured globally via mounted ConfigMap - Arbitrary content types supported for microservices - No RBAC changes needed (removed ConfigMap access) - Proper error handling when cache fails
1 parent f1c421c commit d95a60b

File tree

8 files changed

+295
-222
lines changed

8 files changed

+295
-222
lines changed

config/crd/bases/http.keda.sh_httpscaledobjects.yaml

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,8 @@ spec:
113113
scale-from-zero
114114
properties:
115115
content:
116-
description: Inline HTML content for placeholder page (takes precedence
117-
over ContentConfigMap)
118-
type: string
119-
contentConfigMap:
120-
description: Path to ConfigMap containing placeholder HTML content
121-
(in same namespace)
122-
type: string
123-
contentConfigMapKey:
124-
default: template.html
125-
description: Key in ConfigMap containing the HTML template
116+
description: Inline content for placeholder page (can be HTML,
117+
JSON, plain text, etc.)
126118
type: string
127119
enabled:
128120
default: false

config/interceptor/deployment.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ spec:
5454
value: "10s"
5555
- name: KEDA_HTTP_EXPECT_CONTINUE_TIMEOUT
5656
value: "1s"
57+
- name: KEDA_HTTP_PLACEHOLDER_DEFAULT_TEMPLATE_PATH
58+
value: "/placeholder-templates/default.html"
59+
- name: KEDA_HTTP_PLACEHOLDER_ENABLE_SCRIPT
60+
value: "true"
5761
ports:
5862
- name: admin
5963
containerPort: 9090
@@ -83,9 +87,17 @@ spec:
8387
capabilities:
8488
drop:
8589
- ALL
90+
volumeMounts:
91+
- name: placeholder-templates
92+
mountPath: /placeholder-templates
93+
readOnly: true
8694
securityContext:
8795
runAsNonRoot: true
8896
seccompProfile:
8997
type: RuntimeDefault
9098
serviceAccountName: interceptor
9199
terminationGracePeriodSeconds: 10
100+
volumes:
101+
- name: placeholder-templates
102+
configMap:
103+
name: interceptor-placeholder-templates

config/interceptor/kustomization.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ resources:
99
- metrics.service.yaml
1010
- service_account.yaml
1111
- scaledobject.yaml
12+
- placeholder-templates.yaml
1213
configurations:
1314
- transformerconfig.yaml
1415
labels:
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: interceptor-placeholder-templates
5+
data:
6+
default.html: |
7+
<!DOCTYPE html>
8+
<html>
9+
<head>
10+
<title>Service Starting</title>
11+
<meta charset="utf-8">
12+
<style>
13+
body {
14+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
15+
display: flex;
16+
align-items: center;
17+
justify-content: center;
18+
min-height: 100vh;
19+
margin: 0;
20+
background: #f5f5f5;
21+
}
22+
.container {
23+
text-align: center;
24+
padding: 2rem;
25+
background: white;
26+
border-radius: 8px;
27+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
28+
max-width: 400px;
29+
}
30+
h1 {
31+
color: #333;
32+
margin-bottom: 1rem;
33+
font-size: 1.5rem;
34+
}
35+
.spinner {
36+
width: 40px;
37+
height: 40px;
38+
margin: 1.5rem auto;
39+
border: 4px solid #f3f3f3;
40+
border-top: 4px solid #3498db;
41+
border-radius: 50%;
42+
animation: spin 1s linear infinite;
43+
}
44+
@keyframes spin {
45+
0% { transform: rotate(0deg); }
46+
100% { transform: rotate(360deg); }
47+
}
48+
p {
49+
color: #666;
50+
margin: 0.5rem 0;
51+
}
52+
</style>
53+
</head>
54+
<body>
55+
<div class="container">
56+
<h1>{{.ServiceName}} is starting up...</h1>
57+
<div class="spinner"></div>
58+
<p>Please wait while we prepare your service.</p>
59+
</div>
60+
</body>
61+
</html>

interceptor/config/serving.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ type Serving struct {
4949
TLSPort int `envconfig:"KEDA_HTTP_PROXY_TLS_PORT" default:"8443"`
5050
// ProfilingAddr if not empty, pprof will be available on this address, assuming host:port here
5151
ProfilingAddr string `envconfig:"PROFILING_BIND_ADDRESS" default:""`
52+
// PlaceholderDefaultTemplatePath is the path to the default placeholder template file
53+
PlaceholderDefaultTemplatePath string `envconfig:"KEDA_HTTP_PLACEHOLDER_DEFAULT_TEMPLATE_PATH" default:""`
54+
// PlaceholderEnableScript controls whether to inject auto-refresh script into HTML responses
55+
PlaceholderEnableScript bool `envconfig:"KEDA_HTTP_PLACEHOLDER_ENABLE_SCRIPT" default:"true"`
5256
}
5357

5458
// Parse parses standard configs using envconfig and returns a pointer to the

interceptor/handler/placeholder.go

Lines changed: 107 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,13 @@ import (
66
"fmt"
77
"html/template"
88
"net/http"
9+
"os"
910
"strings"
1011
"sync"
1112
"time"
1213

13-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14-
"k8s.io/client-go/kubernetes"
15-
14+
"github.com/kedacore/http-add-on/interceptor/config"
1615
"github.com/kedacore/http-add-on/operator/apis/http/v1alpha1"
17-
"github.com/kedacore/http-add-on/pkg/routing"
1816
)
1917

2018
const placeholderScript = `<script>
@@ -110,10 +108,11 @@ type cacheEntry struct {
110108

111109
// PlaceholderHandler handles serving placeholder pages during scale-from-zero
112110
type PlaceholderHandler struct {
113-
k8sClient kubernetes.Interface
114-
templateCache map[string]*cacheEntry
115-
cacheMutex sync.RWMutex
116-
defaultTmpl *template.Template
111+
templateCache map[string]*cacheEntry
112+
cacheMutex sync.RWMutex
113+
defaultTmpl *template.Template
114+
servingCfg *config.Serving
115+
enableScript bool
117116
}
118117

119118
// PlaceholderData contains data for rendering placeholder templates
@@ -126,19 +125,39 @@ type PlaceholderData struct {
126125
}
127126

128127
// NewPlaceholderHandler creates a new placeholder handler
129-
func NewPlaceholderHandler(k8sClient kubernetes.Interface, routingTable routing.Table) (*PlaceholderHandler, error) {
130-
// Combine the default template with the script
131-
defaultTemplateWithScript := injectPlaceholderScript(defaultPlaceholderTemplateWithoutScript)
132-
defaultTmpl, err := template.New("default").Parse(defaultTemplateWithScript)
128+
func NewPlaceholderHandler(servingCfg *config.Serving) (*PlaceholderHandler, error) {
129+
var defaultTemplate string
130+
131+
// Try to load template from configured path
132+
if servingCfg.PlaceholderDefaultTemplatePath != "" {
133+
content, err := os.ReadFile(servingCfg.PlaceholderDefaultTemplatePath)
134+
if err == nil {
135+
defaultTemplate = string(content)
136+
} else {
137+
// Fall back to built-in template if file cannot be read
138+
fmt.Printf("Warning: Could not read placeholder template from %s: %v. Using built-in template.\n",
139+
servingCfg.PlaceholderDefaultTemplatePath, err)
140+
defaultTemplate = defaultPlaceholderTemplateWithoutScript
141+
}
142+
} else {
143+
defaultTemplate = defaultPlaceholderTemplateWithoutScript
144+
}
145+
146+
// Inject script if enabled
147+
if servingCfg.PlaceholderEnableScript {
148+
defaultTemplate = injectPlaceholderScript(defaultTemplate)
149+
}
150+
151+
defaultTmpl, err := template.New("default").Parse(defaultTemplate)
133152
if err != nil {
134153
return nil, fmt.Errorf("failed to parse default template: %w", err)
135154
}
136155

137156
return &PlaceholderHandler{
138-
k8sClient: k8sClient,
139-
routingTable: routingTable,
140157
templateCache: make(map[string]*cacheEntry),
141158
defaultTmpl: defaultTmpl,
159+
servingCfg: servingCfg,
160+
enableScript: servingCfg.PlaceholderEnableScript,
142161
}, nil
143162
}
144163

@@ -166,18 +185,45 @@ func injectPlaceholderScript(templateContent string) string {
166185
return templateContent + placeholderScript
167186
}
168187

169-
// For non-HTML content, wrap it in a minimal HTML structure with the script
170-
return fmt.Sprintf(`<!DOCTYPE html>
171-
<html>
172-
<head>
173-
<meta charset="utf-8">
174-
<title>Service Starting</title>
175-
</head>
176-
<body>
177-
%s
178-
%s
179-
</body>
180-
</html>`, templateContent, placeholderScript)
188+
// Don't wrap non-HTML content - return as-is
189+
return templateContent
190+
}
191+
192+
// detectContentType determines the appropriate content type based on Accept header and content
193+
func detectContentType(acceptHeader string, content string) string {
194+
// Check Accept header for specific content types
195+
if strings.Contains(acceptHeader, "application/json") {
196+
return "application/json"
197+
}
198+
if strings.Contains(acceptHeader, "application/xml") {
199+
return "application/xml"
200+
}
201+
if strings.Contains(acceptHeader, "text/plain") {
202+
return "text/plain"
203+
}
204+
205+
// Default to HTML for browser requests or when HTML is accepted
206+
if strings.Contains(acceptHeader, "text/html") || strings.Contains(acceptHeader, "*/*") || acceptHeader == "" {
207+
// Check if content looks like HTML
208+
if strings.Contains(content, "<") && strings.Contains(content, ">") {
209+
return "text/html; charset=utf-8"
210+
}
211+
}
212+
213+
// Try to detect based on content
214+
trimmed := strings.TrimSpace(content)
215+
if (strings.HasPrefix(trimmed, "{") && strings.HasSuffix(trimmed, "}")) ||
216+
(strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]")) {
217+
return "application/json"
218+
}
219+
if strings.HasPrefix(trimmed, "<") {
220+
if strings.HasPrefix(trimmed, "<?xml") {
221+
return "application/xml"
222+
}
223+
return "text/html; charset=utf-8"
224+
}
225+
226+
return "text/plain; charset=utf-8"
181227
}
182228

183229
// ServePlaceholder serves a placeholder page based on the HTTPScaledObject configuration
@@ -194,19 +240,19 @@ func (h *PlaceholderHandler) ServePlaceholder(w http.ResponseWriter, r *http.Req
194240
statusCode = http.StatusServiceUnavailable
195241
}
196242

243+
// Set custom headers first
197244
for k, v := range config.Headers {
198245
w.Header().Set(k, v)
199246
}
200247

201-
w.Header().Set("Content-Type", "text/html; charset=utf-8")
202-
w.Header().Set("X-KEDA-HTTP-Placeholder-Served", "true")
203-
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
204-
248+
// Get template and render content
205249
tmpl, err := h.getTemplate(r.Context(), hso)
206250
if err != nil {
251+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
252+
w.Header().Set("X-KEDA-HTTP-Placeholder-Served", "true")
253+
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
207254
w.WriteHeader(statusCode)
208-
fmt.Fprintf(w, "<h1>%s is starting up...</h1><meta http-equiv='refresh' content='%d'>",
209-
hso.Spec.ScaleTargetRef.Service, config.RefreshInterval)
255+
fmt.Fprintf(w, "%s is starting up...\n", hso.Spec.ScaleTargetRef.Service)
210256
return nil
211257
}
212258

@@ -220,14 +266,32 @@ func (h *PlaceholderHandler) ServePlaceholder(w http.ResponseWriter, r *http.Req
220266

221267
var buf bytes.Buffer
222268
if err := tmpl.Execute(&buf, data); err != nil {
269+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
270+
w.Header().Set("X-KEDA-HTTP-Placeholder-Served", "true")
271+
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
223272
w.WriteHeader(statusCode)
224-
fmt.Fprintf(w, "<h1>%s is starting up...</h1><meta http-equiv='refresh' content='%d'>",
225-
hso.Spec.ScaleTargetRef.Service, config.RefreshInterval)
273+
fmt.Fprintf(w, "%s is starting up...\n", hso.Spec.ScaleTargetRef.Service)
226274
return nil
227275
}
228276

277+
content := buf.String()
278+
279+
// Detect and set content type based on Accept header and content
280+
contentType := detectContentType(r.Header.Get("Accept"), content)
281+
282+
// For non-HTML content, don't inject script even if enabled
283+
isHTML := strings.Contains(contentType, "text/html")
284+
if !isHTML && h.enableScript && strings.Contains(content, placeholderScript) {
285+
// Remove script from non-HTML content
286+
content = strings.ReplaceAll(content, placeholderScript, "")
287+
}
288+
289+
w.Header().Set("Content-Type", contentType)
290+
w.Header().Set("X-KEDA-HTTP-Placeholder-Served", "true")
291+
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
292+
229293
w.WriteHeader(statusCode)
230-
_, err = w.Write(buf.Bytes())
294+
_, err = w.Write([]byte(content))
231295
return err
232296
}
233297

@@ -246,58 +310,20 @@ func (h *PlaceholderHandler) getTemplate(ctx context.Context, hso *v1alpha1.HTTP
246310
}
247311
h.cacheMutex.RUnlock()
248312

249-
injectedContent := injectPlaceholderScript(config.Content)
250-
tmpl, err := template.New("inline").Parse(injectedContent)
251-
if err != nil {
252-
return nil, err
253-
}
254-
255313
h.cacheMutex.Lock()
256-
h.templateCache[cacheKey] = &cacheEntry{
257-
template: tmpl,
258-
hsoGeneration: hso.Generation,
314+
content := config.Content
315+
// Only inject script for HTML-like content if enabled
316+
if h.enableScript && (strings.Contains(content, "<") && strings.Contains(content, ">")) {
317+
content = injectPlaceholderScript(content)
259318
}
260-
h.cacheMutex.Unlock()
261-
return tmpl, nil
262-
}
263-
264-
if config.ContentConfigMap != "" {
265-
cacheKey := fmt.Sprintf("%s/%s/cm/%s", hso.Namespace, hso.Name, config.ContentConfigMap)
266-
267-
cm, err := h.k8sClient.CoreV1().ConfigMaps(hso.Namespace).Get(ctx, config.ContentConfigMap, metav1.GetOptions{})
268-
if err != nil {
269-
return nil, fmt.Errorf("failed to get ConfigMap %s: %w", config.ContentConfigMap, err)
270-
}
271-
272-
h.cacheMutex.RLock()
273-
entry, ok := h.templateCache[cacheKey]
274-
if ok && entry.hsoGeneration == hso.Generation && entry.configMapVersion == cm.ResourceVersion {
275-
h.cacheMutex.RUnlock()
276-
return entry.template, nil
277-
}
278-
h.cacheMutex.RUnlock()
279-
280-
key := config.ContentConfigMapKey
281-
if key == "" {
282-
key = "template.html"
283-
}
284-
285-
content, ok := cm.Data[key]
286-
if !ok {
287-
return nil, fmt.Errorf("key %s not found in ConfigMap %s", key, config.ContentConfigMap)
288-
}
289-
290-
injectedContent := injectPlaceholderScript(content)
291-
tmpl, err := template.New("configmap").Parse(injectedContent)
319+
tmpl, err := template.New("inline").Parse(content)
292320
if err != nil {
321+
h.cacheMutex.Unlock()
293322
return nil, err
294323
}
295-
296-
h.cacheMutex.Lock()
297324
h.templateCache[cacheKey] = &cacheEntry{
298-
template: tmpl,
299-
hsoGeneration: hso.Generation,
300-
configMapVersion: cm.ResourceVersion,
325+
template: tmpl,
326+
hsoGeneration: hso.Generation,
301327
}
302328
h.cacheMutex.Unlock()
303329
return tmpl, nil

0 commit comments

Comments
 (0)