feat: add vector-cron-sink for scheduled cron task output

Scheduled cron task output previously reached only the `dokku` user's cron mail, and could not be redirected because `app.json` rejects bare shell operators in a cron `command`. Setting `vector-cron-sink` on an app or globally routes that output to a dedicated sink instead, on both the `docker-local` and `k3s` schedulers, which keeps log destinations under operator control rather than in a deployed repository. Cron events carry `dokku_app` and `dokku_cron_id` fields so a sink can give each task its own destination. This also fixes a `k3s` bug where configuring a global `vector-sink` silently removed the vector prometheus exporter sink.
This commit is contained in:
Jose Diaz-Gonzalez
2026-08-09 00:54:09 -04:00
parent 047be485a2
commit 52b26a3760
14 changed files with 1346 additions and 112 deletions

View File

@@ -15,7 +15,13 @@ import (
type vectorConfig struct {
Sources map[string]vectorSource `json:"sources"`
Sinks map[string]VectorSink `json:"sinks"`
// Transforms is left nil unless a cron sink is configured, so that configs
// without one marshal identically to those generated before cron routing
// existed
Transforms map[string]any `json:"transforms,omitempty"`
Sinks map[string]VectorSink `json:"sinks"`
}
type vectorSource struct {
@@ -23,6 +29,24 @@ type vectorSource struct {
IncludeLabels []string `json:"include_labels,omitempty"`
}
type vectorRouteTransform struct {
Type string `json:"type"`
Inputs []string `json:"inputs"`
RerouteUnmatched bool `json:"reroute_unmatched"`
Route map[string]vectorCondition `json:"route"`
}
type vectorCondition struct {
Type string `json:"type"`
Source string `json:"source"`
}
type vectorRemapTransform struct {
Type string `json:"type"`
Inputs []string `json:"inputs"`
Source string `json:"source"`
}
type vectorTemplateData struct {
DokkuLibRoot string
DokkuLogsDir string
@@ -187,45 +211,121 @@ func stopVectorContainer() error {
})
}
func writeVectorConfig() error {
apps, _ := common.UnfilteredDokkuApps()
// vectorAppSinks holds the resolved sink configuration for a single app or for
// the global scope
type vectorAppSinks struct {
// SourceID is the vector source component id
SourceID string
// IncludeLabels is the docker_logs label filter for the source
IncludeLabels []string
// SinkID is the component id for the sink receiving non-cron logs
SinkID string
// CronSinkID is the component id for the sink receiving cron task logs
CronSinkID string
// RouterID is the component id for the route transform splitting the source
RouterID string
// CronRemapID is the component id for the remap transform on the cron branch
CronRemapID string
// Sink is the DSN for non-cron logs, empty when unset
Sink string
// CronSink is the DSN for cron task logs, empty when unset
CronSink string
}
// cronRouteTransforms returns the route and remap pair that splits a source
// into cron and non-cron branches. The remap flattens the cron labels into
// top-level fields because vector drops any event whose sink template
// references a missing field, and a nested quoted path is awkward to template.
//
// The route carries a single condition, so an event either matches it or falls
// through to the reserved _unmatched output. A second route added later would
// need a mutually exclusive condition, since route fans out to every match.
func cronRouteTransforms(routerID string, remapID string, sourceID string, hasSink bool) map[string]any {
return map[string]any{
routerID: vectorRouteTransform{
Type: "route",
Inputs: []string{sourceID},
RerouteUnmatched: hasSink,
Route: map[string]vectorCondition{
CronRouteName: {
Type: "vrl",
Source: fmt.Sprintf("%s == %q", vrlLabelPath(ContainerTypeLabel), CronContainerType),
},
},
},
remapID: vectorRemapTransform{
Type: "remap",
Inputs: []string{fmt.Sprintf("%s.%s", routerID, CronRouteName)},
Source: fmt.Sprintf(".dokku_app = to_string(%s) ?? \"\"\n.dokku_cron_id = to_string(%s) ?? \"\"",
vrlLabelPath(AppLabelAlias), vrlLabelPath(CronIDLabel)),
},
}
}
// vrlLabelPath renders a docker label lookup as a VRL path. Segments holding
// characters outside [A-Za-z0-9_] must be double quoted.
func vrlLabelPath(label string) string {
return fmt.Sprintf(".label.%q", label)
}
// buildVectorConfig assembles the vector configuration for the supplied scopes.
// It performs no IO so that the generated shape can be asserted directly.
func buildVectorConfig(scopes []vectorAppSinks) (vectorConfig, error) {
data := vectorConfig{
Sources: map[string]vectorSource{},
Sinks: map[string]VectorSink{},
}
for _, appName := range apps {
value := common.PropertyGet("logs", appName, "vector-sink")
if value == "" {
for _, scope := range scopes {
if scope.Sink == "" && scope.CronSink == "" {
continue
}
inflectedAppName := strings.ReplaceAll(appName, ".", "-")
sink, err := SinkValueToConfig(inflectedAppName, value)
if err != nil {
return err
}
data.Sources[fmt.Sprintf("docker-source:%s", inflectedAppName)] = vectorSource{
data.Sources[scope.SourceID] = vectorSource{
Type: "docker_logs",
IncludeLabels: []string{fmt.Sprintf("%s=%s", reportComputedAppLabelAlias(appName), appName)},
IncludeLabels: scope.IncludeLabels,
}
data.Sinks[fmt.Sprintf("docker-sink:%s", inflectedAppName)] = sink
}
sinkInputs := []string{scope.SourceID}
if scope.CronSink != "" {
if data.Transforms == nil {
data.Transforms = map[string]any{}
}
for id, transform := range cronRouteTransforms(scope.RouterID, scope.CronRemapID, scope.SourceID, scope.Sink != "") {
data.Transforms[id] = transform
}
value := common.PropertyGet("logs", "--global", "vector-sink")
if value != "" {
sink, err := SinkValueToConfig("--global", value)
if err != nil {
return err
sinkInputs = []string{fmt.Sprintf("%s._unmatched", scope.RouterID)}
cronSink, err := SinkValueToConfig(SinkValueToConfigInput{
SinkValue: scope.CronSink,
Inputs: []string{scope.CronRemapID},
})
if err != nil {
return data, err
}
data.Sinks[scope.CronSinkID] = cronSink
}
data.Sources["docker-global-source"] = vectorSource{
Type: "docker_logs",
IncludeLabels: []string{reportComputedAppLabelAlias("global")},
}
if scope.Sink != "" {
sink, err := SinkValueToConfig(SinkValueToConfigInput{
SinkValue: scope.Sink,
Inputs: sinkInputs,
})
if err != nil {
return data, err
}
data.Sinks["docker-global-sink"] = sink
data.Sinks[scope.SinkID] = sink
}
}
if len(data.Sources) == 0 {
@@ -238,14 +338,56 @@ func writeVectorConfig() error {
if len(data.Sinks) == 0 {
// write logs to a blackhole
sink, err := SinkValueToConfig("--null", VectorDefaultSink)
sink, err := SinkValueToConfig(SinkValueToConfigInput{
SinkValue: VectorDefaultSink,
Inputs: []string{"docker-null-source"},
})
if err != nil {
return err
return data, err
}
data.Sinks["docker-null-sink"] = sink
}
return data, nil
}
// vectorScopes collects the sink configuration for every app plus the global scope
func vectorScopes() []vectorAppSinks {
apps, _ := common.UnfilteredDokkuApps()
scopes := []vectorAppSinks{}
for _, appName := range apps {
inflectedAppName := strings.ReplaceAll(appName, ".", "-")
scopes = append(scopes, vectorAppSinks{
SourceID: fmt.Sprintf("docker-source:%s", inflectedAppName),
IncludeLabels: []string{fmt.Sprintf("%s=%s", reportComputedAppLabelAlias(appName), appName)},
SinkID: fmt.Sprintf("docker-sink:%s", inflectedAppName),
CronSinkID: fmt.Sprintf("docker-cron-sink:%s", inflectedAppName),
RouterID: fmt.Sprintf("docker-router:%s", inflectedAppName),
CronRemapID: fmt.Sprintf("docker-cron-remap:%s", inflectedAppName),
Sink: common.PropertyGet("logs", appName, "vector-sink"),
CronSink: common.PropertyGet("logs", appName, "vector-cron-sink"),
})
}
return append(scopes, vectorAppSinks{
SourceID: "docker-global-source",
IncludeLabels: []string{reportComputedAppLabelAlias("global")},
SinkID: "docker-global-sink",
CronSinkID: "docker-global-cron-sink",
RouterID: "docker-global-router",
CronRemapID: "docker-global-cron-remap",
Sink: common.PropertyGet("logs", "--global", "vector-sink"),
CronSink: common.PropertyGet("logs", "--global", "vector-cron-sink"),
})
}
func writeVectorConfig() error {
data, err := buildVectorConfig(vectorScopes())
if err != nil {
return err
}
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err