diff --git a/CHANGELOG.md b/CHANGELOG.md
index 051f529cc..1a46dfe27 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ This changelog keeps track of work items that have been completed and are ready
- **General**: Allow using HSO and SO with different names ([#1293](https://github.com/kedacore/http-add-on/issues/1293))
- **General**: Support profiling for KEDA components ([#4789](https://github.com/kedacore/keda/issues/4789))
- **General**: Add possibility to skip TLS verification for upstreams in interceptor ([#1307](https://github.com/kedacore/http-add-on/pull/1307))
+- **General**: Add custom placeholder responses for scale-from-zero scenarios ([#874](https://github.com/kedacore/http-add-on/issues/874))
### Improvements
- **Interceptor**: Support HTTPScaledObject scoped timeout ([#813](https://github.com/kedacore/http-add-on/issues/813))
diff --git a/config/crd/bases/http.keda.sh_httpscaledobjects.yaml b/config/crd/bases/http.keda.sh_httpscaledobjects.yaml
index 4af745de3..771ae519e 100644
--- a/config/crd/bases/http.keda.sh_httpscaledobjects.yaml
+++ b/config/crd/bases/http.keda.sh_httpscaledobjects.yaml
@@ -108,6 +108,45 @@ spec:
items:
type: string
type: array
+ placeholderConfig:
+ description: (optional) Configuration for placeholder pages during
+ scale-from-zero
+ properties:
+ content:
+ description: |-
+ Inline content for placeholder response. Supports any format (HTML, JSON, XML, plain text, etc.)
+ Content is processed as a Go template with variables: ServiceName, Namespace, RefreshInterval, RequestID, Timestamp
+ Content-Type should be set via the Headers field to match your content format
+ type: string
+ enabled:
+ default: false
+ description: Enable placeholder response when replicas are scaled
+ to zero
+ type: boolean
+ headers:
+ additionalProperties:
+ type: string
+ description: |-
+ Additional HTTP headers to include with placeholder response
+ Use this to set Content-Type matching your content format (e.g., 'Content-Type: application/json')
+ type: object
+ refreshInterval:
+ default: 5
+ description: |-
+ RefreshInterval is a template variable available in content (in seconds)
+ This is just data passed to the template, not used by the interceptor for automatic refresh
+ format: int32
+ maximum: 60
+ minimum: 1
+ type: integer
+ statusCode:
+ default: 503
+ description: HTTP status code to return with placeholder response
+ format: int32
+ maximum: 599
+ minimum: 100
+ type: integer
+ type: object
replicas:
description: (optional) Replica information
properties:
diff --git a/config/interceptor/deployment.yaml b/config/interceptor/deployment.yaml
index 15b538a42..eb2034e61 100644
--- a/config/interceptor/deployment.yaml
+++ b/config/interceptor/deployment.yaml
@@ -54,6 +54,8 @@ spec:
value: "10s"
- name: KEDA_HTTP_EXPECT_CONTINUE_TIMEOUT
value: "1s"
+ - name: KEDA_HTTP_PLACEHOLDER_DEFAULT_TEMPLATE_PATH
+ value: "/placeholder-templates/default.html"
ports:
- name: admin
containerPort: 9090
@@ -83,9 +85,17 @@ spec:
capabilities:
drop:
- ALL
+ volumeMounts:
+ - name: placeholder-templates
+ mountPath: /placeholder-templates
+ readOnly: true
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
serviceAccountName: interceptor
terminationGracePeriodSeconds: 10
+ volumes:
+ - name: placeholder-templates
+ configMap:
+ name: interceptor-placeholder-templates
diff --git a/config/interceptor/kustomization.yaml b/config/interceptor/kustomization.yaml
index 32e18f830..7afebff32 100644
--- a/config/interceptor/kustomization.yaml
+++ b/config/interceptor/kustomization.yaml
@@ -9,10 +9,11 @@ resources:
- metrics.service.yaml
- service_account.yaml
- scaledobject.yaml
+- placeholder-templates.yaml
configurations:
- transformerconfig.yaml
labels:
- includeSelectors: true
includeTemplates: true
pairs:
- app.kubernetes.io/instance: interceptor
+ app.kubernetes.io/instance: interceptor
\ No newline at end of file
diff --git a/config/interceptor/placeholder-templates.yaml b/config/interceptor/placeholder-templates.yaml
new file mode 100644
index 000000000..933554f76
--- /dev/null
+++ b/config/interceptor/placeholder-templates.yaml
@@ -0,0 +1,64 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: interceptor-placeholder-templates
+data:
+ # Optional default template used when placeholderConfig.content is not specified
+ # This is an HTML example - users can create any format (JSON, XML, plain text, etc.)
+ # Users should always set the Content-Type header in their HTTPScaledObject to match their format
+ default.html: |
+
+
+
+ Service Starting
+
+
+
+
+
+
{{.ServiceName}} is starting up...
+
+
Please wait while we prepare your service.
+
+
+
\ No newline at end of file
diff --git a/docs/ref/vX.X.X/http_scaled_object.md b/docs/ref/vX.X.X/http_scaled_object.md
index 313729f5e..5f19a4838 100644
--- a/docs/ref/vX.X.X/http_scaled_object.md
+++ b/docs/ref/vX.X.X/http_scaled_object.md
@@ -33,6 +33,24 @@ spec:
window: 1m
concurrency:
targetValue: 100
+ placeholderConfig:
+ enabled: true
+ refreshInterval: 5
+ statusCode: 503
+ headers:
+ Content-Type: "text/html; charset=utf-8"
+ X-Service-Status: "warming-up"
+ content: |
+
+
+
+ Service Starting
+
+
+
+ {{.ServiceName}} is starting...
+
+
```
This document is a narrated reference guide for the `HTTPScaledObject`.
@@ -134,3 +152,84 @@ This section enables scaling based on the request concurrency.
>Default: 100
This is the target value for the scaling configuration.
+
+## `placeholderConfig`
+
+This optional section enables serving placeholder responses when the workload is scaled to zero. When enabled, instead of returning an error while waiting for the workload to scale up, the interceptor will serve a customizable response with any content format.
+
+### `enabled`
+
+>Default: false
+
+Whether to enable placeholder responses for this HTTPScaledObject.
+
+### `refreshInterval`
+
+>Default: 5
+
+A template variable (in seconds) that can be used in your content template. This is just data passed to the template - it does not automatically refresh the response. You can use it in your content for client-side refresh logic if needed (e.g., ``).
+
+### `statusCode`
+
+>Default: 503
+
+The HTTP status code to return with the placeholder response. Common values are 503 (Service Unavailable) or 202 (Accepted).
+
+### `headers`
+
+>Default: {}
+
+A map of custom HTTP headers to include in the placeholder response. **Important**: Use this to set the `Content-Type` header to match your content format. For example:
+- `Content-Type: text/html; charset=utf-8` for HTML
+- `Content-Type: application/json` for JSON
+- `Content-Type: text/plain` for plain text
+
+### `content`
+
+>Default: ConfigMap-provided template (if configured), otherwise returns simple text
+
+Custom content for the placeholder response. Supports any format (HTML, JSON, XML, plain text, etc.). Content is processed as a Go template with the following variables:
+- `{{.ServiceName}}` - The name of the service from scaleTargetRef
+- `{{.Namespace}}` - The namespace of the HTTPScaledObject
+- `{{.RefreshInterval}}` - The configured refresh interval value (just a number)
+- `{{.RequestID}}` - The X-Request-ID header value if present
+- `{{.Timestamp}}` - The current timestamp in RFC3339 format
+
+**Examples:**
+
+HTML with client-side refresh:
+```yaml
+content: |
+
+
+
+ Service Starting
+
+
+
+ {{.ServiceName}} is starting...
+
+
+headers:
+ Content-Type: "text/html; charset=utf-8"
+```
+
+JSON response:
+```yaml
+content: |
+ {
+ "status": "warming_up",
+ "service": "{{.ServiceName}}",
+ "namespace": "{{.Namespace}}",
+ "timestamp": "{{.Timestamp}}"
+ }
+headers:
+ Content-Type: "application/json"
+```
+
+Plain text:
+```yaml
+content: "{{.ServiceName}} is starting up. Please retry in a few seconds."
+headers:
+ Content-Type: "text/plain"
+```
diff --git a/examples/vX.X.X/httpscaledobject-json-api.yaml b/examples/vX.X.X/httpscaledobject-json-api.yaml
new file mode 100644
index 000000000..14918dd59
--- /dev/null
+++ b/examples/vX.X.X/httpscaledobject-json-api.yaml
@@ -0,0 +1,38 @@
+# Example: JSON placeholder for API-to-API communication
+# Use case: Microservices calling other microservices
+kind: HTTPScaledObject
+apiVersion: http.keda.sh/v1alpha1
+metadata:
+ name: api-service
+spec:
+ hosts:
+ - api.example.com
+ scaleTargetRef:
+ name: api-backend
+ kind: Deployment
+ apiVersion: apps/v1
+ service: api-backend
+ port: 8080
+ replicas:
+ min: 0
+ max: 10
+ scalingMetric:
+ concurrency:
+ targetValue: 100
+ placeholderConfig:
+ enabled: true
+ refreshInterval: 10
+ statusCode: 202 # 202 Accepted - request received but not yet processed
+ headers:
+ Content-Type: "application/json"
+ Retry-After: "10"
+ X-Service-Status: "scaling"
+ content: |
+ {
+ "status": "warming_up",
+ "service": "{{.ServiceName}}",
+ "namespace": "{{.Namespace}}",
+ "retry_after_seconds": {{.RefreshInterval}},
+ "timestamp": "{{.Timestamp}}",
+ "message": "Service is scaling up to handle your request"
+ }
diff --git a/examples/vX.X.X/httpscaledobject-plaintext.yaml b/examples/vX.X.X/httpscaledobject-plaintext.yaml
new file mode 100644
index 000000000..88791ee6c
--- /dev/null
+++ b/examples/vX.X.X/httpscaledobject-plaintext.yaml
@@ -0,0 +1,35 @@
+# Example: Plain text placeholder for simple clients
+# Use case: CLI tools, simple HTTP clients, or monitoring systems
+kind: HTTPScaledObject
+apiVersion: http.keda.sh/v1alpha1
+metadata:
+ name: simple-service
+spec:
+ hosts:
+ - simple.example.com
+ scaleTargetRef:
+ name: simple-backend
+ kind: Deployment
+ apiVersion: apps/v1
+ service: simple-backend
+ port: 8080
+ replicas:
+ min: 0
+ max: 10
+ scalingMetric:
+ concurrency:
+ targetValue: 100
+ placeholderConfig:
+ enabled: true
+ refreshInterval: 3
+ statusCode: 503
+ headers:
+ Content-Type: "text/plain; charset=utf-8"
+ content: |
+ {{.ServiceName}} is currently unavailable.
+
+ The service is scaling up to handle your request.
+ Please retry in {{.RefreshInterval}} seconds.
+
+ Namespace: {{.Namespace}}
+ Timestamp: {{.Timestamp}}
diff --git a/examples/vX.X.X/httpscaledobject-xml.yaml b/examples/vX.X.X/httpscaledobject-xml.yaml
new file mode 100644
index 000000000..d237ae672
--- /dev/null
+++ b/examples/vX.X.X/httpscaledobject-xml.yaml
@@ -0,0 +1,36 @@
+# Example: XML placeholder for legacy systems
+# Use case: SOAP services or XML-based APIs
+kind: HTTPScaledObject
+apiVersion: http.keda.sh/v1alpha1
+metadata:
+ name: legacy-service
+spec:
+ hosts:
+ - legacy.example.com
+ scaleTargetRef:
+ name: legacy-backend
+ kind: Deployment
+ apiVersion: apps/v1
+ service: legacy-backend
+ port: 8080
+ replicas:
+ min: 0
+ max: 5
+ scalingMetric:
+ concurrency:
+ targetValue: 50
+ placeholderConfig:
+ enabled: true
+ refreshInterval: 5
+ statusCode: 503
+ headers:
+ Content-Type: "application/xml; charset=utf-8"
+ content: |
+
+
+ unavailable
+ {{.ServiceName}}
+ {{.Namespace}}
+ Service is scaling up
+ {{.RefreshInterval}}
+
diff --git a/examples/vX.X.X/httpscaledobject.yaml b/examples/vX.X.X/httpscaledobject.yaml
new file mode 100644
index 000000000..0b33defab
--- /dev/null
+++ b/examples/vX.X.X/httpscaledobject.yaml
@@ -0,0 +1,44 @@
+kind: HTTPScaledObject
+apiVersion: http.keda.sh/v1alpha1
+metadata:
+ name: xkcd
+spec:
+ hosts:
+ - myhost.com
+ pathPrefixes:
+ - /test
+ scaleTargetRef:
+ name: xkcd
+ kind: Deployment
+ apiVersion: apps/v1
+ service: xkcd
+ port: 8080
+ replicas:
+ min: 1
+ max: 10
+ scaledownPeriod: 300
+ scalingMetric:
+ requestRate:
+ granularity: 1s
+ targetValue: 100
+ window: 1m
+ placeholderConfig:
+ enabled: true
+ refreshInterval: 5
+ statusCode: 503
+ headers:
+ # IMPORTANT: Always set Content-Type to match your content format
+ Content-Type: "text/html; charset=utf-8"
+ X-Service-Status: "warming-up"
+ content: |
+
+
+
+ Service Starting
+
+
+
+ {{.ServiceName}} is starting...
+
+
+ # Note: See examples/vX.X.X/httpscaledobject-*.yaml for JSON/XML/plain text examples
\ No newline at end of file
diff --git a/interceptor/config/serving.go b/interceptor/config/serving.go
index 3dfb0c540..27ea1a3be 100644
--- a/interceptor/config/serving.go
+++ b/interceptor/config/serving.go
@@ -49,6 +49,9 @@ type Serving struct {
TLSPort int `envconfig:"KEDA_HTTP_PROXY_TLS_PORT" default:"8443"`
// ProfilingAddr if not empty, pprof will be available on this address, assuming host:port here
ProfilingAddr string `envconfig:"PROFILING_BIND_ADDRESS" default:""`
+ // PlaceholderDefaultTemplatePath is the optional path to the default placeholder template file
+ // If not set, placeholder pages must provide inline content via HTTPScaledObject spec
+ PlaceholderDefaultTemplatePath string `envconfig:"KEDA_HTTP_PLACEHOLDER_DEFAULT_TEMPLATE_PATH" default:""`
}
// Parse parses standard configs using envconfig and returns a pointer to the
diff --git a/interceptor/handler/placeholder.go b/interceptor/handler/placeholder.go
new file mode 100644
index 000000000..33f8c74e9
--- /dev/null
+++ b/interceptor/handler/placeholder.go
@@ -0,0 +1,161 @@
+package handler
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/kedacore/http-add-on/interceptor/config"
+ "github.com/kedacore/http-add-on/operator/apis/http/v1alpha1"
+)
+
+const (
+ headerPlaceholderServed = "X-KEDA-HTTP-Placeholder-Served"
+ headerCacheControl = "Cache-Control"
+ headerContentType = "Content-Type"
+ cacheControlValue = "no-cache, no-store, must-revalidate"
+ fallbackContentType = "text/plain; charset=utf-8"
+ fallbackMessageFormat = "%s is starting up...\n"
+)
+
+type cacheEntry struct {
+ template *template.Template
+ hsoGeneration int64
+}
+
+type PlaceholderHandler struct {
+ templateCache map[string]*cacheEntry
+ cacheMutex sync.RWMutex
+ defaultTmpl *template.Template
+ servingCfg *config.Serving
+}
+
+type PlaceholderData struct {
+ ServiceName string
+ Namespace string
+ RefreshInterval int32
+ RequestID string
+ Timestamp string
+}
+
+func NewPlaceholderHandler(servingCfg *config.Serving) (*PlaceholderHandler, error) {
+ var defaultTmpl *template.Template
+
+ if servingCfg.PlaceholderDefaultTemplatePath != "" {
+ content, err := os.ReadFile(servingCfg.PlaceholderDefaultTemplatePath)
+ if err != nil {
+ fmt.Printf("Warning: Could not read placeholder template from %s: %v. No default template will be used.\n",
+ servingCfg.PlaceholderDefaultTemplatePath, err)
+ } else {
+ defaultTmpl, err = template.New("default").Parse(string(content))
+ if err != nil {
+ fmt.Printf("Warning: Could not parse placeholder template from %s: %v. No default template will be used.\n",
+ servingCfg.PlaceholderDefaultTemplatePath, err)
+ defaultTmpl = nil
+ }
+ }
+ }
+
+ return &PlaceholderHandler{
+ templateCache: make(map[string]*cacheEntry),
+ defaultTmpl: defaultTmpl,
+ servingCfg: servingCfg,
+ }, nil
+}
+
+func (h *PlaceholderHandler) ServePlaceholder(w http.ResponseWriter, r *http.Request, hso *v1alpha1.HTTPScaledObject) error {
+ if hso.Spec.PlaceholderConfig == nil || !hso.Spec.PlaceholderConfig.Enabled {
+ http.Error(w, "Service temporarily unavailable", http.StatusServiceUnavailable)
+ return nil
+ }
+
+ config := hso.Spec.PlaceholderConfig
+
+ statusCode := int(config.StatusCode)
+ if statusCode == 0 {
+ statusCode = http.StatusServiceUnavailable
+ }
+
+ for k, v := range config.Headers {
+ w.Header().Set(k, v)
+ }
+
+ tmpl, err := h.resolveTemplate(r.Context(), hso)
+ if err != nil {
+ return h.serveFallbackPlaceholder(w, hso.Spec.ScaleTargetRef.Service, statusCode)
+ }
+
+ data := PlaceholderData{
+ ServiceName: hso.Spec.ScaleTargetRef.Service,
+ Namespace: hso.Namespace,
+ RefreshInterval: config.RefreshInterval,
+ RequestID: r.Header.Get("X-Request-ID"),
+ Timestamp: time.Now().Format(time.RFC3339),
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return h.serveFallbackPlaceholder(w, hso.Spec.ScaleTargetRef.Service, statusCode)
+ }
+
+ w.Header().Set(headerPlaceholderServed, "true")
+ w.Header().Set(headerCacheControl, cacheControlValue)
+ w.WriteHeader(statusCode)
+ _, err = w.Write(buf.Bytes())
+ return err
+}
+
+func (h *PlaceholderHandler) serveFallbackPlaceholder(w http.ResponseWriter, serviceName string, statusCode int) error {
+ w.Header().Set(headerContentType, fallbackContentType)
+ w.Header().Set(headerPlaceholderServed, "true")
+ w.Header().Set(headerCacheControl, cacheControlValue)
+ w.WriteHeader(statusCode)
+ _, err := fmt.Fprintf(w, fallbackMessageFormat, serviceName)
+ return err
+}
+
+func (h *PlaceholderHandler) resolveTemplate(_ context.Context, hso *v1alpha1.HTTPScaledObject) (*template.Template, error) {
+ config := hso.Spec.PlaceholderConfig
+
+ if config.Content != "" {
+ return h.getCachedInlineTemplate(hso, config.Content)
+ }
+
+ if h.defaultTmpl != nil {
+ return h.defaultTmpl, nil
+ }
+
+ return nil, fmt.Errorf("no placeholder template configured")
+}
+
+func (h *PlaceholderHandler) getCachedInlineTemplate(hso *v1alpha1.HTTPScaledObject, content string) (*template.Template, error) {
+ cacheKey := fmt.Sprintf("%s/%s/inline", hso.Namespace, hso.Name)
+
+ h.cacheMutex.RLock()
+ entry, ok := h.templateCache[cacheKey]
+ if ok && entry.hsoGeneration == hso.Generation {
+ h.cacheMutex.RUnlock()
+ return entry.template, nil
+ }
+ h.cacheMutex.RUnlock()
+
+ h.cacheMutex.Lock()
+ defer h.cacheMutex.Unlock()
+
+ tmpl, err := template.New("inline").Parse(content)
+ if err != nil {
+ return nil, err
+ }
+
+ h.templateCache[cacheKey] = &cacheEntry{
+ template: tmpl,
+ hsoGeneration: hso.Generation,
+ }
+
+ return tmpl, nil
+}
diff --git a/interceptor/handler/placeholder_test.go b/interceptor/handler/placeholder_test.go
new file mode 100644
index 000000000..3451a6a59
--- /dev/null
+++ b/interceptor/handler/placeholder_test.go
@@ -0,0 +1,770 @@
+package handler
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/kedacore/http-add-on/interceptor/config"
+ "github.com/kedacore/http-add-on/operator/apis/http/v1alpha1"
+)
+
+const testCustomContent = `{{.ServiceName}}`
+
+func TestNewPlaceholderHandler(t *testing.T) {
+ servingCfg := &config.Serving{}
+
+ handler, err := NewPlaceholderHandler(servingCfg)
+ assert.NoError(t, err)
+ assert.NotNil(t, handler)
+ assert.NotNil(t, handler.servingCfg)
+ assert.NotNil(t, handler.templateCache)
+}
+
+func TestServePlaceholder_DisabledConfig(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ // Create HTTPScaledObject with disabled placeholder
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "test-service",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: false,
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://example.com", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
+ assert.Contains(t, w.Body.String(), "Service temporarily unavailable")
+}
+
+func TestServePlaceholder_DefaultTemplate(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ // Create HTTPScaledObject with enabled placeholder
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "test-service",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 503,
+ RefreshInterval: 5,
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://example.com", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
+ assert.Contains(t, w.Body.String(), "test-service is starting up...")
+ assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type"))
+ assert.Equal(t, "true", w.Header().Get("X-KEDA-HTTP-Placeholder-Served"))
+}
+
+func TestServePlaceholder_InlineContent(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ customContent := `Custom placeholder for {{.ServiceName}}
`
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "test-service",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 202,
+ RefreshInterval: 3,
+ Content: customContent,
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://example.com", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusAccepted, w.Code)
+ assert.Contains(t, w.Body.String(), "Custom placeholder for test-service")
+}
+
+func TestServePlaceholder_NonHTMLContent(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ // Test with JSON content
+ jsonContent := `{"status": "starting", "service": "{{.ServiceName}}"}`
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "test-service",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 503,
+ RefreshInterval: 5,
+ Content: jsonContent,
+ Headers: map[string]string{
+ "Content-Type": "application/json",
+ },
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://example.com", nil)
+ req.Header.Set("Accept", "application/json")
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
+ assert.Contains(t, w.Body.String(), `"service": "test-service"`)
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+}
+
+func TestServePlaceholder_CustomHeaders(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "test-service",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 503,
+ RefreshInterval: 5,
+ Headers: map[string]string{
+ "X-Custom-Header": "custom-value",
+ "X-Service-Name": "test-service",
+ },
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://example.com", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, "custom-value", w.Header().Get("X-Custom-Header"))
+ assert.Equal(t, "test-service", w.Header().Get("X-Service-Name"))
+}
+
+func TestServePlaceholder_InvalidTemplate(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ // Invalid template syntax
+ invalidContent := `{{.UnknownField`
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "test-service",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 503,
+ RefreshInterval: 5,
+ Content: invalidContent,
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://example.com", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err) // Should not return error, but fall back
+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
+ // Should fall back to simple response
+ assert.Contains(t, w.Body.String(), "test-service is starting up...")
+}
+
+func TestGetTemplate_Caching(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Content: testCustomContent,
+ },
+ },
+ }
+
+ ctx := context.Background()
+
+ tmpl1, err := handler.resolveTemplate(ctx, hso)
+ require.NoError(t, err)
+ assert.NotNil(t, tmpl1)
+
+ tmpl2, err := handler.resolveTemplate(ctx, hso)
+ require.NoError(t, err)
+ assert.NotNil(t, tmpl2)
+
+ assert.Equal(t, fmt.Sprintf("%p", tmpl1), fmt.Sprintf("%p", tmpl2))
+}
+
+func TestGetTemplate_CacheInvalidation_Generation(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ customContent1 := `Version 1: {{.ServiceName}}`
+ customContent2 := `Version 2: {{.ServiceName}}`
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Content: customContent1,
+ },
+ },
+ }
+
+ ctx := context.Background()
+
+ tmpl1, err := handler.resolveTemplate(ctx, hso)
+ require.NoError(t, err)
+ assert.NotNil(t, tmpl1)
+
+ hso.Generation = 2
+ hso.Spec.PlaceholderConfig.Content = customContent2
+
+ tmpl2, err := handler.resolveTemplate(ctx, hso)
+ require.NoError(t, err)
+ assert.NotNil(t, tmpl2)
+
+ assert.NotEqual(t, fmt.Sprintf("%p", tmpl1), fmt.Sprintf("%p", tmpl2))
+}
+
+func TestGetTemplate_CacheInvalidation_ConfigMapVersion_REMOVED(t *testing.T) {
+ t.Skip("ConfigMap support removed per maintainer feedback")
+ // The code below is kept for reference but won't be executed
+ /*
+ cm := &v1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "placeholder-cm",
+ Namespace: "default",
+ ResourceVersion: "1",
+ },
+ Data: map[string]string{
+ "template.html": `Version 1: {{.ServiceName}}`,
+ },
+ }
+ k8sClient := fake.NewSimpleClientset(cm)
+ routingTable := test.NewTable()
+ handler, _ := NewPlaceholderHandler(k8sClient, routingTable)
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ ContentConfigMap: "placeholder-cm",
+ },
+ },
+ }
+
+ ctx := context.Background()
+
+ tmpl1, err := handler.resolveTemplate(ctx, hso)
+ require.NoError(t, err)
+ assert.NotNil(t, tmpl1)
+
+ cm.ResourceVersion = "2"
+ cm.Data["template.html"] = `Version 2: {{.ServiceName}}`
+ _, err = k8sClient.CoreV1().ConfigMaps("default").Update(ctx, cm, metav1.UpdateOptions{})
+ require.NoError(t, err)
+
+ tmpl2, err := handler.resolveTemplate(ctx, hso)
+ require.NoError(t, err)
+ assert.NotNil(t, tmpl2)
+ */
+}
+
+func TestGetTemplate_ConcurrentAccess(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Content: testCustomContent,
+ },
+ },
+ }
+
+ ctx := context.Background()
+
+ _, err := handler.resolveTemplate(ctx, hso)
+ require.NoError(t, err)
+
+ var wg sync.WaitGroup
+ errors := make(chan error, 100)
+ templates := make(chan interface{}, 100)
+
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ tmpl, err := handler.resolveTemplate(ctx, hso)
+ if err != nil {
+ errors <- err
+ } else {
+ templates <- tmpl
+ }
+ }()
+ }
+
+ wg.Wait()
+ close(errors)
+ close(templates)
+
+ var errorCount int
+ for err := range errors {
+ t.Errorf("Concurrent access error: %v", err)
+ errorCount++
+ }
+ assert.Equal(t, 0, errorCount)
+
+ var firstTemplate interface{}
+ templateCount := 0
+ for tmpl := range templates {
+ templateCount++
+ if firstTemplate == nil {
+ firstTemplate = tmpl
+ } else {
+ assert.Equal(t, fmt.Sprintf("%p", firstTemplate), fmt.Sprintf("%p", tmpl),
+ "All templates should be the same cached instance")
+ }
+ }
+ assert.Equal(t, 100, templateCount)
+}
+
+func TestGetTemplate_ConcurrentFirstAccess(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-app",
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Content: testCustomContent,
+ },
+ },
+ }
+
+ ctx := context.Background()
+
+ var wg sync.WaitGroup
+ errors := make(chan error, 100)
+
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := handler.resolveTemplate(ctx, hso)
+ if err != nil {
+ errors <- err
+ }
+ }()
+ }
+
+ wg.Wait()
+ close(errors)
+
+ for err := range errors {
+ t.Errorf("Concurrent access error: %v", err)
+ }
+
+ handler.cacheMutex.RLock()
+ cacheKey := fmt.Sprintf("%s/%s/inline", hso.Namespace, hso.Name)
+ entry, ok := handler.templateCache[cacheKey]
+ handler.cacheMutex.RUnlock()
+
+ assert.True(t, ok, "Cache should have an entry")
+ assert.NotNil(t, entry, "Cache entry should not be nil")
+ assert.NotNil(t, entry.template, "Cached template should not be nil")
+}
+
+func TestGetTemplate_ConcurrentCacheUpdates(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ ctx := context.Background()
+
+ var wg sync.WaitGroup
+ errors := make(chan error, 100)
+
+ for i := 0; i < 10; i++ {
+ for j := 0; j < 10; j++ {
+ wg.Add(1)
+ go func(cmIndex, iteration int) {
+ defer wg.Done()
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: fmt.Sprintf("test-app-%d", cmIndex),
+ Namespace: "default",
+ Generation: int64(iteration),
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Content: fmt.Sprintf(`Template %d-%d: {{.ServiceName}}`, cmIndex, iteration),
+ },
+ },
+ }
+
+ _, err := handler.resolveTemplate(ctx, hso)
+ if err != nil {
+ errors <- err
+ }
+ }(i, j)
+ }
+ }
+
+ wg.Wait()
+ close(errors)
+
+ for err := range errors {
+ t.Errorf("Concurrent cache update error: %v", err)
+ }
+}
+
+// Content-Agnostic Tests - Verify the feature works with any content format
+
+func TestServePlaceholder_JSONResponse(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ jsonContent := `{
+ "status": "warming_up",
+ "service": "{{.ServiceName}}",
+ "namespace": "{{.Namespace}}",
+ "retry_after_seconds": {{.RefreshInterval}},
+ "timestamp": "{{.Timestamp}}"
+}`
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "api-service",
+ Namespace: "production",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "api-backend",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 202,
+ RefreshInterval: 10,
+ Content: jsonContent,
+ Headers: map[string]string{
+ "Content-Type": "application/json",
+ "Retry-After": "10",
+ },
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://api.example.com/users", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusAccepted, w.Code)
+ assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
+ assert.Equal(t, "10", w.Header().Get("Retry-After"))
+
+ body := w.Body.String()
+ assert.Contains(t, body, `"service": "api-backend"`)
+ assert.Contains(t, body, `"namespace": "production"`)
+ assert.Contains(t, body, `"retry_after_seconds": 10`)
+ assert.Contains(t, body, `"status": "warming_up"`)
+}
+
+func TestServePlaceholder_XMLResponse(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ xmlContent := `
+
+ unavailable
+ {{.ServiceName}}
+ {{.Namespace}}
+ Service is scaling up
+ {{.RefreshInterval}}
+`
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "legacy-service",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "legacy-backend",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 503,
+ RefreshInterval: 5,
+ Content: xmlContent,
+ Headers: map[string]string{
+ "Content-Type": "application/xml; charset=utf-8",
+ },
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://legacy.example.com", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
+ assert.Equal(t, "application/xml; charset=utf-8", w.Header().Get("Content-Type"))
+
+ body := w.Body.String()
+ // Note: html/template escapes XML, so < becomes <
+ assert.Contains(t, body, "legacy-backend")
+ assert.Contains(t, body, "default")
+ assert.Contains(t, body, "5")
+ assert.Contains(t, body, "xml version")
+}
+
+func TestServePlaceholder_PlainTextResponse(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ textContent := `{{.ServiceName}} is currently unavailable.
+
+The service is scaling up to handle your request.
+Please retry in {{.RefreshInterval}} seconds.
+
+Namespace: {{.Namespace}}
+Request ID: {{.RequestID}}`
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "simple-service",
+ Namespace: "apps",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "simple-backend",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 503,
+ RefreshInterval: 3,
+ Content: textContent,
+ Headers: map[string]string{
+ "Content-Type": "text/plain; charset=utf-8",
+ },
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://simple.example.com", nil)
+ req.Header.Set("X-Request-ID", "abc-123-xyz")
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
+ assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type"))
+
+ body := w.Body.String()
+ assert.Contains(t, body, "simple-backend is currently unavailable")
+ assert.Contains(t, body, "Please retry in 3 seconds")
+ assert.Contains(t, body, "Namespace: apps")
+ assert.Contains(t, body, "Request ID: abc-123-xyz")
+}
+
+func TestServePlaceholder_HTMLWithUserControlledRefresh(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ htmlContent := `
+
+
+
+
+ {{.ServiceName}} - Starting Up
+
+
+ {{.ServiceName}} is starting...
+ The service will be ready soon. This page will refresh automatically.
+ Namespace: {{.Namespace}}
+
+`
+
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "web-app",
+ Namespace: "frontend",
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "web-frontend",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ StatusCode: 503,
+ RefreshInterval: 5,
+ Content: htmlContent,
+ Headers: map[string]string{
+ "Content-Type": "text/html; charset=utf-8",
+ },
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://webapp.example.com", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
+ assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type"))
+
+ body := w.Body.String()
+ assert.Contains(t, body, "")
+ assert.Contains(t, body, ``)
+ assert.Contains(t, body, "web-frontend is starting...
")
+ assert.Contains(t, body, "Namespace: frontend")
+ // Verify no automatic script injection
+ assert.NotContains(t, body, "checkServiceStatus")
+}
+
+func TestServePlaceholder_ContentTypeUserControl(t *testing.T) {
+ servingCfg := &config.Serving{}
+ handler, _ := NewPlaceholderHandler(servingCfg)
+
+ // Test that user-provided Content-Type is respected
+ // Note: Currently using html/template which auto-escapes, so XML/HTML chars become entities
+ testCases := []struct {
+ name string
+ content string
+ contentType string
+ expectedContent string
+ }{
+ {
+ name: "application/json",
+ content: `{"service": "{{.ServiceName}}"}`,
+ contentType: "application/json",
+ expectedContent: `test`,
+ },
+ {
+ name: "text/plain",
+ content: `Service: {{.ServiceName}}`,
+ contentType: "text/plain",
+ expectedContent: `Service: test`,
+ },
+ }
+
+ for i, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ hso := &v1alpha1.HTTPScaledObject{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: fmt.Sprintf("test-app-%d", i),
+ Namespace: "default",
+ Generation: int64(i + 1),
+ },
+ Spec: v1alpha1.HTTPScaledObjectSpec{
+ ScaleTargetRef: v1alpha1.ScaleTargetRef{
+ Service: "test",
+ },
+ PlaceholderConfig: &v1alpha1.PlaceholderConfig{
+ Enabled: true,
+ Content: tc.content,
+ Headers: map[string]string{
+ "Content-Type": tc.contentType,
+ },
+ },
+ },
+ }
+
+ req := httptest.NewRequest("GET", "http://example.com", nil)
+ w := httptest.NewRecorder()
+
+ err := handler.ServePlaceholder(w, req, hso)
+ assert.NoError(t, err)
+ assert.Equal(t, tc.contentType, w.Header().Get("Content-Type"))
+ assert.Contains(t, w.Body.String(), tc.expectedContent)
+ })
+ }
+}
diff --git a/interceptor/main.go b/interceptor/main.go
index 238f50ed7..9805c8809 100644
--- a/interceptor/main.go
+++ b/interceptor/main.go
@@ -198,7 +198,7 @@ func main() {
setupLog.Info("starting the proxy server with TLS enabled", "port", proxyTLSPort)
- if err := runProxyServer(ctx, ctrl.Log, queues, waitFunc, routingTable, svcCache, timeoutCfg, proxyTLSPort, proxyTLSEnabled, proxyTLSConfig, tracingCfg); !util.IsIgnoredErr(err) {
+ if err := runProxyServer(ctx, ctrl.Log, queues, waitFunc, routingTable, svcCache, timeoutCfg, servingCfg, proxyTLSPort, proxyTLSEnabled, proxyTLSConfig, tracingCfg, cl, endpointsCache); !util.IsIgnoredErr(err) {
setupLog.Error(err, "tls proxy server failed")
return err
}
@@ -212,7 +212,7 @@ func main() {
setupLog.Info("starting the proxy server with TLS disabled", "port", proxyPort)
k8sSharedInformerFactory.WaitForCacheSync(ctx.Done())
- if err := runProxyServer(ctx, ctrl.Log, queues, waitFunc, routingTable, svcCache, timeoutCfg, proxyPort, false, nil, tracingCfg); !util.IsIgnoredErr(err) {
+ if err := runProxyServer(ctx, ctrl.Log, queues, waitFunc, routingTable, svcCache, timeoutCfg, servingCfg, proxyPort, false, nil, tracingCfg, cl, endpointsCache); !util.IsIgnoredErr(err) {
setupLog.Error(err, "proxy server failed")
return err
}
@@ -405,10 +405,13 @@ func runProxyServer(
routingTable routing.Table,
svcCache k8s.ServiceCache,
timeouts *config.Timeouts,
+ servingCfg *config.Serving,
port int,
tlsEnabled bool,
tlsConfig map[string]interface{},
tracingConfig *config.Tracing,
+ k8sClient kubernetes.Interface,
+ endpointsCache k8s.EndpointsCache,
) error {
dialer := kedanet.NewNetDialer(timeouts.Connect, timeouts.KeepAlive)
dialContextFunc := kedanet.DialContextWithRetry(dialer, timeouts.DefaultBackoff())
@@ -436,6 +439,12 @@ func runProxyServer(
forwardingTLSCfg.InsecureSkipVerify = tlsCfg.InsecureSkipVerify
}
+ // Create placeholder handler
+ placeholderHandler, err := handler.NewPlaceholderHandler(servingCfg)
+ if err != nil {
+ return fmt.Errorf("creating placeholder handler: %w", err)
+ }
+
upstreamHandler = newForwardingHandler(
logger,
dialContextFunc,
@@ -443,6 +452,8 @@ func runProxyServer(
newForwardingConfigFromTimeouts(timeouts),
forwardingTLSCfg,
tracingConfig,
+ placeholderHandler,
+ endpointsCache,
)
upstreamHandler = middleware.NewCountingMiddleware(
q,
diff --git a/interceptor/main_test.go b/interceptor/main_test.go
index adefff29a..95646ac66 100644
--- a/interceptor/main_test.go
+++ b/interceptor/main_test.go
@@ -81,6 +81,7 @@ func TestRunProxyServerCountMiddleware(t *testing.T) {
fmt.Println(err, "Error setting up tracer")
}
+ servingCfg := &config.Serving{}
g.Go(func() error {
return runProxyServer(
ctx,
@@ -90,10 +91,13 @@ func TestRunProxyServerCountMiddleware(t *testing.T) {
routingTable,
svcCache,
timeouts,
+ servingCfg,
port,
false,
map[string]interface{}{},
&tracingCfg,
+ nil,
+ nil,
)
})
// wait for server to start
@@ -220,6 +224,7 @@ func TestRunProxyServerWithTLSCountMiddleware(t *testing.T) {
return false, nil
}
tracingCfg := config.Tracing{Enabled: true, Exporter: "otlphttp"}
+ servingCfg := &config.Serving{}
g.Go(func() error {
return runProxyServer(
@@ -230,10 +235,13 @@ func TestRunProxyServerWithTLSCountMiddleware(t *testing.T) {
routingTable,
svcCache,
timeouts,
+ servingCfg,
port,
true,
map[string]interface{}{"certificatePath": "../certs/tls.crt", "keyPath": "../certs/tls.key", "skipVerify": true},
&tracingCfg,
+ nil,
+ nil,
)
})
@@ -370,6 +378,7 @@ func TestRunProxyServerWithMultipleCertsTLSCountMiddleware(t *testing.T) {
}
tracingCfg := config.Tracing{Enabled: true, Exporter: "otlphttp"}
+ servingCfg := &config.Serving{}
g.Go(func() error {
return runProxyServer(
@@ -380,10 +389,13 @@ func TestRunProxyServerWithMultipleCertsTLSCountMiddleware(t *testing.T) {
routingTable,
svcCache,
timeouts,
+ servingCfg,
port,
true,
map[string]interface{}{"certstorePaths": "../certs"},
&tracingCfg,
+ nil,
+ nil,
)
})
diff --git a/interceptor/proxy_handlers.go b/interceptor/proxy_handlers.go
index fe679cd82..146fccdae 100644
--- a/interceptor/proxy_handlers.go
+++ b/interceptor/proxy_handlers.go
@@ -13,6 +13,8 @@ import (
"github.com/kedacore/http-add-on/interceptor/config"
"github.com/kedacore/http-add-on/interceptor/handler"
+ "github.com/kedacore/http-add-on/operator/apis/http/v1alpha1"
+ "github.com/kedacore/http-add-on/pkg/k8s"
kedanet "github.com/kedacore/http-add-on/pkg/net"
"github.com/kedacore/http-add-on/pkg/util"
)
@@ -52,6 +54,8 @@ func newForwardingHandler(
fwdCfg forwardingConfig,
tlsCfg *tls.Config,
tracingCfg *config.Tracing,
+ placeholderHandler *handler.PlaceholderHandler,
+ endpointsCache k8s.EndpointsCache,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var uh *handler.Upstream
@@ -59,6 +63,12 @@ func newForwardingHandler(
httpso := util.HTTPSOFromContext(ctx)
hasFailover := httpso.Spec.ColdStartTimeoutFailoverRef != nil
+ if shouldServePlaceholder(httpso, placeholderHandler) {
+ if err := servePlaceholderIfNoEndpoints(lggr, w, r, httpso, placeholderHandler, endpointsCache); err != nil {
+ return
+ }
+ }
+
conditionWaitTimeout := fwdCfg.waitTimeout
roundTripper := &http.Transport{
Proxy: http.ProxyFromEnvironment,
@@ -114,3 +124,43 @@ func newForwardingHandler(
uh.ServeHTTP(w, r)
})
}
+
+func shouldServePlaceholder(httpso *v1alpha1.HTTPScaledObject, placeholderHandler *handler.PlaceholderHandler) bool {
+ return httpso.Spec.PlaceholderConfig != nil &&
+ httpso.Spec.PlaceholderConfig.Enabled &&
+ placeholderHandler != nil
+}
+
+func servePlaceholderIfNoEndpoints(
+ lggr logr.Logger,
+ w http.ResponseWriter,
+ r *http.Request,
+ httpso *v1alpha1.HTTPScaledObject,
+ placeholderHandler *handler.PlaceholderHandler,
+ endpointsCache k8s.EndpointsCache,
+) error {
+ endpoints, err := endpointsCache.Get(httpso.GetNamespace(), httpso.Spec.ScaleTargetRef.Service)
+ if err != nil {
+ lggr.Error(err, "failed to get endpoints from cache while placeholder is configured",
+ "namespace", httpso.GetNamespace(),
+ "service", httpso.Spec.ScaleTargetRef.Service)
+ w.WriteHeader(http.StatusServiceUnavailable)
+ if _, writeErr := w.Write([]byte("Service temporarily unavailable - unable to check service status")); writeErr != nil {
+ lggr.Error(writeErr, "could not write error response to client")
+ }
+ return err
+ }
+
+ if workloadActiveEndpoints(endpoints) == 0 {
+ if placeholderErr := placeholderHandler.ServePlaceholder(w, r, httpso); placeholderErr != nil {
+ lggr.Error(placeholderErr, "failed to serve placeholder page")
+ w.WriteHeader(http.StatusBadGateway)
+ if _, err := w.Write([]byte("error serving placeholder page")); err != nil {
+ lggr.Error(err, "could not write error response to client")
+ }
+ }
+ return fmt.Errorf("placeholder served")
+ }
+
+ return nil
+}
diff --git a/interceptor/proxy_handlers_integration_test.go b/interceptor/proxy_handlers_integration_test.go
index 77f0df016..1bd67ff45 100644
--- a/interceptor/proxy_handlers_integration_test.go
+++ b/interceptor/proxy_handlers_integration_test.go
@@ -319,7 +319,9 @@ func newHarness(
respHeaderTimeout: time.Second,
},
&tls.Config{},
- &config.Tracing{}),
+ &config.Tracing{},
+ nil,
+ nil),
svcCache,
false,
)
diff --git a/interceptor/proxy_handlers_test.go b/interceptor/proxy_handlers_test.go
index 1343730e1..443d411c0 100644
--- a/interceptor/proxy_handlers_test.go
+++ b/interceptor/proxy_handlers_test.go
@@ -80,6 +80,8 @@ func TestImmediatelySuccessfulProxy(t *testing.T) {
},
&tls.Config{},
&config.Tracing{},
+ nil,
+ nil,
)
const path = "/testfwd"
res, req, err := reqAndRes(path)
@@ -132,6 +134,8 @@ func TestImmediatelySuccessfulProxyTLS(t *testing.T) {
},
&TestTLSConfig,
&config.Tracing{},
+ nil,
+ nil,
)
const path = "/testfwd"
res, req, err := reqAndRes(path)
@@ -188,6 +192,8 @@ func TestImmediatelySuccessfulFailoverProxy(t *testing.T) {
},
&tls.Config{},
&config.Tracing{},
+ nil, // placeholderHandler
+ nil, // endpointsCache
)
const path = "/testfwd"
res, req, err := reqAndRes(path)
@@ -248,6 +254,8 @@ func TestWaitFailedConnection(t *testing.T) {
},
&tls.Config{},
&config.Tracing{},
+ nil,
+ nil,
)
stream, err := url.Parse("http://0.0.0.0:0")
r.NoError(err)
@@ -299,6 +307,8 @@ func TestWaitFailedConnectionTLS(t *testing.T) {
},
&TestTLSConfig,
&config.Tracing{},
+ nil,
+ nil,
)
stream, err := url.Parse("http://0.0.0.0:0")
r.NoError(err)
@@ -351,6 +361,8 @@ func TestTimesOutOnWaitFunc(t *testing.T) {
},
&tls.Config{},
&config.Tracing{},
+ nil,
+ nil,
)
stream, err := url.Parse("http://1.1.1.1")
r.NoError(err)
@@ -424,6 +436,8 @@ func TestTimesOutOnWaitFuncTLS(t *testing.T) {
},
&TestTLSConfig,
&config.Tracing{},
+ nil,
+ nil,
)
stream, err := url.Parse("http://1.1.1.1")
r.NoError(err)
@@ -508,6 +522,8 @@ func TestWaitsForWaitFunc(t *testing.T) {
},
&tls.Config{},
&config.Tracing{},
+ nil,
+ nil,
)
const path = "/testfwd"
res, req, err := reqAndRes(path)
@@ -575,6 +591,8 @@ func TestWaitsForWaitFuncTLS(t *testing.T) {
},
&TestTLSConfig,
&config.Tracing{},
+ nil,
+ nil,
)
const path = "/testfwd"
res, req, err := reqAndRes(path)
@@ -646,6 +664,8 @@ func TestWaitHeaderTimeout(t *testing.T) {
},
&tls.Config{},
&config.Tracing{},
+ nil,
+ nil,
)
const path = "/testfwd"
res, req, err := reqAndRes(path)
@@ -705,6 +725,8 @@ func TestWaitHeaderTimeoutTLS(t *testing.T) {
},
&TestTLSConfig,
&config.Tracing{},
+ nil,
+ nil,
)
const path = "/testfwd"
res, req, err := reqAndRes(path)
diff --git a/operator/apis/http/v1alpha1/httpscaledobject_types.go b/operator/apis/http/v1alpha1/httpscaledobject_types.go
index 93cf0b0ec..ec5c51748 100644
--- a/operator/apis/http/v1alpha1/httpscaledobject_types.go
+++ b/operator/apis/http/v1alpha1/httpscaledobject_types.go
@@ -132,6 +132,28 @@ type HTTPScaledObjectTimeoutsSpec struct {
ResponseHeader metav1.Duration `json:"responseHeader" description:"How long to wait between when the HTTP request is sent to the backing app and when response headers need to arrive"`
}
+type PlaceholderConfig struct {
+ // +kubebuilder:default=false
+ // +optional
+ Enabled bool `json:"enabled" description:"Enable placeholder response when replicas are scaled to zero"`
+ // Supports Go template variables: ServiceName, Namespace, RefreshInterval, RequestID, Timestamp
+ // +optional
+ Content string `json:"content,omitempty" description:"Inline content for placeholder response (any format supported)"`
+ // +kubebuilder:default=503
+ // +kubebuilder:validation:Minimum=100
+ // +kubebuilder:validation:Maximum=599
+ // +optional
+ StatusCode int32 `json:"statusCode,omitempty" description:"HTTP status code to return with placeholder response (Default 503)"`
+ // Template variable only - not used by interceptor for automatic refresh
+ // +kubebuilder:default=5
+ // +kubebuilder:validation:Minimum=1
+ // +kubebuilder:validation:Maximum=60
+ // +optional
+ RefreshInterval int32 `json:"refreshInterval,omitempty" description:"Template variable for refresh interval in seconds (Default 5)"`
+ // +optional
+ Headers map[string]string `json:"headers,omitempty" description:"Additional HTTP headers to include with placeholder response"`
+}
+
// HTTPScaledObjectSpec defines the desired state of HTTPScaledObject
type HTTPScaledObjectSpec struct {
// The hosts to route. All requests which the "Host" header
@@ -171,6 +193,9 @@ type HTTPScaledObjectSpec struct {
// (optional) Timeouts that override the global ones
// +optional
Timeouts *HTTPScaledObjectTimeoutsSpec `json:"timeouts,omitempty" description:"Timeouts that override the global ones"`
+ // (optional) Configuration for placeholder pages during scale-from-zero
+ // +optional
+ PlaceholderConfig *PlaceholderConfig `json:"placeholderConfig,omitempty" description:"Configuration for placeholder pages during scale-from-zero"`
}
// HTTPScaledObjectStatus defines the observed state of HTTPScaledObject
diff --git a/operator/apis/http/v1alpha1/zz_generated.deepcopy.go b/operator/apis/http/v1alpha1/zz_generated.deepcopy.go
index dcc03f42c..d2ab8d03c 100644
--- a/operator/apis/http/v1alpha1/zz_generated.deepcopy.go
+++ b/operator/apis/http/v1alpha1/zz_generated.deepcopy.go
@@ -196,6 +196,11 @@ func (in *HTTPScaledObjectSpec) DeepCopyInto(out *HTTPScaledObjectSpec) {
*out = new(HTTPScaledObjectTimeoutsSpec)
**out = **in
}
+ if in.PlaceholderConfig != nil {
+ in, out := &in.PlaceholderConfig, &out.PlaceholderConfig
+ *out = new(PlaceholderConfig)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPScaledObjectSpec.
@@ -245,6 +250,28 @@ func (in *HTTPScaledObjectTimeoutsSpec) DeepCopy() *HTTPScaledObjectTimeoutsSpec
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PlaceholderConfig) DeepCopyInto(out *PlaceholderConfig) {
+ *out = *in
+ if in.Headers != nil {
+ in, out := &in.Headers, &out.Headers
+ *out = make(map[string]string, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlaceholderConfig.
+func (in *PlaceholderConfig) DeepCopy() *PlaceholderConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(PlaceholderConfig)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RateMetricSpec) DeepCopyInto(out *RateMetricSpec) {
*out = *in
diff --git a/tests/checks/placeholder_pages/placeholder_pages_test.go b/tests/checks/placeholder_pages/placeholder_pages_test.go
new file mode 100644
index 000000000..7dd12caae
--- /dev/null
+++ b/tests/checks/placeholder_pages/placeholder_pages_test.go
@@ -0,0 +1,353 @@
+//go:build e2e
+// +build e2e
+
+package placeholderpages_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "k8s.io/client-go/kubernetes"
+
+ . "github.com/kedacore/http-add-on/tests/helper"
+)
+
+const (
+ testName = "placeholder-test"
+ testNamespace = testName + "-ns"
+)
+
+type templateData struct {
+ TestNamespace string
+ TestName string
+}
+
+const testTemplate = `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{.TestName}}
+ namespace: {{.TestNamespace}}
+spec:
+ replicas: 0
+ selector:
+ matchLabels:
+ app: {{.TestName}}
+ template:
+ metadata:
+ labels:
+ app: {{.TestName}}
+ spec:
+ containers:
+ - name: {{.TestName}}
+ image: registry.k8s.io/e2e-test-images/agnhost:2.45
+ args: ["netexec"]
+ ports:
+ - containerPort: 8080
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{.TestName}}
+ namespace: {{.TestNamespace}}
+spec:
+ ports:
+ - port: 80
+ targetPort: 8080
+ selector:
+ app: {{.TestName}}
+---
+apiVersion: http.keda.sh/v1alpha1
+kind: HTTPScaledObject
+metadata:
+ name: {{.TestName}}
+ namespace: {{.TestNamespace}}
+spec:
+ hosts:
+ - {{.TestName}}.test
+ scaleTargetRef:
+ name: {{.TestName}}
+ service: {{.TestName}}
+ port: 80
+ replicas:
+ min: 0
+ max: 10
+ scaledownPeriod: 10
+ placeholderConfig:
+ enabled: true
+ statusCode: 503
+ refreshInterval: 5
+ headers:
+ Content-Type: "text/plain; charset=utf-8"
+ X-Test-Header: "test-value"
+ content: "{{ "{{" }} .ServiceName {{ "}}" }} is starting up..."
+`
+
+func TestPlaceholderPages(t *testing.T) {
+ // Test content-agnostic placeholder responses (HTML, JSON, plain text)
+ t.Log("--- setting up ---")
+ // Create kubernetes resources
+ kc := GetKubernetesClient(t)
+ data := templateData{
+ TestNamespace: testNamespace,
+ TestName: testName,
+ }
+
+ CreateNamespace(t, kc, testNamespace)
+ defer DeleteNamespace(t, testNamespace)
+
+ KubectlApplyWithTemplate(t, data, "placeholder-test", testTemplate)
+ defer KubectlDeleteWithTemplate(t, data, "placeholder-test", testTemplate)
+
+ // Create a test pod to make requests
+ clientPod := `
+apiVersion: v1
+kind: Pod
+metadata:
+ name: curl-client
+ namespace: ` + testNamespace + `
+spec:
+ containers:
+ - name: curl
+ image: curlimages/curl
+ command: ["sleep", "3600"]
+`
+ // Create the pod using KubectlApplyWithTemplate
+ KubectlApplyWithTemplate(t, data, "curl-client", clientPod)
+ defer KubectlDeleteWithTemplate(t, data, "curl-client", clientPod)
+
+ // Wait for curl pod to be ready
+ assert.True(t, WaitForPodCountInNamespace(t, kc, testNamespace, 1, 60, 2),
+ "curl client pod should exist")
+ assert.True(t, WaitForAllPodRunningInNamespace(t, kc, testNamespace, 60, 2),
+ "curl client pod should be running")
+
+ // Test placeholder response
+ testPlaceholderResponse(t, kc)
+
+ // Test HTML placeholder with user-controlled content
+ testHTMLPlaceholder(t, kc, data)
+
+ // Test JSON placeholder for API communication
+ testJSONPlaceholder(t, kc, data)
+
+ // Test plain text placeholder
+ testPlainTextPlaceholder(t, kc, data)
+}
+
+func testPlaceholderResponse(t *testing.T, kc *kubernetes.Clientset) {
+ t.Log("--- testing default placeholder response ---")
+
+ // Make request through interceptor
+ curlCmd := fmt.Sprintf("curl -si -H 'Host: %s.test' http://keda-add-ons-http-interceptor-proxy.keda:8080/", testName)
+ stdout, stderr, err := ExecCommandOnSpecificPod(t, "curl-client", testNamespace, curlCmd)
+ stdout = RemoveANSI(stdout)
+ t.Logf("curl output: %s", stdout)
+ if stderr != "" {
+ t.Logf("curl stderr: %s", stderr)
+ }
+
+ assert.NoError(t, err, "curl command should succeed")
+
+ // Verify placeholder response
+ assert.Contains(t, stdout, "HTTP/1.1 503", "should return 503 status")
+ assert.Contains(t, stdout, "X-Keda-Http-Placeholder-Served", "should have placeholder header")
+ assert.Contains(t, stdout, "X-Test-Header", "should have custom header")
+ assert.Contains(t, stdout, "test-value", "should have custom header value")
+ assert.Contains(t, stdout, "Content-Type: text/plain", "should have correct Content-Type")
+ assert.Contains(t, stdout, "is starting up", "should have placeholder message")
+
+ // Verify NO automatic script injection
+ assert.NotContains(t, stdout, "checkServiceStatus", "should NOT have automatic script injection")
+}
+
+func testHTMLPlaceholder(t *testing.T, kc *kubernetes.Clientset, data templateData) {
+ t.Log("--- testing HTML placeholder with user-controlled content ---")
+
+ htmlTemplate := `
+apiVersion: http.keda.sh/v1alpha1
+kind: HTTPScaledObject
+metadata:
+ name: {{.TestName}}-html
+ namespace: {{.TestNamespace}}
+spec:
+ hosts:
+ - {{.TestName}}-html.test
+ scaleTargetRef:
+ name: {{.TestName}}
+ service: {{.TestName}}
+ port: 80
+ replicas:
+ min: 0
+ max: 10
+ scaledownPeriod: 10
+ placeholderConfig:
+ enabled: true
+ statusCode: 503
+ refreshInterval: 5
+ headers:
+ Content-Type: "text/html; charset=utf-8"
+ content: |
+
+
+
+ Service Starting
+
+
+
+ {{ "{{" }} .ServiceName {{ "}}" }} is starting - custom HTML
+ This is a user-controlled HTML placeholder.
+
+
+`
+
+ KubectlApplyWithTemplate(t, data, "html-placeholder", htmlTemplate)
+ defer KubectlDeleteWithTemplate(t, data, "html-placeholder", htmlTemplate)
+
+ // Make request to HTML placeholder
+ curlCmd := fmt.Sprintf("curl -si -H 'Host: %s-html.test' http://keda-add-ons-http-interceptor-proxy.keda:8080/", testName)
+ stdout, stderr, err := ExecCommandOnSpecificPod(t, "curl-client", testNamespace, curlCmd)
+ stdout = RemoveANSI(stdout)
+ t.Logf("HTML placeholder output: %s", stdout)
+ if stderr != "" {
+ t.Logf("HTML placeholder stderr: %s", stderr)
+ }
+
+ assert.NoError(t, err, "curl command should succeed")
+
+ // Verify custom HTML content
+ assert.Contains(t, stdout, "HTTP/1.1 503", "should return 503 status")
+ assert.Contains(t, stdout, "Content-Type: text/html", "should have HTML Content-Type")
+ assert.Contains(t, stdout, "", "should have HTML doctype")
+ assert.Contains(t, stdout, "is starting - custom HTML", "should have custom HTML content")
+ assert.Contains(t, stdout, "user-controlled HTML placeholder", "should have custom message")
+ assert.Contains(t, stdout, ``, "should have user-controlled meta refresh")
+
+ // Verify NO automatic script injection
+ assert.NotContains(t, stdout, "checkServiceStatus", "should NOT have automatic script injection")
+}
+
+func testJSONPlaceholder(t *testing.T, kc *kubernetes.Clientset, data templateData) {
+ t.Log("--- testing JSON placeholder for API communication ---")
+
+ jsonTemplate := `
+apiVersion: http.keda.sh/v1alpha1
+kind: HTTPScaledObject
+metadata:
+ name: {{.TestName}}-json
+ namespace: {{.TestNamespace}}
+spec:
+ hosts:
+ - {{.TestName}}-json.test
+ scaleTargetRef:
+ name: {{.TestName}}
+ service: {{.TestName}}
+ port: 80
+ replicas:
+ min: 0
+ max: 10
+ scaledownPeriod: 10
+ placeholderConfig:
+ enabled: true
+ statusCode: 202
+ refreshInterval: 10
+ headers:
+ Content-Type: "application/json"
+ Retry-After: "10"
+ content: |
+ {
+ "status": "warming_up",
+ "service": "{{ "{{" }} .ServiceName {{ "}}" }}",
+ "namespace": "{{ "{{" }} .Namespace {{ "}}" }}",
+ "retry_after_seconds": {{ "{{" }} .RefreshInterval {{ "}}" }}
+ }
+`
+
+ KubectlApplyWithTemplate(t, data, "json-placeholder", jsonTemplate)
+ defer KubectlDeleteWithTemplate(t, data, "json-placeholder", jsonTemplate)
+
+ // Make request to JSON placeholder
+ curlCmd := fmt.Sprintf("curl -si -H 'Host: %s-json.test' http://keda-add-ons-http-interceptor-proxy.keda:8080/", testName)
+ stdout, stderr, err := ExecCommandOnSpecificPod(t, "curl-client", testNamespace, curlCmd)
+ stdout = RemoveANSI(stdout)
+ t.Logf("JSON placeholder output: %s", stdout)
+ if stderr != "" {
+ t.Logf("JSON placeholder stderr: %s", stderr)
+ }
+
+ assert.NoError(t, err, "curl command should succeed")
+
+ // Verify JSON response
+ assert.Contains(t, stdout, "HTTP/1.1 202", "should return 202 Accepted status")
+ assert.Contains(t, stdout, "Content-Type: application/json", "should have JSON Content-Type")
+ assert.Contains(t, stdout, "Retry-After: 10", "should have Retry-After header")
+ assert.Contains(t, stdout, `"status": "warming_up"`, "should have status field")
+ assert.Contains(t, stdout, `"service":`, "should have service field")
+ assert.Contains(t, stdout, `"retry_after_seconds": 10`, "should have retry_after_seconds field")
+
+ // Verify it's valid JSON structure (contains braces)
+ assert.Contains(t, stdout, "{", "should have JSON opening brace")
+ assert.Contains(t, stdout, "}", "should have JSON closing brace")
+}
+
+func testPlainTextPlaceholder(t *testing.T, kc *kubernetes.Clientset, data templateData) {
+ t.Log("--- testing plain text placeholder ---")
+
+ textTemplate := `
+apiVersion: http.keda.sh/v1alpha1
+kind: HTTPScaledObject
+metadata:
+ name: {{.TestName}}-text
+ namespace: {{.TestNamespace}}
+spec:
+ hosts:
+ - {{.TestName}}-text.test
+ scaleTargetRef:
+ name: {{.TestName}}
+ service: {{.TestName}}
+ port: 80
+ replicas:
+ min: 0
+ max: 10
+ scaledownPeriod: 10
+ placeholderConfig:
+ enabled: true
+ statusCode: 503
+ refreshInterval: 3
+ headers:
+ Content-Type: "text/plain; charset=utf-8"
+ content: |
+ {{ "{{" }} .ServiceName {{ "}}" }} is currently unavailable.
+
+ The service is scaling up to handle your request.
+ Please retry in {{ "{{" }} .RefreshInterval {{ "}}" }} seconds.
+
+ Namespace: {{ "{{" }} .Namespace {{ "}}" }}
+`
+
+ KubectlApplyWithTemplate(t, data, "text-placeholder", textTemplate)
+ defer KubectlDeleteWithTemplate(t, data, "text-placeholder", textTemplate)
+
+ // Make request to plain text placeholder
+ curlCmd := fmt.Sprintf("curl -si -H 'Host: %s-text.test' http://keda-add-ons-http-interceptor-proxy.keda:8080/", testName)
+ stdout, stderr, err := ExecCommandOnSpecificPod(t, "curl-client", testNamespace, curlCmd)
+ stdout = RemoveANSI(stdout)
+ t.Logf("Plain text placeholder output: %s", stdout)
+ if stderr != "" {
+ t.Logf("Plain text placeholder stderr: %s", stderr)
+ }
+
+ assert.NoError(t, err, "curl command should succeed")
+
+ // Verify plain text response
+ assert.Contains(t, stdout, "HTTP/1.1 503", "should return 503 status")
+ assert.Contains(t, stdout, "Content-Type: text/plain", "should have plain text Content-Type")
+ assert.Contains(t, stdout, "is currently unavailable", "should have unavailable message")
+ assert.Contains(t, stdout, "Please retry in 3 seconds", "should have retry message with interval")
+ assert.Contains(t, stdout, "Namespace:", "should have namespace in output")
+
+ // Verify it's plain text (no HTML tags)
+ assert.NotContains(t, stdout, "", "should NOT have HTML tags")
+ assert.NotContains(t, stdout, "", "should NOT have body tag")
+}