v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s
gates / gates (push) Successful in 44s
This commit is contained in:
@@ -3,6 +3,7 @@ package appbackup
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -80,3 +81,121 @@ func DBServiceNames(composePath string) ([]string, error) {
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// dbCredentialKeyRe matches a database service's credential variable (POSTGRES_PASSWORD, MARIADB_ROOT_PASSWORD,
|
||||
// MYSQL_PASSWORD, …): the engine images read these ONLY when they initialise an empty data directory, so once a
|
||||
// restore puts a data directory back, the value that matches it is the one it was created with (R-1106).
|
||||
var dbCredentialKeyRe = regexp.MustCompile(`^(POSTGRES|PG|MARIADB|MYSQL)_[A-Z_]*PASSWORD$`)
|
||||
|
||||
// interpRefRe pulls the variable names out of a compose value: ${NAME}, ${NAME:-x}, ${NAME-x}, $NAME.
|
||||
var interpRefRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:]?[-?+][^}]*)?\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
type composeDBEnvDoc struct {
|
||||
Services map[string]struct {
|
||||
Image string `yaml:"image"`
|
||||
Environment yaml.Node `yaml:"environment"`
|
||||
} `yaml:"services"`
|
||||
}
|
||||
|
||||
// DBCredentialVars (R-1106, `07` §7.4 — operator decision 1A, 2026-10-16) returns the sorted, unique app.yaml
|
||||
// variable names that feed a database service's credential variables in composePath — e.g. rallly's
|
||||
// `POSTGRES_PASSWORD=${DB_PASSWORD}` → DB_PASSWORD. These are the values a restore must put back WITH the data it puts
|
||||
// back: a fresh install's generated value does not open a restored data directory. A literal credential (no
|
||||
// variable) yields nothing — there is nothing to carry. Same parse and same DB predicate as DBServiceNames, so the
|
||||
// two cannot disagree about which services hold a database. Pinned by TestR1106_DBCredentialVars*.
|
||||
func DBCredentialVars(composePath string) ([]string, error) {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading compose file: %w", err)
|
||||
}
|
||||
var doc composeDBEnvDoc
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
add := func(key, val string) {
|
||||
if !dbCredentialKeyRe.MatchString(key) {
|
||||
return
|
||||
}
|
||||
for _, m := range interpRefRe.FindAllStringSubmatch(val, -1) {
|
||||
n := m[1]
|
||||
if n == "" {
|
||||
n = m[2]
|
||||
}
|
||||
seen[n] = true
|
||||
}
|
||||
}
|
||||
for _, svc := range doc.Services {
|
||||
if _, ok := dbTypeForImage(svc.Image); !ok {
|
||||
continue
|
||||
}
|
||||
env := svc.Environment
|
||||
switch env.Kind {
|
||||
case yaml.SequenceNode: // - KEY=value
|
||||
for _, it := range env.Content {
|
||||
if k, v, ok := strings.Cut(it.Value, "="); ok {
|
||||
add(strings.TrimSpace(k), v)
|
||||
}
|
||||
}
|
||||
case yaml.MappingNode: // KEY: value
|
||||
for i := 0; i+1 < len(env.Content); i += 2 {
|
||||
add(env.Content[i].Value, env.Content[i+1].Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type composeDBVolDoc struct {
|
||||
Services map[string]struct {
|
||||
Image string `yaml:"image"`
|
||||
Volumes []interface{} `yaml:"volumes"`
|
||||
} `yaml:"services"`
|
||||
}
|
||||
|
||||
// DBDataVolumes (R-1106) returns the sorted NAMED volumes (not binds) that a database service in composePath mounts —
|
||||
// its data directory. A restore that replays one of these puts back a data directory that only its own password
|
||||
// opens; a restore that replays only a logical dump does not, and the current password stays right.
|
||||
func DBDataVolumes(composePath string) ([]string, error) {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading compose file: %w", err)
|
||||
}
|
||||
var doc composeDBVolDoc
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, svc := range doc.Services {
|
||||
if _, ok := dbTypeForImage(svc.Image); !ok {
|
||||
continue
|
||||
}
|
||||
for _, v := range svc.Volumes {
|
||||
var src string
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
src, _, _ = strings.Cut(t, ":")
|
||||
case map[string]interface{}:
|
||||
if typ, _ := t["type"].(string); typ != "" && typ != "volume" {
|
||||
continue
|
||||
}
|
||||
src, _ = t["source"].(string)
|
||||
}
|
||||
if src == "" || strings.ContainsAny(src, "/.$~") {
|
||||
continue // a bind or an interpolated path — not a named volume
|
||||
}
|
||||
seen[src] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user