fix: apply app-label-alias to shipped events

The alias was only ever used to build the `include_labels` filter on the generated vector source, while dokku labels containers with `com.dokku.app-name` unconditionally. Setting the property therefore pointed the source at a label no container carries, and log collection stopped without any error. The source now always filters the label dokku applies, and a generated remap renames the field on its way to the sink, which is what the property was documented to do. An app whose own alias differs from the global one gets a branch in the global pipeline, so a per-app value is honored even when the app ships through the global sink.

Closes #8916.
This commit is contained in:
Jose Diaz-Gonzalez
2026-08-09 14:01:43 -04:00
parent d767dd83f1
commit 5b5d3d761b
6 changed files with 649 additions and 41 deletions

View File

@@ -8,6 +8,7 @@ import (
"html/template"
"os"
"path/filepath"
"sort"
"strings"
"github.com/dokku/dokku/plugins/common"
@@ -232,6 +233,19 @@ type vectorAppSinks struct {
// CronRemapID is the component id for the remap transform on the cron branch
CronRemapID string
// RelabelID is the component id for the remap transform renaming the app
// label on the non-cron branch
RelabelID string
// LabelAlias is the label key the app name is shipped under for this scope.
// It is the AppLabelAlias constant unless the app-label-alias property is set
LabelAlias string
// LabelAliasOverrides holds the apps within this scope whose own alias
// differs from LabelAlias. Only the global scope carries these, since a
// per-app source only ever covers one app
LabelAliasOverrides []vectorLabelAliasOverride
// Sink is the DSN for non-cron logs, empty when unset
Sink string
@@ -239,6 +253,16 @@ type vectorAppSinks struct {
CronSink string
}
// vectorLabelAliasOverride is the alias a single app ships its name under when
// it differs from the alias of the scope collecting it
type vectorLabelAliasOverride struct {
// AppName is the app the override applies to
AppName string
// Alias is the label key the app name is shipped under
Alias 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
@@ -247,12 +271,22 @@ type vectorAppSinks struct {
// 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 {
//
// Any label rename is appended to the remap rather than given a component of
// its own, so that dokku_app is captured from the literal label before the
// rename runs and keeps its value regardless of the configured alias.
func cronRouteTransforms(scope vectorAppSinks, relabel string) map[string]any {
source := fmt.Sprintf(".dokku_app = to_string(%s) ?? \"\"\n.dokku_cron_id = to_string(%s) ?? \"\"",
vrlLabelPath(AppLabelAlias), vrlLabelPath(CronIDLabel))
if relabel != "" {
source = fmt.Sprintf("%s\n%s", source, relabel)
}
return map[string]any{
routerID: vectorRouteTransform{
scope.RouterID: vectorRouteTransform{
Type: "route",
Inputs: []string{sourceID},
RerouteUnmatched: hasSink,
Inputs: []string{scope.SourceID},
RerouteUnmatched: scope.Sink != "",
Route: map[string]vectorCondition{
CronRouteName: {
Type: "vrl",
@@ -260,11 +294,10 @@ func cronRouteTransforms(routerID string, remapID string, sourceID string, hasSi
},
},
},
remapID: vectorRemapTransform{
scope.CronRemapID: 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)),
Inputs: []string{fmt.Sprintf("%s.%s", scope.RouterID, CronRouteName)},
Source: source,
},
}
}
@@ -275,6 +308,92 @@ func vrlLabelPath(label string) string {
return fmt.Sprintf(".label.%q", label)
}
// vrlRenameLabel renders the assignment moving the dokku app label onto the
// supplied alias
func vrlRenameLabel(alias string) string {
return fmt.Sprintf("%s = del(%s)", vrlLabelPath(alias), vrlLabelPath(AppLabelAlias))
}
// vrlClause is one branch of a generated VRL conditional. An empty Condition
// renders as a trailing else
type vrlClause struct {
Condition string
Statement string
}
// vrlIfChain renders clauses as a single if/else if/else statement
func vrlIfChain(clauses []vrlClause) string {
if len(clauses) == 1 && clauses[0].Condition == "" {
return clauses[0].Statement
}
var chain strings.Builder
for i, clause := range clauses {
if i > 0 {
chain.WriteString(" else ")
}
if clause.Condition != "" {
chain.WriteString(fmt.Sprintf("if %s ", clause.Condition))
}
chain.WriteString(fmt.Sprintf("{\n %s\n}", clause.Statement))
}
return chain.String()
}
// relabelVRL renders the program renaming the dokku app label to the alias
// configured for the scope, or an empty string when there is nothing to rename.
//
// The rename happens on the event rather than on the container because dokku
// only ever labels containers com.dokku.app-name: pointing the source filter at
// any other label - which is what this property used to do - matches nothing at
// all and silently collects no logs.
//
// The global scope collects every app, so an app whose own alias differs from
// the global one gets a branch of its own here. Without that, a per-app alias
// would be silently ignored for any app shipping through the global sink.
func relabelVRL(scope vectorAppSinks) string {
alias := scope.LabelAlias
if alias == "" {
alias = AppLabelAlias
}
if len(scope.LabelAliasOverrides) == 0 {
if alias == AppLabelAlias {
return ""
}
return vrlRenameLabel(alias)
}
clauses := []vrlClause{}
retained := []string{}
for _, override := range scope.LabelAliasOverrides {
if override.Alias == AppLabelAlias {
retained = append(retained, fmt.Sprintf("app != %q", override.AppName))
continue
}
clauses = append(clauses, vrlClause{
Condition: fmt.Sprintf("app == %q", override.AppName),
Statement: vrlRenameLabel(override.Alias),
})
}
if alias != AppLabelAlias {
clauses = append(clauses, vrlClause{
Condition: strings.Join(retained, " && "),
Statement: vrlRenameLabel(alias),
})
}
if len(clauses) == 0 {
return ""
}
return fmt.Sprintf("app = to_string(%s) ?? \"\"\n%s", vrlLabelPath(AppLabelAlias), vrlIfChain(clauses))
}
// 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) {
@@ -293,12 +412,14 @@ func buildVectorConfig(scopes []vectorAppSinks) (vectorConfig, error) {
IncludeLabels: scope.IncludeLabels,
}
relabel := relabelVRL(scope)
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 != "") {
for id, transform := range cronRouteTransforms(scope, relabel) {
data.Transforms[id] = transform
}
@@ -316,6 +437,21 @@ func buildVectorConfig(scopes []vectorAppSinks) (vectorConfig, error) {
}
if scope.Sink != "" {
// the cron branch renames within its own remap, so this transform
// only exists when there is a non-cron sink downstream to consume it
if relabel != "" {
if data.Transforms == nil {
data.Transforms = map[string]any{}
}
data.Transforms[scope.RelabelID] = vectorRemapTransform{
Type: "remap",
Inputs: sinkInputs,
Source: relabel,
}
sinkInputs = []string{scope.RelabelID}
}
sink, err := SinkValueToConfig(SinkValueToConfigInput{
SinkValue: scope.Sink,
Inputs: sinkInputs,
@@ -355,30 +491,50 @@ func buildVectorConfig(scopes []vectorAppSinks) (vectorConfig, error) {
// vectorScopes collects the sink configuration for every app plus the global scope
func vectorScopes() []vectorAppSinks {
apps, _ := common.UnfilteredDokkuApps()
globalAlias := reportComputedAppLabelAlias("--global")
scopes := []vectorAppSinks{}
overrides := []vectorLabelAliasOverride{}
for _, appName := range apps {
inflectedAppName := strings.ReplaceAll(appName, ".", "-")
appAlias := reportComputedAppLabelAlias(appName)
if appAlias != globalAlias {
overrides = append(overrides, vectorLabelAliasOverride{
AppName: appName,
Alias: appAlias,
})
}
scopes = append(scopes, vectorAppSinks{
SourceID: fmt.Sprintf("docker-source:%s", inflectedAppName),
IncludeLabels: []string{fmt.Sprintf("%s=%s", reportComputedAppLabelAlias(appName), appName)},
IncludeLabels: []string{fmt.Sprintf("%s=%s", AppLabelAlias, 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),
RelabelID: fmt.Sprintf("docker-relabel:%s", inflectedAppName),
LabelAlias: appAlias,
Sink: common.PropertyGet("logs", appName, "vector-sink"),
CronSink: common.PropertyGet("logs", appName, "vector-cron-sink"),
})
}
sort.Slice(overrides, func(i int, j int) bool {
return overrides[i].AppName < overrides[j].AppName
})
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"),
SourceID: "docker-global-source",
IncludeLabels: []string{AppLabelAlias},
SinkID: "docker-global-sink",
CronSinkID: "docker-global-cron-sink",
RouterID: "docker-global-router",
CronRemapID: "docker-global-cron-remap",
RelabelID: "docker-global-relabel",
LabelAlias: globalAlias,
LabelAliasOverrides: overrides,
Sink: common.PropertyGet("logs", "--global", "vector-sink"),
CronSink: common.PropertyGet("logs", "--global", "vector-cron-sink"),
})
}

View File

@@ -2,8 +2,12 @@ package logs
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/dokku/dokku/plugins/common"
)
func appScope(sink string, cronSink string) vectorAppSinks {
@@ -14,6 +18,27 @@ func appScope(sink string, cronSink string) vectorAppSinks {
CronSinkID: "docker-cron-sink:myapp",
RouterID: "docker-router:myapp",
CronRemapID: "docker-cron-remap:myapp",
RelabelID: "docker-relabel:myapp",
Sink: sink,
CronSink: cronSink,
}
}
func aliasScope(sink string, cronSink string, alias string) vectorAppSinks {
scope := appScope(sink, cronSink)
scope.LabelAlias = alias
return scope
}
func globalScope(sink string, cronSink string) vectorAppSinks {
return vectorAppSinks{
SourceID: "docker-global-source",
IncludeLabels: []string{"com.dokku.app-name"},
SinkID: "docker-global-sink",
CronSinkID: "docker-global-cron-sink",
RouterID: "docker-global-router",
CronRemapID: "docker-global-cron-remap",
RelabelID: "docker-global-relabel",
Sink: sink,
CronSink: cronSink,
}
@@ -131,16 +156,9 @@ func TestBuildVectorConfigBothSinks(t *testing.T) {
}
func TestBuildVectorConfigGlobalScope(t *testing.T) {
_, decoded := marshalConfig(t, []vectorAppSinks{{
SourceID: "docker-global-source",
IncludeLabels: []string{"com.dokku.app-name"},
SinkID: "docker-global-sink",
CronSinkID: "docker-global-cron-sink",
RouterID: "docker-global-router",
CronRemapID: "docker-global-cron-remap",
Sink: "console://?encoding[codec]=json",
CronSink: "console://?encoding[codec]=text",
}})
_, decoded := marshalConfig(t, []vectorAppSinks{
globalScope("console://?encoding[codec]=json", "console://?encoding[codec]=text"),
})
lookup(t, decoded, "transforms", "docker-global-router")
lookup(t, decoded, "transforms", "docker-global-cron-remap")
@@ -172,3 +190,215 @@ func TestBuildVectorConfigInvalidSink(t *testing.T) {
t.Fatal("buildVectorConfig() expected an error for an invalid sink DSN")
}
}
// TestBuildVectorConfigAliasKeepsSourceFilter is the guard for the bug this
// alias handling replaced: filtering the source on the alias matched no
// container at all, because dokku only ever labels containers with the literal
// key, so setting the property silently collected nothing.
func TestBuildVectorConfigAliasKeepsSourceFilter(t *testing.T) {
_, decoded := marshalConfig(t, []vectorAppSinks{
aliasScope("console://?encoding[codec]=json", "", "app_name"),
})
labels := lookup(t, decoded, "sources", "docker-source:myapp", "include_labels")
if got := labels.([]interface{})[0]; got != "com.dokku.app-name=myapp" {
t.Errorf("include_labels[0] = %v, want com.dokku.app-name=myapp", got)
}
}
func TestBuildVectorConfigAliasRelabelsPlainBranch(t *testing.T) {
_, decoded := marshalConfig(t, []vectorAppSinks{
aliasScope("console://?encoding[codec]=json", "", "app_name"),
})
if got := lookup(t, decoded, "transforms", "docker-relabel:myapp", "type"); got != "remap" {
t.Errorf("relabel type = %v, want remap", got)
}
inputs := lookup(t, decoded, "transforms", "docker-relabel:myapp", "inputs")
if got := inputs.([]interface{})[0]; got != "docker-source:myapp" {
t.Errorf("relabel inputs[0] = %v, want docker-source:myapp", got)
}
source := lookup(t, decoded, "transforms", "docker-relabel:myapp", "source")
want := `.label."app_name" = del(.label."com.dokku.app-name")`
if source != want {
t.Errorf("relabel source = %v, want %v", source, want)
}
sinkInputs := lookup(t, decoded, "sinks", "docker-sink:myapp", "inputs")
if got := sinkInputs.([]interface{})[0]; got != "docker-relabel:myapp" {
t.Errorf("sink inputs[0] = %v, want docker-relabel:myapp", got)
}
}
// TestBuildVectorConfigAliasRelabelsBothBranches pins the rename onto the tail
// of the cron remap. dokku_app is captured from the literal label first, so it
// keeps its value no matter which alias the event is shipped under.
func TestBuildVectorConfigAliasRelabelsBothBranches(t *testing.T) {
_, decoded := marshalConfig(t, []vectorAppSinks{
aliasScope("console://?encoding[codec]=json", "console://?encoding[codec]=text", "app_name"),
})
remapSource := lookup(t, decoded, "transforms", "docker-cron-remap:myapp", "source")
want := ".dokku_app = to_string(.label.\"com.dokku.app-name\") ?? \"\"\n" +
".dokku_cron_id = to_string(.label.\"com.dokku.cron-id\") ?? \"\"\n" +
".label.\"app_name\" = del(.label.\"com.dokku.app-name\")"
if remapSource != want {
t.Errorf("cron remap source = %v, want %v", remapSource, want)
}
inputs := lookup(t, decoded, "transforms", "docker-relabel:myapp", "inputs")
if got := inputs.([]interface{})[0]; got != "docker-router:myapp._unmatched" {
t.Errorf("relabel inputs[0] = %v, want docker-router:myapp._unmatched", got)
}
sinkInputs := lookup(t, decoded, "sinks", "docker-sink:myapp", "inputs")
if got := sinkInputs.([]interface{})[0]; got != "docker-relabel:myapp" {
t.Errorf("sink inputs[0] = %v, want docker-relabel:myapp", got)
}
}
// TestBuildVectorConfigAliasCronSinkOnly covers the branch with nothing
// downstream to consume a relabel component: vector rejects a transform whose
// output no sink reads, so the rename has to stay inside the cron remap.
func TestBuildVectorConfigAliasCronSinkOnly(t *testing.T) {
_, decoded := marshalConfig(t, []vectorAppSinks{
aliasScope("", "console://?encoding[codec]=text", "app_name"),
})
transforms := lookup(t, decoded, "transforms").(map[string]interface{})
if _, ok := transforms["docker-relabel:myapp"]; ok {
t.Error("relabel transform should not exist without a plain sink")
}
remapSource := lookup(t, decoded, "transforms", "docker-cron-remap:myapp", "source").(string)
if !strings.Contains(remapSource, `.label."app_name" = del(.label."com.dokku.app-name")`) {
t.Errorf("cron remap source %q missing the rename", remapSource)
}
}
func setupScopesTest(t *testing.T, apps []string) {
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_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)
}
for _, appName := range apps {
if err := os.MkdirAll(filepath.Join(os.Getenv("DOKKU_ROOT"), appName), 0755); err != nil {
t.Fatalf("MkdirAll %s: %v", appName, err)
}
}
}
func scopeByID(t *testing.T, scopes []vectorAppSinks, sourceID string) vectorAppSinks {
t.Helper()
for _, scope := range scopes {
if scope.SourceID == sourceID {
return scope
}
}
t.Fatalf("no scope with source id %q", sourceID)
return vectorAppSinks{}
}
// TestVectorScopesGlobalAliasIgnoresAppNamedGlobal pins the global scope to the
// --global property. It used to resolve against an app literally named global,
// which would have let that app's own alias drive every other app's shipping.
func TestVectorScopesGlobalAliasIgnoresAppNamedGlobal(t *testing.T) {
setupScopesTest(t, []string{"global", "myapp"})
if err := common.PropertyWrite("logs", "--global", "app-label-alias", "gname"); err != nil {
t.Fatalf("PropertyWrite --global: %v", err)
}
if err := common.PropertyWrite("logs", "global", "app-label-alias", "hijack"); err != nil {
t.Fatalf("PropertyWrite global: %v", err)
}
scopes := vectorScopes()
globalScope := scopeByID(t, scopes, "docker-global-source")
if globalScope.LabelAlias != "gname" {
t.Errorf("global LabelAlias = %q, want gname", globalScope.LabelAlias)
}
// the app named global differs from the global alias, so it is the only
// scope that should be listed as an override
want := []vectorLabelAliasOverride{{AppName: "global", Alias: "hijack"}}
if len(globalScope.LabelAliasOverrides) != 1 || globalScope.LabelAliasOverrides[0] != want[0] {
t.Errorf("global LabelAliasOverrides = %v, want %v", globalScope.LabelAliasOverrides, want)
}
appScope := scopeByID(t, scopes, "docker-source:myapp")
if appScope.LabelAlias != "gname" {
t.Errorf("myapp LabelAlias = %q, want gname", appScope.LabelAlias)
}
if got := appScope.IncludeLabels[0]; got != "com.dokku.app-name=myapp" {
t.Errorf("myapp include_labels[0] = %q, want com.dokku.app-name=myapp", got)
}
}
func TestRelabelVRLDefaultAlias(t *testing.T) {
for _, alias := range []string{"", AppLabelAlias} {
if got := relabelVRL(aliasScope("console://", "", alias)); got != "" {
t.Errorf("relabelVRL(%q) = %q, want an empty string", alias, got)
}
}
}
// TestRelabelVRLGlobalOverrides covers the global scope, which collects every
// app: an app whose own alias differs from the global one needs a branch here,
// or its alias would be silently dropped whenever it ships through the global
// sink.
func TestRelabelVRLGlobalOverrides(t *testing.T) {
prelude := "app = to_string(.label.\"com.dokku.app-name\") ?? \"\"\n"
tests := []struct {
name string
alias string
overrides []vectorLabelAliasOverride
want string
}{
{
name: "renaming override under a default global alias",
alias: AppLabelAlias,
overrides: []vectorLabelAliasOverride{{AppName: "appa", Alias: "foo"}},
want: prelude + "if app == \"appa\" {\n .label.\"foo\" = del(.label.\"com.dokku.app-name\")\n}",
},
{
name: "override pinned back to the default label",
alias: "gname",
overrides: []vectorLabelAliasOverride{{AppName: "appb", Alias: AppLabelAlias}},
want: prelude + "if app != \"appb\" {\n .label.\"gname\" = del(.label.\"com.dokku.app-name\")\n}",
},
{
name: "both kinds of override at once",
alias: "gname",
overrides: []vectorLabelAliasOverride{
{AppName: "appa", Alias: "foo"},
{AppName: "appb", Alias: AppLabelAlias},
},
want: prelude + "if app == \"appa\" {\n .label.\"foo\" = del(.label.\"com.dokku.app-name\")\n}" +
" else if app != \"appb\" {\n .label.\"gname\" = del(.label.\"com.dokku.app-name\")\n}",
},
}
for _, test := range tests {
scope := globalScope("console://", "")
scope.LabelAlias = test.alias
scope.LabelAliasOverrides = test.overrides
if got := relabelVRL(scope); got != test.want {
t.Errorf("%s: relabelVRL() = %q, want %q", test.name, got, test.want)
}
}
}

View File

@@ -3,6 +3,7 @@ package logs
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
@@ -10,6 +11,10 @@ import (
)
func validateSetValue(appName string, key string, value string) error {
if key == "app-label-alias" {
return validateAppLabelAlias(appName, value)
}
if key == "max-size" {
return validateMaxSize(appName, value)
}
@@ -29,6 +34,22 @@ func validateSetValue(appName string, key string, value string) error {
return nil
}
// appLabelAliasPattern matches label keys that are safe to both use as a docker
// label and quote into the generated vector remap program
var appLabelAliasPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
func validateAppLabelAlias(appName string, value string) error {
if value == "" {
return nil
}
if !appLabelAliasPattern.MatchString(value) {
return errors.New("Invalid app-label-alias value, must start with a letter or number and contain only letters, numbers, and any of [_, ., -]")
}
return nil
}
func validateMaxSize(appName string, value string) error {
if value == "" {
return nil