Files
dokku/plugins/common/properties.go
Jose Diaz-Gonzalez 70dc921967 fix: migrate env files before reading deprecated vars
Install steps run in alphabetical order of the enabled plugin directory, so `apps`, `builder`, and `checks` read an app's environment before the `config` plugin had moved the `ENV` file to its new location. The read came back empty, so their deprecated `DOKKU_*` variables were never migrated to the matching plugin property and were never unset, with nothing reported either way: `dokku config:show` kept listing the variable while the plugin behaved as though it were unset. The relocation now runs before any deprecated variable is read, whatever the install order, and each old file is removed as soon as it has been drained rather than on a later install, which also covers the global file that was never removed at all. A file that reappears at the old path can only have been written by hand, so it is merged in with a warning naming its keys instead of being discarded.
2026-08-07 16:43:56 -04:00

810 lines
23 KiB
Go

package common
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
)
// CommandPropertySet is a generic function that will set a property for a given plugin/app combination
func CommandPropertySet(pluginName, appName, property, value string, validProperties map[string]string, validGlobalProperties map[string]bool) {
if appName != "--global" {
if err := VerifyAppName(appName); err != nil {
LogFailWithError(err)
}
}
if appName == "--global" && !validGlobalProperties[property] {
LogFail("Property cannot be specified globally")
}
if property == "" {
LogFail("No property specified")
}
for k := range validGlobalProperties {
if _, ok := validProperties[k]; !ok {
validProperties[k] = ""
}
}
if _, ok := validProperties[property]; !ok {
properties := reflect.ValueOf(validProperties).MapKeys()
validPropertyList := make([]string, len(properties))
for i := 0; i < len(properties); i++ {
validPropertyList[i] = properties[i].String()
}
sort.Strings(validPropertyList)
LogFail(fmt.Sprintf("Invalid property specified, valid properties include: %s", strings.Join(validPropertyList, ", ")))
}
if value != "" {
LogInfo2Quiet(fmt.Sprintf("Setting %s to %s", property, value))
if err := PropertyWrite(pluginName, appName, property, value); err != nil {
LogFailWithError(err)
}
} else {
LogInfo2Quiet(fmt.Sprintf("Unsetting %s", property))
if err := PropertyDelete(pluginName, appName, property); err != nil {
LogFailWithError(err)
}
}
}
// PropertyClone clones a set of properties from one app to another
func PropertyClone(pluginName string, oldAppName string, newAppName string) error {
properties, err := PropertyGetAll(pluginName, oldAppName)
if err != nil {
return nil
}
for property, value := range properties {
if err := PropertyWrite(pluginName, newAppName, property, value); err != nil {
return err
}
}
return nil
}
// PropertyDelete deletes a property from the plugin properties for an app
func PropertyDelete(pluginName string, appName string, property string) error {
propertyPath := getPropertyPath(pluginName, appName, property)
if err := os.Remove(propertyPath); err != nil {
if !PropertyExists(pluginName, appName, property) {
return nil
}
return fmt.Errorf("Unable to remove %s property %s.%s", pluginName, appName, property)
}
return nil
}
// PropertyDestroy destroys the plugin properties for an app
func PropertyDestroy(pluginName string, appName string) error {
if appName == "_all_" {
pluginConfigPath := getPluginConfigPath(pluginName)
return os.RemoveAll(pluginConfigPath)
}
pluginAppConfigRoot := getPluginAppPropertyPath(pluginName, appName)
return os.RemoveAll(pluginAppConfigRoot)
}
// PropertyExists returns whether a property exists or not
func PropertyExists(pluginName string, appName string, property string) bool {
propertyPath := getPropertyPath(pluginName, appName, property)
_, err := os.Stat(propertyPath)
return !os.IsNotExist(err)
}
// PropertyGet returns the value for a given property
func PropertyGet(pluginName string, appName string, property string) string {
return PropertyGetDefault(pluginName, appName, property, "")
}
// PropertyGetAll returns a map of all properties for a given app
func PropertyGetAll(pluginName string, appName string) (map[string]string, error) {
properties := make(map[string]string)
pluginAppConfigRoot := getPluginAppPropertyPath(pluginName, appName)
fi, err := os.Stat(pluginAppConfigRoot)
if err != nil {
return properties, nil
}
if !fi.IsDir() {
return properties, errors.New("Specified property path is not a directory")
}
files, err := os.ReadDir(pluginAppConfigRoot)
if err != nil {
return properties, err
}
for _, file := range files {
if file.IsDir() {
continue
}
property := file.Name()
properties[property] = PropertyGet(pluginName, appName, property)
}
return properties, nil
}
// PropertyGetAllByPrefix returns a map of all properties for a given app with a specified prefix
func PropertyGetAllByPrefix(pluginName string, appName string, prefix string) (map[string]string, error) {
properties := make(map[string]string)
pluginAppConfigRoot := getPluginAppPropertyPath(pluginName, appName)
fi, err := os.Stat(pluginAppConfigRoot)
if err != nil {
return properties, nil
}
if !fi.IsDir() {
return properties, errors.New("Specified property path is not a directory")
}
files, err := os.ReadDir(pluginAppConfigRoot)
if err != nil {
return properties, err
}
for _, file := range files {
if file.IsDir() {
continue
}
property := file.Name()
if !strings.HasPrefix(property, prefix) {
continue
}
properties[property] = PropertyGet(pluginName, appName, property)
}
return properties, nil
}
// PropertyGetDefault returns the value for a given property with a specified default value
func PropertyGetDefault(pluginName, appName, property, defaultValue string) (val string) {
if !PropertyExists(pluginName, appName, property) {
val = defaultValue
return
}
propertyPath := getPropertyPath(pluginName, appName, property)
b, err := os.ReadFile(propertyPath)
if err != nil {
LogWarn(fmt.Sprintf("Unable to read %s property %s.%s", pluginName, appName, property))
return
}
val = string(b)
return
}
// PropertyListAdd adds a property to a list at an optionally specified index
func PropertyListAdd(pluginName string, appName string, property string, value string, index int) error {
if err := propertyTouch(pluginName, appName, property); err != nil {
return err
}
scannedLines, err := PropertyListGet(pluginName, appName, property)
if err != nil {
return err
}
value = strings.TrimSpace(value)
var lines []string
for i, line := range scannedLines {
if index != 0 && i == (index-1) {
lines = append(lines, value)
}
lines = append(lines, line)
}
if index == 0 || index > len(scannedLines) {
lines = append(lines, value)
}
return PropertyListWrite(pluginName, appName, property, lines)
}
// PropertyListWrite completely rewrites a list property
func PropertyListWrite(pluginName string, appName string, property string, values []string) error {
if err := propertyTouch(pluginName, appName, property); err != nil {
return err
}
propertyPath := getPropertyPath(pluginName, appName, property)
file, err := os.OpenFile(propertyPath, os.O_RDWR|os.O_TRUNC, 0600)
if err != nil {
return err
}
w := bufio.NewWriter(file)
for _, line := range values {
fmt.Fprintln(w, line)
}
if err = w.Flush(); err != nil {
return fmt.Errorf("Unable to write %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if err := file.Close(); err != nil {
return fmt.Errorf("Unable to close %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if err := SetPermissions(SetPermissionInput{
Filename: propertyPath,
Mode: os.FileMode(0600),
}); err != nil {
return fmt.Errorf("Unable to set permissions for %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
return nil
}
// PropertyListGet returns a property list
func PropertyListGet(pluginName string, appName string, property string) (lines []string, err error) {
if !PropertyExists(pluginName, appName, property) {
return lines, nil
}
propertyPath := getPropertyPath(pluginName, appName, property)
file, err := os.Open(propertyPath)
if err != nil {
return lines, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err = scanner.Err(); err != nil {
return lines, fmt.Errorf("Unable to read %s config value for %s.%s: %s", pluginName, appName, property, err.Error())
}
return lines, nil
}
// PropertyListLength returns the length of a property list
func PropertyListLength(pluginName string, appName string, property string) (length int, err error) {
if !PropertyExists(pluginName, appName, property) {
return length, nil
}
propertyPath := getPropertyPath(pluginName, appName, property)
file, err := os.Open(propertyPath)
if err != nil {
return length, err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err = scanner.Err(); err != nil {
return length, fmt.Errorf("Unable to read %s config value for %s.%s: %s", pluginName, appName, property, err.Error())
}
length = len(lines)
return length, nil
}
// PropertyListGetByIndex returns an entry within property list by index
func PropertyListGetByIndex(pluginName string, appName string, property string, index int) (propertyValue string, err error) {
lines, err := PropertyListGet(pluginName, appName, property)
if err != nil {
return
}
found := false
for i, line := range lines {
if i == index {
propertyValue = line
found = true
}
}
if !found {
err = errors.New("Index not found")
}
return
}
// PropertyListGetByValue returns an entry within property list by value
func PropertyListGetByValue(pluginName string, appName string, property string, value string) (propertyValue string, err error) {
lines, err := PropertyListGet(pluginName, appName, property)
if err != nil {
return
}
found := false
for _, line := range lines {
if line == value {
propertyValue = line
found = true
}
}
if !found {
err = errors.New("Value not found")
}
return
}
// PropertyListRemove removes a value from a property list
func PropertyListRemove(pluginName string, appName string, property string, value string) error {
lines, err := PropertyListGet(pluginName, appName, property)
if err != nil {
return err
}
propertyPath := getPropertyPath(pluginName, appName, property)
file, err := os.OpenFile(propertyPath, os.O_RDWR|os.O_TRUNC, 0600)
if err != nil {
return err
}
found := false
w := bufio.NewWriter(file)
for _, line := range lines {
if line == value {
found = true
continue
}
fmt.Fprintln(w, line)
}
if err = w.Flush(); err != nil {
return fmt.Errorf("Unable to write %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if err := file.Close(); err != nil {
return fmt.Errorf("Unable to close %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if err := SetPermissions(SetPermissionInput{
Filename: propertyPath,
Mode: os.FileMode(0600),
}); err != nil {
return fmt.Errorf("Unable to set permissions for %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if !found {
return errors.New("Property not found, nothing was removed")
}
return nil
}
// PropertyListRemoveByPrefix removes a value by prefix from a property list
func PropertyListRemoveByPrefix(pluginName string, appName string, property string, prefix string) error {
lines, err := PropertyListGet(pluginName, appName, property)
if err != nil {
return err
}
propertyPath := getPropertyPath(pluginName, appName, property)
file, err := os.OpenFile(propertyPath, os.O_RDWR|os.O_TRUNC, 0600)
if err != nil {
return err
}
found := false
w := bufio.NewWriter(file)
for _, line := range lines {
if strings.HasPrefix(line, prefix) {
found = true
continue
}
fmt.Fprintln(w, line)
}
if err = w.Flush(); err != nil {
return fmt.Errorf("Unable to write %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if err := file.Close(); err != nil {
return fmt.Errorf("Unable to close %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if err := SetPermissions(SetPermissionInput{
Filename: propertyPath,
Mode: os.FileMode(0600),
}); err != nil {
return fmt.Errorf("Unable to set permissions for %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if !found {
return errors.New("Property not found, nothing was removed")
}
return nil
}
// PropertyListSet sets a value within a property list at a specified index
func PropertyListSet(pluginName string, appName string, property string, value string, index int) error {
if err := propertyTouch(pluginName, appName, property); err != nil {
return err
}
scannedLines, err := PropertyListGet(pluginName, appName, property)
if err != nil {
return err
}
value = strings.TrimSpace(value)
var lines []string
if index >= len(scannedLines) {
lines = append(lines, scannedLines...)
lines = append(lines, value)
} else {
for i, line := range scannedLines {
if i == index {
lines = append(lines, value)
} else {
lines = append(lines, line)
}
}
}
return PropertyListWrite(pluginName, appName, property, lines)
}
// propertyTouch ensures a given application property file exists
func propertyTouch(pluginName string, appName string, property string) error {
if err := makePluginAppPropertyPath(pluginName, appName); err != nil {
return fmt.Errorf("Unable to create %s config directory for %s: %s", pluginName, appName, err.Error())
}
propertyPath := getPropertyPath(pluginName, appName, property)
if PropertyExists(pluginName, appName, property) {
return nil
}
file, err := os.Create(propertyPath)
if err != nil {
return fmt.Errorf("Unable to write %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
defer file.Close()
return nil
}
// PropertyWrite writes a value for a given application property
func PropertyWrite(pluginName string, appName string, property string, value string) error {
if err := propertyTouch(pluginName, appName, property); err != nil {
return err
}
propertyPath := getPropertyPath(pluginName, appName, property)
file, err := os.Create(propertyPath)
if err != nil {
return fmt.Errorf("Unable to write %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
defer file.Close()
fmt.Fprint(file, value)
if err := file.Close(); err != nil {
return fmt.Errorf("Unable to close %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
if err := SetPermissions(SetPermissionInput{
Filename: propertyPath,
Mode: os.FileMode(0600),
}); err != nil {
return fmt.Errorf("Unable to set permissions for %s config value %s.%s: %s", pluginName, appName, property, err.Error())
}
return nil
}
// PropertyMapWrite persists a string→string map for a given property as a single
// JSON file. Unlike PropertyWrite/PropertyListWrite, map *keys* may contain any
// bytes (including '/' or '\n') because they live inside file content rather
// than in the on-disk filename. A nil map is persisted as "{}".
func PropertyMapWrite(pluginName string, appName string, property string, m map[string]string) error {
if err := propertyTouch(pluginName, appName, property); err != nil {
return err
}
if m == nil {
m = map[string]string{}
}
b, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("Unable to marshal %s map property %s.%s: %w", pluginName, appName, property, err)
}
propertyPath := getPropertyPath(pluginName, appName, property)
if err := os.WriteFile(propertyPath, b, 0600); err != nil {
return fmt.Errorf("Unable to write %s map property %s.%s: %w", pluginName, appName, property, err)
}
if err := SetPermissions(SetPermissionInput{
Filename: propertyPath,
Mode: os.FileMode(0600),
}); err != nil {
return fmt.Errorf("Unable to set permissions for %s map property %s.%s: %w", pluginName, appName, property, err)
}
return nil
}
// PropertyMapGet returns the string→string map persisted for a property. A
// missing or empty-file property is reported as an empty map without error.
func PropertyMapGet(pluginName string, appName string, property string) (map[string]string, error) {
m := map[string]string{}
if !PropertyExists(pluginName, appName, property) {
return m, nil
}
b, err := os.ReadFile(getPropertyPath(pluginName, appName, property))
if err != nil {
return nil, fmt.Errorf("Unable to read %s map property %s.%s: %w", pluginName, appName, property, err)
}
if len(b) == 0 {
return m, nil
}
if err := json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("Unable to parse %s map property %s.%s as JSON: %w", pluginName, appName, property, err)
}
return m, nil
}
// PropertyMapSet reads the current map, sets one key, and writes the map back.
func PropertyMapSet(pluginName string, appName string, property string, key string, value string) error {
m, err := PropertyMapGet(pluginName, appName, property)
if err != nil {
return err
}
m[key] = value
return PropertyMapWrite(pluginName, appName, property, m)
}
// PropertyMapDelete reads the current map, removes one key, and writes the map
// back. Removing a key that is not present is a no-op.
func PropertyMapDelete(pluginName string, appName string, property string, key string) error {
if !PropertyExists(pluginName, appName, property) {
return nil
}
m, err := PropertyMapGet(pluginName, appName, property)
if err != nil {
return err
}
if _, ok := m[key]; !ok {
return nil
}
delete(m, key)
return PropertyMapWrite(pluginName, appName, property, m)
}
// PropertyMapLength returns the number of entries in the map persisted for a
// property. A missing property is reported as length 0 without error.
func PropertyMapLength(pluginName string, appName string, property string) (int, error) {
m, err := PropertyMapGet(pluginName, appName, property)
if err != nil {
return 0, err
}
return len(m), nil
}
// PropertySetup creates the plugin config root
func PropertySetup(pluginName string) error {
configRoot := filepath.Join(MustGetEnv("DOKKU_LIB_ROOT"), "config")
pluginConfigRoot := getPluginConfigPath(pluginName)
if err := os.MkdirAll(pluginConfigRoot, 0755); err != nil {
return err
}
// check if configRoot is a symlink
if !IsSymlink(configRoot) {
input := SetPermissionInput{
Filename: configRoot,
Mode: os.FileMode(0755),
}
if err := SetPermissions(input); err != nil {
return err
}
}
return SetPermissions(SetPermissionInput{
Filename: pluginConfigRoot,
Mode: os.FileMode(0755),
})
}
func PropertySetupApp(pluginName string, appName string) error {
if err := PropertySetup(pluginName); err != nil {
return err
}
if err := makePluginAppPropertyPath(pluginName, appName); err != nil {
return fmt.Errorf("Unable to create %s config directory for %s: %s", pluginName, appName, err.Error())
}
return nil
}
// MigrateConfigEntry describes a single config-to-property migration
type MigrateConfigEntry struct {
// ConfigVar is the per-app environment variable name (e.g. "DOKKU_APP_PROXY_TYPE")
ConfigVar string
// GlobalConfigVar is the global environment variable name (e.g. "DOKKU_PROXY_TYPE"), empty if none
GlobalConfigVar string
// Property is the target property name (e.g. "type")
Property string
// Transform is an optional value transformation function applied before writing
Transform func(value string) string
// ListProperty indicates the value should be written as a list property via PropertyListWrite
ListProperty bool
}
// MigrateConfigToProperties migrates config variables to properties for a given plugin
// across all apps and optionally globally. It is idempotent: if the property already
// exists, the migration is skipped for that app/entry.
func MigrateConfigToProperties(pluginName string, entries []MigrateConfigEntry) error {
migrateLegacyEnvFiles()
apps, err := UnfilteredDokkuApps()
if err != nil && !errors.Is(err, NoAppsExist) {
return nil
}
for _, entry := range entries {
if entry.GlobalConfigVar != "" {
if err := migrateConfigEntry(pluginName, "--global", entry.GlobalConfigVar, entry); err != nil {
return err
}
}
for _, appName := range apps {
if entry.ConfigVar == "" {
continue
}
if err := migrateConfigEntry(pluginName, appName, entry.ConfigVar, entry); err != nil {
return err
}
}
}
return nil
}
// migrateLegacyEnvFiles drains the pre-0.38 ENV files into the config property
// path before any config var is read.
//
// Install triggers fire in lexicographic order of the enabled-plugin directory
// names, so plugins sorting before "config" - apps, builder and checks among
// them - would otherwise read an environment the config plugin has not
// relocated yet, find it empty, and migrate nothing without reporting anything.
// A failure here is not fatal: the migration is retried on the next install, so
// an unavailable trigger degrades to the previous behavior rather than aborting
// every plugin's install.
func migrateLegacyEnvFiles() {
if _, err := CallPlugnTrigger(PlugnTriggerInput{
Trigger: "config-migrate-env",
}); err != nil {
LogWarn(fmt.Sprintf("Unable to migrate legacy env files: %s", err.Error()))
}
}
// migrateConfigEntry migrates a single config variable to a property for a given app
func migrateConfigEntry(pluginName string, appName string, configVar string, entry MigrateConfigEntry) error {
if entry.ListProperty {
if exists, _ := PropertyListLength(pluginName, appName, entry.Property); exists > 0 {
return nil
}
} else if PropertyExists(pluginName, appName, entry.Property) {
return nil
}
triggerName := "config-get"
triggerArgs := []string{appName, configVar}
if appName == "--global" {
triggerName = "config-get-global"
triggerArgs = []string{configVar}
}
results, _ := CallPlugnTrigger(PlugnTriggerInput{
Trigger: triggerName,
Args: triggerArgs,
})
value := results.StdoutContents()
if value == "" {
return nil
}
if entry.Transform != nil {
value = entry.Transform(value)
}
if appName == "--global" {
LogInfo1(fmt.Sprintf("Migrating deprecated global %s to %s %s property. Use 'dokku %s:set --global %s <value>' to manage this going forward.", configVar, pluginName, entry.Property, pluginName, entry.Property))
} else {
LogInfo1(fmt.Sprintf("Migrating deprecated %s to %s %s property for %s. Use 'dokku %s:set %s %s <value>' to manage this going forward.", configVar, pluginName, entry.Property, appName, pluginName, appName, entry.Property))
}
if entry.ListProperty {
values := strings.Split(value, " ")
if err := PropertyListWrite(pluginName, appName, entry.Property, values); err != nil {
return err
}
} else {
if err := PropertyWrite(pluginName, appName, entry.Property, value); err != nil {
return err
}
}
unsetTrigger := "config-unset"
unsetArgs := []string{appName, configVar}
if appName == "--global" {
unsetArgs = []string{"--global", configVar}
}
_, err := CallPlugnTrigger(PlugnTriggerInput{
Trigger: unsetTrigger,
Args: unsetArgs,
Env: map[string]string{
"DOKKU_QUIET_OUTPUT": "1",
},
})
if err != nil {
LogWarn(err.Error())
}
return nil
}
func getPropertyPath(pluginName string, appName string, property string) string {
pluginAppConfigRoot := getPluginAppPropertyPath(pluginName, appName)
return filepath.Join(pluginAppConfigRoot, property)
}
// getPluginAppPropertyPath returns the plugin property path for a given plugin/app combination
func getPluginAppPropertyPath(pluginName string, appName string) string {
return filepath.Join(getPluginConfigPath(pluginName), appName)
}
// getPluginConfigPath returns the plugin property path for a given plugin
func getPluginConfigPath(pluginName string) string {
return filepath.Join(MustGetEnv("DOKKU_LIB_ROOT"), "config", pluginName)
}
// makePluginAppPropertyPath ensures that a property path exists
func makePluginAppPropertyPath(pluginName string, appName string) error {
pluginAppConfigRoot := getPluginAppPropertyPath(pluginName, appName)
if err := os.MkdirAll(pluginAppConfigRoot, 0755); err != nil {
return err
}
return SetPermissions(SetPermissionInput{
Filename: pluginAppConfigRoot,
Mode: os.FileMode(0755),
})
}