Skip to content

[Feature]: Date/Time template functions and expression-based activation (activateExpression) #31

Description

@selenehyun

Feature Description

Add date/time comparison template functions and a new activateExpression field on LynqHub that allows template-based activation logic per database row. This enables time-based expiration, scheduled activation, trial periods, and other temporal patterns — without requiring external cron jobs or database triggers.

Motivation

Today, the activate field is a simple truthy check on a single database column (isActive()). This is insufficient when activation depends on temporal logic or multi-column evaluation. Operators must currently maintain a separate cron job or DB trigger to flip a boolean column when expiration occurs, adding operational burden and sync latency. The proposed functions and expression field make temporal activation declarative and K8s-native.

Use Cases

1. Time-Based Expiration

Nodes automatically deactivate when expired_at datetime passes.

extraValueMappings:
  expiredAt: expired_at
activateExpression: '{{ and (isActive .activate) (timeAfterNow .expiredAt) }}'

2. Time Window (Active During Certain Period)

A node is active only between start_date and end_date.

extraValueMappings:
  startDate: start_date
  endDate: end_date
activateExpression: '{{ and (isActive .activate) (timeBeforeNow .startDate) (timeAfterNow .endDate) }}'

3. Trial / Subscription Management

A 14-day trial starting from created_at. No explicit expiration column exists.

extraValueMappings:
  createdAt: created_at
activateExpression: '{{ timeAfterNow (dateAdd .createdAt "336h") }}'

4. Grace Period After Deactivation

Even after activate=0, keep resources alive for 7 days based on deactivated_at.

extraValueMappings:
  deactivatedAt: deactivated_at
activateExpression: '{{ or (isActive .activate) (timeAfterNow (dateAdd .deactivatedAt "168h")) }}'

Proposed Design

Part 1: Date/Time Template Functions

New Custom Functions

Function Signature Description
parseTime parseTime(s string) string Auto-parse datetime string. Returns RFC3339 string. Returns "" on empty/invalid.
timeAfterNow timeAfterNow(s string) bool Is the parsed time after now? (= "has NOT yet passed") Returns false on empty/invalid.
timeBeforeNow timeBeforeNow(s string) bool Is the parsed time before now? (= "has already passed") Returns false on empty/invalid.
timeAfter timeAfter(a, b string) bool Is time A after time B? Both auto-parsed.
timeBefore timeBefore(a, b string) bool Is time A before time B? Both auto-parsed.
dateAdd dateAdd(s, duration string) string Add duration to parsed datetime. Returns RFC3339 string.
dateSub dateSub(s, duration string) string Subtract duration. Returns RFC3339 string.
isActive isActive(s string) bool Truthy check: "1", "true", "TRUE", "True", "yes", "YES", "Yes". Must match existing isActive() in datasource layer.

Auto-Parse Format Priority

parseTime and all time-accepting functions automatically detect:

  1. RFC 3339: 2006-01-02T15:04:05Z07:00
  2. MySQL datetime: 2006-01-02 15:04:05
  3. MySQL date only: 2006-01-02
  4. Unix timestamp (numeric string, value > 1,000,000,000): "1735689600"

Duration Formats

Go standard durations ("24h", "30m", "1h30m") plus:

  • "Nd" → N × 24h (e.g., "14d" = "336h")
  • "Nw" → N × 168h (e.g., "2w" = "336h")

No months or years (ambiguous lengths).

Edge Case Handling

Input Behavior
Empty string "" / NULL parseTime returns "". Comparisons return false.
Invalid format Same as empty.
Timezone UTC assumed unless offset specified in string.

Part 2: Expression-Based Activation (activateExpression)

New CRD Field

type LynqHubSpec struct {
    // ... existing fields ...

    // ActivateExpression is a Go template expression that determines whether
    // a row is active. Evaluated per row with all template variables available.
    // Must render to a truthy string for the row to be active.
    // When set, REPLACES the simple isActive() check on the activate column.
    // When empty (default), existing isActive(.activate) behavior is used.
    // +optional
    ActivateExpression string `json:"activateExpression,omitempty"`
}

Evaluation Flow

For each database row:
  1. Query all columns (uid, activate, extraValueMappings)
  2. Build template variables
  3. If activateExpression is set:
       rendered, err = engine.Render(activateExpression, vars)
       if err != nil:
         treat row as ACTIVE (fail-open), increment expressionErrors
       else:
         active = isActive(rendered)
     Else:
       active = isActive(row.Activate)  // current behavior (unchanged)
  4. If active: create/maintain LynqNode CR
     If not active: delete LynqNode CR

Backward Compatibility

Scenario Behavior
No activateExpression (default) Existing isActive() behavior — zero change
activateExpression set Expression evaluated per row, replaces simple truthy check
.activate in expression Raw column value available for use in expressions

API Example

apiVersion: operator.lynq.sh/v1
kind: LynqHub
metadata:
  name: saas-customers
spec:
  source:
    type: mysql
    syncInterval: "30s"
    mysql:
      host: mysql.default.svc.cluster.local
      port: 3306
      database: saas
      table: customers
      username: root
      passwordRef:
        name: mysql-creds
        key: password
  valueMappings:
    uid: customer_id
    activate: is_active
  extraValueMappings:
    expiredAt: expired_at
    plan: subscription_plan
  activateExpression: '{{ and (isActive .activate) (timeAfterNow .expiredAt) }}'

Implementation Considerations

Click to expand — categorized items to address during implementation

Safety (Must address before merge)

  • Fail-open on expression error: If activateExpression rendering fails for a row (e.g., undefined variable due to missingkey=error), treat the row as active (preserve existing LynqNode). Never delete resources due to a configuration error. Track errors in status.expressionErrors.
  • Circuit breaker for mass deactivation: If more than a configurable threshold (e.g., 50%) of previously-active rows would be deactivated in a single sync cycle, abort the deletion and emit a MassDeactivationBlocked event. This prevents accidental mass deletion from expression bugs.
  • Remove dangerous Sprig functions: Delete env, expandenv, getHostByName from the template engine's function map. These allow reading operator environment variables and performing DNS lookups from templates. This is a pre-existing vulnerability that this feature amplifies.
  • activate optionality: When activateExpression is set, valueMappings.activate should NOT be required. The expression is the sole activation mechanism. Coordinate with [Enhancement]: Make activate column optional in valueMappings with default-active behavior #21.

Correctness

  • Unified isActive truthy set: The isActive template function MUST use the exact same truthy values as mysql.go's isActive(): "1", "true", "TRUE", "True", "yes", "YES", "Yes". Extract to a shared function.
  • NULL handling for "never expires": Empty expiredAt (NULL) causes timeAfterNow("")false, deactivating rows that should never expire. Document the pattern: {{ or (eq .expiredAt "") (timeAfterNow .expiredAt) }}.
  • Partial failure behavior: When some rows fail expression evaluation: continue processing successful rows, report failure count in status, emit events per failed row.

Operational

  • Status fields: Add to LynqHubStatus: totalRows (pre-filter), activeRows (post-filter), expressionErrors (evaluation failures).
  • ExpressionHealthy condition: Add condition (only when activateExpression is configured). True when no evaluation errors, False with error details.
  • Events: Emit ExpressionEvaluationFailed (per-row errors), MassDeactivationBlocked (circuit breaker triggered).
  • Flapping at time boundaries: Near expiration times, rapid create/delete cycles can occur each syncInterval. Document this limitation. Consider a future deactivationGracePeriod field.

Performance

  • Template pre-compilation: Parse activateExpression template once per sync cycle, execute per row. Do not create a new Engine per row.
  • Evaluation timeout: Set a per-row evaluation timeout (e.g., 1s) to prevent expensive template operations.
  • Single time.Now() per sync: Capture time.Now() once at reconciliation start. All row evaluations use the same timestamp for consistency.

Validation

  • Webhook parse-check: Validate activateExpression as a valid Go template at admission time.
  • Variable reference warning: Emit admission warning if expression references variables not in valueMappings or extraValueMappings.
  • MaxLength: Add +kubebuilder:validation:MaxLength=2048 to the field.

Related Issues

Implementation Phases

Phase 1: Date/Time Template Functions (No CRD Changes)

Add all 8 functions to the template engine. Remove dangerous Sprig functions. Immediately usable in any template context.

Phase 2: Expression-Based Activation

Add activateExpression CRD field, hub controller evaluation logic, fail-open semantics, circuit breaker, status fields, conditions, events, webhook validation.

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions