Files
dokku/plugins/logs/functions_test.go
Jose Diaz-Gonzalez b43929df27 fix: regenerate vector config on app lifecycle changes
The generated vector config is a snapshot of the app list and their sink properties, but it was only ever written by `logs:set` and `logs:vector-start`. Renaming an app left a source filtering on a label no container carries and gave the new name no source at all, so the app kept a sink with nothing feeding it. Cloning produced the same result for the clone, and destroying an app left its source and sink behind, the latter still pointing at an endpoint decommissioned along with the app. The global relabel transform embeds app names directly in generated VRL, so a rename also left behind a branch naming an app that no longer existed. Every case was silent, and the only repair was an operator running `logs:vector-start`. The `post-app-clone-setup`, `post-app-rename-setup` and `post-delete` triggers now rewrite the config, warning rather than failing so that a config write cannot abort the app operation whose state it is derived from.

Closes #8918.
2026-08-09 23:53:12 -04:00

545 lines
20 KiB
Go

package logs
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/dokku/dokku/plugins/common"
)
func appScope(sink string, cronSink string) vectorAppSinks {
return vectorAppSinks{
SourceID: "docker-source:myapp",
IncludeLabels: []string{"com.dokku.app-name=myapp"},
SinkID: "docker-sink:myapp",
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,
}
}
func marshalConfig(t *testing.T, scopes []vectorAppSinks) (string, map[string]interface{}) {
t.Helper()
config, err := buildVectorConfig(scopes)
if err != nil {
t.Fatalf("buildVectorConfig() error = %v", err)
}
b, err := json.MarshalIndent(config, "", " ")
if err != nil {
t.Fatalf("MarshalIndent() error = %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
return string(b), decoded
}
func lookup(t *testing.T, decoded map[string]interface{}, path ...string) interface{} {
t.Helper()
var current interface{} = decoded
for _, key := range path {
asMap, ok := current.(map[string]interface{})
if !ok {
t.Fatalf("path %v: %q is not a map", path, key)
}
current, ok = asMap[key]
if !ok {
t.Fatalf("path %v: missing key %q", path, key)
}
}
return current
}
// TestBuildVectorConfigOmitsTransforms is the backwards compatibility guard:
// a config without a cron sink must marshal exactly as it did before cron
// routing existed, which means no transforms key at all.
func TestBuildVectorConfigOmitsTransforms(t *testing.T) {
raw, decoded := marshalConfig(t, []vectorAppSinks{appScope("console://?encoding[codec]=json", "")})
if strings.Contains(raw, "transforms") {
t.Errorf("config should not contain a transforms key:\n%s", raw)
}
inputs := lookup(t, decoded, "sinks", "docker-sink:myapp", "inputs")
if got := inputs.([]interface{})[0]; got != "docker-source:myapp" {
t.Errorf("sink inputs[0] = %v, want docker-source:myapp", got)
}
}
func TestBuildVectorConfigCronSinkOnly(t *testing.T) {
_, decoded := marshalConfig(t, []vectorAppSinks{appScope("", "console://?encoding[codec]=json")})
if got := lookup(t, decoded, "transforms", "docker-router:myapp", "type"); got != "route" {
t.Errorf("router type = %v, want route", got)
}
// nothing consumes the unmatched output when there is no plain sink
if got := lookup(t, decoded, "transforms", "docker-router:myapp", "reroute_unmatched"); got != false {
t.Errorf("reroute_unmatched = %v, want false", got)
}
condition := lookup(t, decoded, "transforms", "docker-router:myapp", "route", "cron", "source")
want := `.label."com.dokku.container-type" == "cron"`
if condition != want {
t.Errorf("route condition = %v, want %v", condition, want)
}
remapSource := lookup(t, decoded, "transforms", "docker-cron-remap:myapp", "source").(string)
for _, fragment := range []string{".dokku_app", ".dokku_cron_id", `.label."com.dokku.cron-id"`} {
if !strings.Contains(remapSource, fragment) {
t.Errorf("remap source %q missing %q", remapSource, fragment)
}
}
inputs := lookup(t, decoded, "sinks", "docker-cron-sink:myapp", "inputs")
if got := inputs.([]interface{})[0]; got != "docker-cron-remap:myapp" {
t.Errorf("cron sink inputs[0] = %v, want docker-cron-remap:myapp", got)
}
sinks := lookup(t, decoded, "sinks").(map[string]interface{})
if _, ok := sinks["docker-sink:myapp"]; ok {
t.Error("plain sink should not exist when only a cron sink is set")
}
}
func TestBuildVectorConfigBothSinks(t *testing.T) {
_, decoded := marshalConfig(t, []vectorAppSinks{
appScope("console://?encoding[codec]=json", "console://?encoding[codec]=text"),
})
if got := lookup(t, decoded, "transforms", "docker-router:myapp", "reroute_unmatched"); got != true {
t.Errorf("reroute_unmatched = %v, want true", got)
}
inputs := lookup(t, decoded, "sinks", "docker-sink:myapp", "inputs")
if got := inputs.([]interface{})[0]; got != "docker-router:myapp._unmatched" {
t.Errorf("plain sink inputs[0] = %v, want docker-router:myapp._unmatched", got)
}
cronInputs := lookup(t, decoded, "sinks", "docker-cron-sink:myapp", "inputs")
if got := cronInputs.([]interface{})[0]; got != "docker-cron-remap:myapp" {
t.Errorf("cron sink inputs[0] = %v, want docker-cron-remap:myapp", got)
}
}
func TestBuildVectorConfigGlobalScope(t *testing.T) {
_, 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")
lookup(t, decoded, "sinks", "docker-global-cron-sink")
inputs := lookup(t, decoded, "sinks", "docker-global-sink", "inputs")
if got := inputs.([]interface{})[0]; got != "docker-global-router._unmatched" {
t.Errorf("global sink inputs[0] = %v, want docker-global-router._unmatched", got)
}
}
func TestBuildVectorConfigNoSinks(t *testing.T) {
raw, decoded := marshalConfig(t, []vectorAppSinks{appScope("", "")})
if strings.Contains(raw, "transforms") {
t.Errorf("config should not contain a transforms key:\n%s", raw)
}
lookup(t, decoded, "sources", "docker-null-source")
inputs := lookup(t, decoded, "sinks", "docker-null-sink", "inputs")
if got := inputs.([]interface{})[0]; got != "docker-null-source" {
t.Errorf("null sink inputs[0] = %v, want docker-null-source", got)
}
}
func TestBuildVectorConfigInvalidSink(t *testing.T) {
if _, err := buildVectorConfig([]vectorAppSinks{appScope("console://?sinks=nope", "")}); err == nil {
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)
}
if err := common.CreateDataDirectory("logs"); err != nil {
t.Fatalf("CreateDataDirectory: %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)
}
}
}
// setLogsProperty sets a logs property for the given scope and regenerates the
// config, standing in for the logs:set command
func setLogsProperty(t *testing.T, appName string, property string, value string) {
t.Helper()
if err := common.PropertyWrite("logs", appName, property, value); err != nil {
t.Fatalf("PropertyWrite %s %s: %v", appName, property, err)
}
if err := writeVectorConfig(); err != nil {
t.Fatalf("writeVectorConfig: %v", err)
}
}
// readVectorConfig decodes the generated config from disk
func readVectorConfig(t *testing.T) map[string]interface{} {
t.Helper()
b, err := os.ReadFile(filepath.Join(common.GetDataDirectory("logs"), "vector.json"))
if err != nil {
t.Fatalf("ReadFile vector.json: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("Unmarshal vector.json: %v", err)
}
return decoded
}
// assertComponents checks whether an app has a source and a sink in the config
func assertComponents(t *testing.T, decoded map[string]interface{}, appName string, want bool) {
t.Helper()
components := map[string]string{
"sources": fmt.Sprintf("docker-source:%s", appName),
"sinks": fmt.Sprintf("docker-sink:%s", appName),
}
for section, id := range components {
group, ok := lookup(t, decoded, section).(map[string]interface{})
if !ok {
t.Fatalf("%s is not a map", section)
}
if _, ok := group[id]; ok != want {
t.Errorf("%s[%q] exists = %v, want %v", section, id, ok, want)
}
}
}
// TestTriggerPostDeleteRewritesVectorConfig covers a destroyed app leaving its
// source and sink behind forever. post-delete fires before the app root is
// removed, so the app is still listed by UnfilteredDokkuApps at this point and
// only drops out because its properties are destroyed first.
func TestTriggerPostDeleteRewritesVectorConfig(t *testing.T) {
setupScopesTest(t, []string{"appa", "appb"})
setLogsProperty(t, "appa", "vector-sink", "console://?encoding[codec]=json")
setLogsProperty(t, "appb", "vector-sink", "console://?encoding[codec]=json")
assertComponents(t, readVectorConfig(t), "appa", true)
if err := TriggerPostDelete("appa"); err != nil {
t.Fatalf("TriggerPostDelete: %v", err)
}
decoded := readVectorConfig(t)
assertComponents(t, decoded, "appa", false)
assertComponents(t, decoded, "appb", true)
}
// TestTriggerPostAppRenameSetupRewritesVectorConfig covers the rename case: the
// sink property follows the app, so without a regeneration the surviving source
// filters on a label no container carries and the new name has no source at all.
func TestTriggerPostAppRenameSetupRewritesVectorConfig(t *testing.T) {
setupScopesTest(t, []string{"appa", "appa-renamed"})
setLogsProperty(t, "appa", "vector-sink", "console://?encoding[codec]=json")
if err := TriggerPostAppRenameSetup("appa", "appa-renamed"); err != nil {
t.Fatalf("TriggerPostAppRenameSetup: %v", err)
}
decoded := readVectorConfig(t)
assertComponents(t, decoded, "appa", false)
assertComponents(t, decoded, "appa-renamed", true)
labels := lookup(t, decoded, "sources", "docker-source:appa-renamed", "include_labels")
if got := labels.([]interface{})[0]; got != "com.dokku.app-name=appa-renamed" {
t.Errorf("include_labels[0] = %v, want com.dokku.app-name=appa-renamed", got)
}
}
// TestTriggerPostAppCloneSetupRewritesVectorConfig covers the clone case, where
// the clone inherits a sink but would otherwise have no source feeding it.
func TestTriggerPostAppCloneSetupRewritesVectorConfig(t *testing.T) {
setupScopesTest(t, []string{"appa", "appa-clone"})
setLogsProperty(t, "appa", "vector-sink", "console://?encoding[codec]=json")
if err := TriggerPostAppCloneSetup("appa", "appa-clone"); err != nil {
t.Fatalf("TriggerPostAppCloneSetup: %v", err)
}
decoded := readVectorConfig(t)
assertComponents(t, decoded, "appa", true)
assertComponents(t, decoded, "appa-clone", true)
}
// TestTriggerPostAppRenameSetupRewritesGlobalRelabel pins the generated VRL,
// which embeds app names directly as branches of the global relabel transform.
// A rename leaves a branch naming an app that no longer exists, so the renamed
// app ships under the global alias rather than its own.
func TestTriggerPostAppRenameSetupRewritesGlobalRelabel(t *testing.T) {
setupScopesTest(t, []string{"appa", "appa-renamed"})
setLogsProperty(t, "--global", "vector-sink", "console://?encoding[codec]=json")
setLogsProperty(t, "appa", "app-label-alias", "app_name")
source := lookup(t, readVectorConfig(t), "transforms", "docker-global-relabel", "source").(string)
if !strings.Contains(source, `app == "appa"`) {
t.Fatalf("relabel source %q missing the pre-rename branch", source)
}
if err := TriggerPostAppRenameSetup("appa", "appa-renamed"); err != nil {
t.Fatalf("TriggerPostAppRenameSetup: %v", err)
}
source = lookup(t, readVectorConfig(t), "transforms", "docker-global-relabel", "source").(string)
if strings.Contains(source, `app == "appa"`) {
t.Errorf("relabel source %q still names the old app", source)
}
if !strings.Contains(source, `app == "appa-renamed"`) {
t.Errorf("relabel source %q missing the renamed app", source)
}
}
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)
}
}
}