mirror of
https://github.com/dokku/dokku.git
synced 2026-06-01 18:58:03 +02:00
fix: persist scheduler-k3s annotations and labels as JSON maps
Annotation and label values containing a newline previously round-tripped as two adjacent `key: value` lines, and the second line then failed the `SplitN(line, ": ", 2)` parse on read. Move the per-`(processType, resourceType)` files to `PropertyMap*` storage so `\n` in values and `/` in keys are preserved verbatim. An idempotent `TriggerInstall` migration walks `--global` and every app, converting any legacy line-formatted file in place by probing `PropertyMapGet` first and rewriting via `PropertyListGet` only when the probe fails. Property names are unchanged, so the annotations/labels report scanner and the `reservedAnnotationPrefixes` filter keep working without modification. Closes #8719.
This commit is contained in:
@@ -376,6 +376,8 @@ The following resource types are supported:
|
||||
- `traefik_ingressroute`
|
||||
- `traefik_middleware`
|
||||
|
||||
Annotation keys may contain `/` (e.g. Kubernetes-style keys such as `prometheus.io/scrape`) and values may span multiple lines; both are preserved verbatim.
|
||||
|
||||
A `ps:restart` is required after setting annotations in order to have them apply to running resources.
|
||||
|
||||
#### Removing an annotation
|
||||
@@ -446,6 +448,8 @@ The following resource types are supported:
|
||||
- `traefik_ingressroute`
|
||||
- `traefik_middleware`
|
||||
|
||||
Label keys may contain `/` (e.g. Kubernetes-style keys such as `app.kubernetes.io/part-of`) and values may span multiple lines; both are preserved verbatim.
|
||||
|
||||
A `ps:restart` is required after setting labels in order to have them apply to running resources.
|
||||
|
||||
#### Removing a label
|
||||
|
||||
@@ -775,22 +775,7 @@ func getGlobalAnnotations(appName string) (ProcessAnnotations, error) {
|
||||
|
||||
// getAnnotation retrieves an annotation for a given app, process type, and resource type
|
||||
func getAnnotation(appName string, processType string, resourceType string) (map[string]string, error) {
|
||||
annotations := map[string]string{}
|
||||
annotationsList, err := common.PropertyListGet("scheduler-k3s", appName, fmt.Sprintf("%s.%s", processType, resourceType))
|
||||
if err != nil {
|
||||
return annotations, err
|
||||
}
|
||||
|
||||
for _, annotation := range annotationsList {
|
||||
parts := strings.SplitN(annotation, ": ", 2)
|
||||
if len(parts) != 2 {
|
||||
return annotations, fmt.Errorf("Invalid annotation format: %s", annotation)
|
||||
}
|
||||
|
||||
annotations[parts[0]] = parts[1]
|
||||
}
|
||||
|
||||
return annotations, nil
|
||||
return common.PropertyMapGet("scheduler-k3s", appName, fmt.Sprintf("%s.%s", processType, resourceType))
|
||||
}
|
||||
|
||||
func getDeployTimeout(appName string) string {
|
||||
@@ -1216,24 +1201,9 @@ func getGlobalLabel(appName string) (ProcessLabels, error) {
|
||||
return getLabels(appName, GlobalProcessType)
|
||||
}
|
||||
|
||||
// getLabel retrieves an label for a given app, process type, and resource type
|
||||
// getLabel retrieves a label for a given app, process type, and resource type
|
||||
func getLabel(appName string, processType string, resourceType string) (map[string]string, error) {
|
||||
labels := map[string]string{}
|
||||
labelsList, err := common.PropertyListGet("scheduler-k3s", appName, fmt.Sprintf("labels.%s.%s", processType, resourceType))
|
||||
if err != nil {
|
||||
return labels, err
|
||||
}
|
||||
|
||||
for _, label := range labelsList {
|
||||
parts := strings.SplitN(label, ": ", 2)
|
||||
if len(parts) != 2 {
|
||||
return labels, fmt.Errorf("Invalid label format: %s", label)
|
||||
}
|
||||
|
||||
labels[parts[0]] = parts[1]
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
return common.PropertyMapGet("scheduler-k3s", appName, fmt.Sprintf("labels.%s.%s", processType, resourceType))
|
||||
}
|
||||
|
||||
func getLetsencryptServer(appName string) string {
|
||||
|
||||
@@ -31,31 +31,15 @@ func CommandAnnotationsSet(appName string, processType string, resourceType stri
|
||||
}
|
||||
|
||||
property := fmt.Sprintf("%s.%s", processType, resourceType)
|
||||
annotationsList, err := common.PropertyListGet("scheduler-k3s", appName, property)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to get property list: %w", err)
|
||||
}
|
||||
|
||||
annotations := []string{}
|
||||
for _, annotation := range annotationsList {
|
||||
parts := strings.SplitN(annotation, ": ", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("Invalid annotation: %s", annotation)
|
||||
if value == "" {
|
||||
if err := common.PropertyMapDelete("scheduler-k3s", appName, property, key); err != nil {
|
||||
return fmt.Errorf("Unable to delete property map entry: %w", err)
|
||||
}
|
||||
if key == parts[0] {
|
||||
continue
|
||||
}
|
||||
|
||||
annotations = append(annotations, annotation)
|
||||
return nil
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
annotations = append(annotations, fmt.Sprintf("%s: %s", key, value))
|
||||
}
|
||||
|
||||
sort.Strings(annotations)
|
||||
if err := common.PropertyListWrite("scheduler-k3s", appName, property, annotations); err != nil {
|
||||
return fmt.Errorf("Unable to write property list: %w", err)
|
||||
if err := common.PropertyMapSet("scheduler-k3s", appName, property, key, value); err != nil {
|
||||
return fmt.Errorf("Unable to set property map entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1183,31 +1167,15 @@ func CommandLabelsSet(appName string, processType string, resourceType string, k
|
||||
}
|
||||
|
||||
property := fmt.Sprintf("labels.%s.%s", processType, resourceType)
|
||||
labelsList, err := common.PropertyListGet("scheduler-k3s", appName, property)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to get property list: %w", err)
|
||||
}
|
||||
|
||||
labels := []string{}
|
||||
for _, annotation := range labelsList {
|
||||
parts := strings.SplitN(annotation, ": ", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("Invalid annotation: %s", annotation)
|
||||
if value == "" {
|
||||
if err := common.PropertyMapDelete("scheduler-k3s", appName, property, key); err != nil {
|
||||
return fmt.Errorf("Unable to delete property map entry: %w", err)
|
||||
}
|
||||
if key == parts[0] {
|
||||
continue
|
||||
}
|
||||
|
||||
labels = append(labels, annotation)
|
||||
return nil
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
labels = append(labels, fmt.Sprintf("%s: %s", key, value))
|
||||
}
|
||||
|
||||
sort.Strings(labels)
|
||||
if err := common.PropertyListWrite("scheduler-k3s", appName, property, labels); err != nil {
|
||||
return fmt.Errorf("Unable to write property list: %w", err)
|
||||
if err := common.PropertyMapSet("scheduler-k3s", appName, property, key, value); err != nil {
|
||||
return fmt.Errorf("Unable to set property map entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -79,6 +79,10 @@ func TriggerInstall() error {
|
||||
return fmt.Errorf("Unable to migrate chart properties: %w", err)
|
||||
}
|
||||
|
||||
if err := migrateAnnotationsLabelsToMapFormat(); err != nil {
|
||||
return fmt.Errorf("Unable to migrate annotations and labels: %w", err)
|
||||
}
|
||||
|
||||
if err := syncExistingCertificates(); err != nil {
|
||||
common.LogWarn(fmt.Sprintf("Warning: failed to sync existing certificates: %v", err))
|
||||
}
|
||||
@@ -126,6 +130,105 @@ func migrateChartPropertiesToMapFormat() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateAnnotationsLabelsToMapFormat converts the legacy line-formatted
|
||||
// annotation and label property files ("key: value" per line) into JSON maps
|
||||
// written via PropertyMapWrite. The property name (e.g. "web.deployment",
|
||||
// "labels.global.service") is unchanged - only the file content layout shifts.
|
||||
// Without this migration, post-upgrade reads via PropertyMapGet would fail to
|
||||
// parse the legacy line format as JSON. Idempotent: any property whose content
|
||||
// is already JSON (or empty) is skipped by probing PropertyMapGet first.
|
||||
func migrateAnnotationsLabelsToMapFormat() error {
|
||||
annotationResources := map[string]bool{}
|
||||
for _, rt := range AnnotationResourceTypes {
|
||||
annotationResources[rt] = true
|
||||
}
|
||||
labelResources := map[string]bool{}
|
||||
for _, rt := range LabelResourceTypes {
|
||||
labelResources[rt] = true
|
||||
}
|
||||
|
||||
scopes := []string{"--global"}
|
||||
apps, err := common.UnfilteredDokkuApps()
|
||||
if err != nil && !errors.Is(err, common.NoAppsExist) {
|
||||
return fmt.Errorf("Unable to list apps for annotation/label migration: %w", err)
|
||||
}
|
||||
scopes = append(scopes, apps...)
|
||||
|
||||
for _, scope := range scopes {
|
||||
properties, err := common.PropertyGetAllByPrefix("scheduler-k3s", scope, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to list properties for %s: %w", scope, err)
|
||||
}
|
||||
|
||||
for propertyName := range properties {
|
||||
isLabel := strings.HasPrefix(propertyName, "labels.")
|
||||
checkName := propertyName
|
||||
if isLabel {
|
||||
checkName = strings.TrimPrefix(propertyName, "labels.")
|
||||
} else if isReservedAnnotationProperty(propertyName) {
|
||||
continue
|
||||
}
|
||||
|
||||
dot := strings.LastIndex(checkName, ".")
|
||||
if dot <= 0 || dot == len(checkName)-1 {
|
||||
continue
|
||||
}
|
||||
|
||||
processType := checkName[:dot]
|
||||
resourceType := checkName[dot+1:]
|
||||
if processType == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if isLabel {
|
||||
if !labelResources[resourceType] {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if !annotationResources[resourceType] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateAnnotationLabelProperty(scope, propertyName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateAnnotationLabelProperty rewrites one legacy line-formatted property
|
||||
// file as a JSON map. It is a no-op when the file is already valid JSON or
|
||||
// empty (idempotent).
|
||||
func migrateAnnotationLabelProperty(scope string, propertyName string) error {
|
||||
if _, err := common.PropertyMapGet("scheduler-k3s", scope, propertyName); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines, err := common.PropertyListGet("scheduler-k3s", scope, propertyName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to read legacy property %s/%s: %w", scope, propertyName, err)
|
||||
}
|
||||
|
||||
m := map[string]string{}
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ": ", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("Invalid legacy entry in %s/%s: %s", scope, propertyName, line)
|
||||
}
|
||||
m[parts[0]] = parts[1]
|
||||
}
|
||||
|
||||
if err := common.PropertyMapWrite("scheduler-k3s", scope, propertyName, m); err != nil {
|
||||
return fmt.Errorf("Unable to write %s/%s as map: %w", scope, propertyName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerPostCertsUpdate handles post-certs-update trigger
|
||||
func TriggerPostCertsUpdate(appName string) error {
|
||||
scheduler := common.PropertyGetDefault("scheduler", appName, "selected", "")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package scheduler_k3s
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/dokku/dokku/plugins/common"
|
||||
@@ -18,6 +21,28 @@ func setupChartMigrationTest(t *testing.T) {
|
||||
Expect(common.PropertySetup("scheduler-k3s")).To(Succeed())
|
||||
}
|
||||
|
||||
func setupAnnotationsLabelsMigrationTest(t *testing.T, apps ...string) {
|
||||
t.Helper()
|
||||
setupChartMigrationTest(t)
|
||||
dokkuRoot := t.TempDir()
|
||||
t.Setenv("DOKKU_ROOT", dokkuRoot)
|
||||
for _, appName := range apps {
|
||||
Expect(os.MkdirAll(filepath.Join(dokkuRoot, appName), 0755)).To(Succeed())
|
||||
}
|
||||
}
|
||||
|
||||
// writeLegacyAnnotationLabelFile seeds a property file directly using
|
||||
// PropertyListWrite, matching the pre-migration on-disk layout (one
|
||||
// "key: value" entry per line).
|
||||
func writeLegacyAnnotationLabelFile(t *testing.T, scope string, property string, entries map[string]string) {
|
||||
t.Helper()
|
||||
lines := []string{}
|
||||
for k, v := range entries {
|
||||
lines = append(lines, k+": "+v)
|
||||
}
|
||||
Expect(common.PropertyListWrite("scheduler-k3s", scope, property, lines)).To(Succeed())
|
||||
}
|
||||
|
||||
func TestMigrateChartPropertiesToMapFormat(t *testing.T) {
|
||||
setupChartMigrationTest(t)
|
||||
|
||||
@@ -86,3 +111,86 @@ func TestMigrateChartPropertiesToMapFormatNoLegacyIsNoOp(t *testing.T) {
|
||||
Expect(common.PropertyExists("scheduler-k3s", "--global", "chart-overrides."+chart.ReleaseName)).To(BeFalse())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAnnotationsLabelsToMapFormat(t *testing.T) {
|
||||
setupAnnotationsLabelsMigrationTest(t, "node-js-app")
|
||||
|
||||
writeLegacyAnnotationLabelFile(t, "node-js-app", "web.deployment", map[string]string{
|
||||
"prometheus.io/scrape": "true",
|
||||
"team": "platform",
|
||||
})
|
||||
writeLegacyAnnotationLabelFile(t, "node-js-app", "labels.global.service", map[string]string{
|
||||
"app.kubernetes.io/managed-by": "dokku",
|
||||
})
|
||||
writeLegacyAnnotationLabelFile(t, "--global", "global.deployment", map[string]string{
|
||||
"owner": "ops",
|
||||
})
|
||||
|
||||
Expect(migrateAnnotationsLabelsToMapFormat()).To(Succeed())
|
||||
|
||||
appAnnotations, err := common.PropertyMapGet("scheduler-k3s", "node-js-app", "web.deployment")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(appAnnotations).To(Equal(map[string]string{
|
||||
"prometheus.io/scrape": "true",
|
||||
"team": "platform",
|
||||
}))
|
||||
|
||||
appLabels, err := common.PropertyMapGet("scheduler-k3s", "node-js-app", "labels.global.service")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(appLabels).To(Equal(map[string]string{
|
||||
"app.kubernetes.io/managed-by": "dokku",
|
||||
}))
|
||||
|
||||
globalAnnotations, err := common.PropertyMapGet("scheduler-k3s", "--global", "global.deployment")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(globalAnnotations).To(Equal(map[string]string{"owner": "ops"}))
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(common.MustGetEnv("DOKKU_LIB_ROOT"), "config", "scheduler-k3s", "node-js-app", "web.deployment"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
var decoded map[string]string
|
||||
Expect(json.Unmarshal(raw, &decoded)).To(Succeed())
|
||||
Expect(decoded).To(Equal(appAnnotations))
|
||||
}
|
||||
|
||||
func TestMigrateAnnotationsLabelsToMapFormatIsIdempotent(t *testing.T) {
|
||||
setupAnnotationsLabelsMigrationTest(t, "node-js-app")
|
||||
|
||||
writeLegacyAnnotationLabelFile(t, "node-js-app", "web.deployment", map[string]string{
|
||||
"prometheus.io/scrape": "true",
|
||||
})
|
||||
|
||||
Expect(migrateAnnotationsLabelsToMapFormat()).To(Succeed())
|
||||
Expect(migrateAnnotationsLabelsToMapFormat()).To(Succeed())
|
||||
|
||||
got, err := common.PropertyMapGet("scheduler-k3s", "node-js-app", "web.deployment")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got).To(Equal(map[string]string{"prometheus.io/scrape": "true"}))
|
||||
}
|
||||
|
||||
func TestMigrateAnnotationsLabelsToMapFormatPreservesAlreadyMigrated(t *testing.T) {
|
||||
setupAnnotationsLabelsMigrationTest(t, "node-js-app")
|
||||
|
||||
preserved := map[string]string{
|
||||
"prometheus.io/scrape": "true",
|
||||
"controller.config": "line one\nline two\nline three",
|
||||
}
|
||||
Expect(common.PropertyMapWrite("scheduler-k3s", "node-js-app", "web.deployment", preserved)).To(Succeed())
|
||||
|
||||
Expect(migrateAnnotationsLabelsToMapFormat()).To(Succeed())
|
||||
|
||||
got, err := common.PropertyMapGet("scheduler-k3s", "node-js-app", "web.deployment")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got).To(Equal(preserved))
|
||||
}
|
||||
|
||||
func TestMigrateAnnotationsLabelsToMapFormatSkipsReservedPrefixes(t *testing.T) {
|
||||
setupAnnotationsLabelsMigrationTest(t)
|
||||
|
||||
Expect(common.PropertyWrite("scheduler-k3s", "--global", "chart.cert-manager.deployment", "irrelevant")).To(Succeed())
|
||||
Expect(common.PropertyWrite("scheduler-k3s", "--global", "node-profile-foo.json", "{}")).To(Succeed())
|
||||
|
||||
Expect(migrateAnnotationsLabelsToMapFormat()).To(Succeed())
|
||||
|
||||
Expect(common.PropertyGet("scheduler-k3s", "--global", "chart.cert-manager.deployment")).To(Equal("irrelevant"))
|
||||
Expect(common.PropertyGet("scheduler-k3s", "--global", "node-profile-foo.json")).To(Equal("{}"))
|
||||
}
|
||||
|
||||
@@ -86,3 +86,13 @@ teardown() {
|
||||
assert_output_contains "app1-val"
|
||||
assert_output_contains "app2-val"
|
||||
}
|
||||
|
||||
@test "(scheduler-k3s:annotations:set) preserves multi-line values and / in keys" {
|
||||
local value=$'line one\nline two\nline three'
|
||||
run /bin/bash -c "dokku scheduler-k3s:annotations:set $TEST_APP --resource-type deployment prometheus.io/scrape \"$value\""
|
||||
assert_success
|
||||
|
||||
run /bin/bash -c "dokku scheduler-k3s:annotations:report $TEST_APP --format json | jq -r '.\"global.deployment.prometheus.io/scrape\"'"
|
||||
assert_success
|
||||
assert_output "$value"
|
||||
}
|
||||
|
||||
@@ -86,3 +86,13 @@ teardown() {
|
||||
assert_output_contains "app1-val"
|
||||
assert_output_contains "app2-val"
|
||||
}
|
||||
|
||||
@test "(scheduler-k3s:labels:set) preserves multi-line values and / in keys" {
|
||||
local value=$'line one\nline two\nline three'
|
||||
run /bin/bash -c "dokku scheduler-k3s:labels:set $TEST_APP --resource-type deployment app.kubernetes.io/part-of \"$value\""
|
||||
assert_success
|
||||
|
||||
run /bin/bash -c "dokku scheduler-k3s:labels:report $TEST_APP --format json | jq -r '.\"global.deployment.app.kubernetes.io/part-of\"'"
|
||||
assert_success
|
||||
assert_output "$value"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user