Merge pull request #8914 from dokku/feat/vector-cron-sink

Add vector-cron-sink for scheduled cron task output
This commit is contained in:
Jose Diaz-Gonzalez
2026-08-09 13:16:21 -04:00
committed by GitHub
15 changed files with 1365 additions and 112 deletions

View File

@@ -2175,7 +2175,10 @@ func installHelmCharts(ctx context.Context, clientset KubernetesClient, shouldIn
}
if chart.ReleaseName == "vector" && chart.Namespace == "vector" {
values = updateVectorValues(values)
values, err = updateVectorValues(values)
if err != nil {
return fmt.Errorf("Error updating vector values: %w", err)
}
}
chartProperties, err := common.PropertyMapGet("scheduler-k3s", "--global", "chart-overrides."+chart.ReleaseName)
@@ -2256,26 +2259,129 @@ func installHelmCharts(ctx context.Context, clientset KubernetesClient, shouldIn
return nil
}
func updateVectorValues(values map[string]interface{}) map[string]interface{} {
value := common.PropertyGet("logs", "--global", "vector-sink")
if value == "" {
return values
const (
// kubernetesLogsTransform is the base transform enriching every container log event
kubernetesLogsTransform = "kubernetes_container_logs"
// kubernetesRouterTransform splits container logs into cron and non-cron branches
kubernetesRouterTransform = "kubernetes_router"
// kubernetesCronRemapTransform flattens cron metadata onto the event
kubernetesCronRemapTransform = "kubernetes_cron_remap"
// kubernetesDefaultSink is the console sink shipped in the base values file
kubernetesDefaultSink = "default_global_sink"
// kubernetesGlobalSink receives non-cron logs when a vector-sink is configured
kubernetesGlobalSink = "kubernetes_global_sink"
// kubernetesCronSink receives cron task logs when a vector-cron-sink is configured
kubernetesCronSink = "kubernetes_cron_sink"
)
// kubernetesCronRouteTransforms returns the route and remap pair that splits
// container logs into cron and non-cron branches.
//
// Cron pods are labelled app.kubernetes.io/name=cron. The raw cron id exceeds
// the Kubernetes label length cap, so it lives in an annotation and is
// flattened onto the event here - vector drops any event whose sink template
// references a missing field.
func kubernetesCronRouteTransforms() map[string]interface{} {
return map[string]interface{}{
kubernetesRouterTransform: map[string]interface{}{
"type": "route",
"inputs": []string{kubernetesLogsTransform},
"reroute_unmatched": true,
"route": map[string]interface{}{
logs.CronRouteName: map[string]interface{}{
"type": "vrl",
"source": `.kubernetes.pod_labels."app.kubernetes.io/name" == "cron"`,
},
},
},
kubernetesCronRemapTransform: map[string]interface{}{
"type": "remap",
"inputs": []string{kubernetesRouterTransform + "." + logs.CronRouteName},
"source": ".dokku_app = to_string(.kubernetes.pod_labels.\"app.kubernetes.io/part-of\") ?? \"\"\n" +
".dokku_cron_id = to_string(.kubernetes.pod_annotations.\"dokku.com/cron-id\") ?? \"\"",
},
}
}
// updateVectorValues layers the configured log sinks onto the vector chart
// values. Sinks and transforms from the base values file are preserved unless
// they are explicitly superseded, so unrelated components such as the
// prometheus exporter keep working.
func updateVectorValues(values map[string]interface{}) (map[string]interface{}, error) {
sinkValue := common.PropertyGet("logs", "--global", "vector-sink")
cronSinkValue := common.PropertyGet("logs", "--global", "vector-cron-sink")
if sinkValue == "" && cronSinkValue == "" {
return values, nil
}
sink, err := logs.SinkValueToConfig("--global", value)
if err != nil {
return nil
customConfig, ok := values["customConfig"].(map[string]interface{})
if !ok {
return values, errors.New("Missing or invalid customConfig in vector chart values")
}
sink["inputs"] = []string{"kubernetes_container_logs"}
sinkMap := map[string]interface{}{
"kubernetes_global_sink": sink,
sinks, ok := customConfig["sinks"].(map[string]interface{})
if !ok {
sinks = map[string]interface{}{}
}
values["customConfig"].(map[string]interface{})["sinks"] = sinkMap
// a configured sink supersedes the console sink from the base values;
// without one, the console sink remains the destination for non-cron logs
nonCronSinkID := kubernetesDefaultSink
if sinkValue != "" {
nonCronSinkID = kubernetesGlobalSink
delete(sinks, kubernetesDefaultSink)
return values
sink, err := logs.SinkValueToConfig(logs.SinkValueToConfigInput{
SinkValue: sinkValue,
Inputs: []string{kubernetesLogsTransform},
})
if err != nil {
return values, fmt.Errorf("Error parsing vector-sink: %w", err)
}
// stored as a plain map so that later lookups, and the yaml encoder,
// see the same shape as the sinks parsed from the base values file
sinks[kubernetesGlobalSink] = map[string]interface{}(sink)
}
if cronSinkValue != "" {
transforms, ok := customConfig["transforms"].(map[string]interface{})
if !ok {
transforms = map[string]interface{}{}
}
for id, transform := range kubernetesCronRouteTransforms() {
transforms[id] = transform
}
customConfig["transforms"] = transforms
cronSink, err := logs.SinkValueToConfig(logs.SinkValueToConfigInput{
SinkValue: cronSinkValue,
Inputs: []string{kubernetesCronRemapTransform},
})
if err != nil {
return values, fmt.Errorf("Error parsing vector-cron-sink: %w", err)
}
sinks[kubernetesCronSink] = map[string]interface{}(cronSink)
// cron logs are routed away from the non-cron sink so that each event
// lands in exactly one destination
nonCronSink, ok := sinks[nonCronSinkID].(map[string]interface{})
if !ok {
return values, fmt.Errorf("Missing or invalid %s sink in vector chart values", nonCronSinkID)
}
nonCronSink["inputs"] = []string{kubernetesRouterTransform + "._unmatched"}
sinks[nonCronSinkID] = nonCronSink
}
customConfig["sinks"] = sinks
values["customConfig"] = customConfig
return values, nil
}
func installHelperCommands(ctx context.Context) error {

View File

@@ -0,0 +1,208 @@
package scheduler_k3s
import (
"testing"
"github.com/dokku/dokku/plugins/common"
"gopkg.in/yaml.v3"
)
// baseVectorValues mirrors the shipped templates/helm-config/vector.yaml
// closely enough to exercise the sink and transform merging
const baseVectorValues = `
customConfig:
sources:
kubernetes_logs:
type: kubernetes_logs
internal_metrics:
type: internal_metrics
transforms:
kubernetes_container_logs:
type: remap
inputs:
- kubernetes_logs
source: |
.container = .kubernetes.container_name
sinks:
default_global_sink:
type: console
inputs:
- kubernetes_container_logs
encoding:
codec: json
prom_exporter:
type: prometheus_exporter
inputs:
- internal_metrics
address: 0.0.0.0:9090
`
func setupVectorValuesTest(t *testing.T, sink string, cronSink string) map[string]interface{} {
t.Helper()
t.Setenv("PLUGIN_PATH", "/var/lib/dokku/plugins")
t.Setenv("PLUGIN_ENABLED_PATH", "/var/lib/dokku/plugins/enabled")
t.Setenv("DOKKU_LIB_ROOT", t.TempDir())
t.Setenv("DOKKU_SYSTEM_USER", "root")
t.Setenv("DOKKU_SYSTEM_GROUP", "root")
if err := common.PropertySetup("logs"); err != nil {
t.Fatalf("PropertySetup: %v", err)
}
if sink != "" {
if err := common.PropertyWrite("logs", "--global", "vector-sink", sink); err != nil {
t.Fatalf("PropertyWrite vector-sink: %v", err)
}
}
if cronSink != "" {
if err := common.PropertyWrite("logs", "--global", "vector-cron-sink", cronSink); err != nil {
t.Fatalf("PropertyWrite vector-cron-sink: %v", err)
}
}
values := map[string]interface{}{}
if err := yaml.Unmarshal([]byte(baseVectorValues), &values); err != nil {
t.Fatalf("Unmarshal base values: %v", err)
}
return values
}
func vectorSinks(t *testing.T, values map[string]interface{}) map[string]interface{} {
t.Helper()
customConfig, ok := values["customConfig"].(map[string]interface{})
if !ok {
t.Fatal("customConfig is not a map")
}
sinks, ok := customConfig["sinks"].(map[string]interface{})
if !ok {
t.Fatal("sinks is not a map")
}
return sinks
}
func vectorTransforms(t *testing.T, values map[string]interface{}) map[string]interface{} {
t.Helper()
customConfig, ok := values["customConfig"].(map[string]interface{})
if !ok {
t.Fatal("customConfig is not a map")
}
transforms, ok := customConfig["transforms"].(map[string]interface{})
if !ok {
t.Fatal("transforms is not a map")
}
return transforms
}
func sinkInput(t *testing.T, sinks map[string]interface{}, sinkID string) string {
t.Helper()
sink, ok := sinks[sinkID].(map[string]interface{})
if !ok {
t.Fatalf("sink %q is missing or not a map", sinkID)
}
switch inputs := sink["inputs"].(type) {
case []string:
return inputs[0]
case []interface{}:
return inputs[0].(string)
default:
t.Fatalf("sink %q has unexpected inputs type %T", sinkID, sink["inputs"])
return ""
}
}
func TestUpdateVectorValuesNoSinks(t *testing.T) {
values := setupVectorValuesTest(t, "", "")
updated, err := updateVectorValues(values)
if err != nil {
t.Fatalf("updateVectorValues() error = %v", err)
}
sinks := vectorSinks(t, updated)
if _, ok := sinks[kubernetesDefaultSink]; !ok {
t.Error("default_global_sink should remain when no sink is configured")
}
if _, ok := sinks[kubernetesGlobalSink]; ok {
t.Error("kubernetes_global_sink should not exist when no sink is configured")
}
}
// TestUpdateVectorValuesPreservesPromExporter guards against the sinks map
// being replaced wholesale, which previously dropped the prometheus exporter
// that the chart still exposes on port 9090
func TestUpdateVectorValuesPreservesPromExporter(t *testing.T) {
values := setupVectorValuesTest(t, "console://?encoding[codec]=json", "")
updated, err := updateVectorValues(values)
if err != nil {
t.Fatalf("updateVectorValues() error = %v", err)
}
sinks := vectorSinks(t, updated)
if _, ok := sinks["prom_exporter"]; !ok {
t.Error("prom_exporter should survive configuring a vector-sink")
}
if _, ok := sinks[kubernetesDefaultSink]; ok {
t.Error("default_global_sink should be superseded by the configured sink")
}
if got := sinkInput(t, sinks, kubernetesGlobalSink); got != kubernetesLogsTransform {
t.Errorf("global sink input = %v, want %v", got, kubernetesLogsTransform)
}
}
func TestUpdateVectorValuesCronSinkOnly(t *testing.T) {
values := setupVectorValuesTest(t, "", "console://?encoding[codec]=text")
updated, err := updateVectorValues(values)
if err != nil {
t.Fatalf("updateVectorValues() error = %v", err)
}
transforms := vectorTransforms(t, updated)
if _, ok := transforms[kubernetesRouterTransform]; !ok {
t.Error("router transform should be added")
}
if _, ok := transforms[kubernetesLogsTransform]; !ok {
t.Error("base container logs transform should be preserved")
}
sinks := vectorSinks(t, updated)
if got := sinkInput(t, sinks, kubernetesCronSink); got != kubernetesCronRemapTransform {
t.Errorf("cron sink input = %v, want %v", got, kubernetesCronRemapTransform)
}
// without a configured plain sink, the console default takes the unmatched branch
if got := sinkInput(t, sinks, kubernetesDefaultSink); got != kubernetesRouterTransform+"._unmatched" {
t.Errorf("default sink input = %v, want %v._unmatched", got, kubernetesRouterTransform)
}
}
func TestUpdateVectorValuesBothSinks(t *testing.T) {
values := setupVectorValuesTest(t, "console://?encoding[codec]=json", "console://?encoding[codec]=text")
updated, err := updateVectorValues(values)
if err != nil {
t.Fatalf("updateVectorValues() error = %v", err)
}
sinks := vectorSinks(t, updated)
if got := sinkInput(t, sinks, kubernetesGlobalSink); got != kubernetesRouterTransform+"._unmatched" {
t.Errorf("global sink input = %v, want %v._unmatched", got, kubernetesRouterTransform)
}
if got := sinkInput(t, sinks, kubernetesCronSink); got != kubernetesCronRemapTransform {
t.Errorf("cron sink input = %v, want %v", got, kubernetesCronRemapTransform)
}
if _, ok := sinks["prom_exporter"]; !ok {
t.Error("prom_exporter should survive configuring both sinks")
}
}
func TestUpdateVectorValuesInvalidCustomConfig(t *testing.T) {
values := setupVectorValuesTest(t, "console://", "")
delete(values, "customConfig")
if _, err := updateVectorValues(values); err == nil {
t.Fatal("updateVectorValues() expected an error for missing customConfig")
}
}