A module may hold several secrets from one provider, each a pair of its own

secrets: maps a requirement to several files under local names. Each local name is
its own need, its own pair credential (the pair is keyed on it: migration 0027),
its own file on the consumer, its own holder at the provider (the identity with the
local name after it) and rotates apart from the others. The plain shape is
unchanged and every existing row is the credential it was (novox/hq 04-ISSUES/069,
ADR 0094).
This commit is contained in:
2026-09-21 20:28:16 +02:00
parent f3bfc11565
commit 6ae4ae1dba
13 changed files with 567 additions and 142 deletions
+181 -7
View File
@@ -278,6 +278,14 @@ type Manifest struct {
// makes `restart-on` precise.
Secrets map[string]string `json:"secrets,omitempty"`
// SecretsMany is the same key, `secrets`, where a requirement maps to SEVERAL files under local
// names — `"secret": {"admin": "/…/admin", "token": "/…/token"}` — because a module may need
// more than one value from a provider that gives one per pair (novox/hq 04-ISSUES/069, ADR
// 0094). Each local name is a pair credential of its own, keyed on that name, delivered as its
// own file, served to the provider as its own holder, and rotated with the others. Filled from
// the manifest's `secrets` object by UnmarshalJSON; never written by hand.
SecretsMany map[string]map[string]string `json:"-"`
// OwnSecrets are secrets this module needs in order to be itself, and where to put them.
//
// **Named for whose they are, not how secret they are.** `secrets` above is a credential for
@@ -619,6 +627,134 @@ func ReceivedID(requirement string) string { return "received-" + requirement }
//
// Every problem is reported rather than the first, because somebody writing a manifest fixes
// them in one pass or in four.
// manifestFields is Manifest without its methods, so the JSON methods below can use the ordinary
// field decoding for everything but `secrets`.
type manifestFields Manifest
// UnmarshalJSON reads `secrets` in both of its shapes — a path, or an object of local names to
// paths (ADR 0094) — and everything else exactly as the fields declare, unknown keys refused.
func (m *Manifest) UnmarshalJSON(raw []byte) error {
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return err
}
plain := map[string]string{}
many := map[string]map[string]string{}
if secrets, ok := keys["secrets"]; ok && string(secrets) != "null" {
var byName map[string]json.RawMessage
if err := json.Unmarshal(secrets, &byName); err != nil {
return fmt.Errorf("secrets: an object of requirement to path, or to {local name: path}: %w", err)
}
for to, v := range byName {
switch {
case len(v) > 0 && v[0] == '"':
var path string
if err := json.Unmarshal(v, &path); err != nil {
return err
}
plain[to] = path
case len(v) > 0 && v[0] == '{':
var paths map[string]string
if err := json.Unmarshal(v, &paths); err != nil {
return fmt.Errorf("secrets.%s: an object of local name to path: %w", to, err)
}
many[to] = paths
default:
return fmt.Errorf("secrets.%s: a path, or an object of local name to path, not %s", to, v)
}
}
delete(keys, "secrets")
}
rest, err := json.Marshal(keys)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(rest))
decoder.DisallowUnknownFields()
var fields manifestFields
if err := decoder.Decode(&fields); err != nil {
return err
}
*m = Manifest(fields)
if len(plain) > 0 {
m.Secrets = plain
}
if len(many) > 0 {
m.SecretsMany = many
}
return nil
}
// MarshalJSON writes `secrets` back in the shape it was read: paths, and objects of local names.
func (m Manifest) MarshalJSON() ([]byte, error) {
raw, err := json.Marshal(manifestFields(m))
if err != nil {
return nil, err
}
if len(m.SecretsMany) == 0 {
return raw, nil
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, err
}
merged := map[string]any{}
for to, path := range m.Secrets {
merged[to] = path
}
for to, paths := range m.SecretsMany {
merged[to] = paths
}
secrets, err := json.Marshal(merged)
if err != nil {
return nil, err
}
keys["secrets"] = secrets
return json.Marshal(keys)
}
// SecretFile is one file a module is given a credential in: the local name it goes by inside
// the module (empty for the ordinary one-file case, where the requirement's name serves) and where.
type SecretFile struct {
Local string
Path string
}
// SecretFiles is every file a module wants the credential for one requirement in, in a stable
// order: the plain path as one entry with no local name, or one entry per local name.
func (m Manifest) SecretFiles(to string) []SecretFile {
if path, ok := m.Secrets[to]; ok {
return []SecretFile{{Path: path}}
}
paths := m.SecretsMany[to]
out := make([]SecretFile, 0, len(paths))
for _, local := range sortedKeys(paths) {
out = append(out, SecretFile{Local: local, Path: paths[local]})
}
return out
}
// SecretRequirements is every requirement this module wants a credential file for, sorted.
func (m Manifest) SecretRequirements() []string {
seen := map[string]bool{}
for to := range m.Secrets {
seen[to] = true
}
for to := range m.SecretsMany {
seen[to] = true
}
return sortedKeys(seen)
}
// SecretLocal is the name a credential goes by inside the module: the local name where the
// requirement maps to several, else the requirement itself. It is what `${secret:<name>}` says.
func SecretLocal(to, local string) string {
if local == "" {
return to
}
return local
}
func ParseManifest(raw []byte) (Manifest, error) {
var m Manifest
// Strictly. **An unknown key is refused**, which is the discipline the host's declaration
@@ -908,11 +1044,47 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, m.Module+" needs a secret with no name")
}
}
for to, where := range m.Secrets {
if !strings.HasPrefix(where, "/") {
problems = append(problems, fmt.Sprintf(
"%s keeps the credential for %q at %q, which is not an absolute path",
m.Module, to, where))
for _, to := range m.SecretRequirements() {
if _, plain := m.Secrets[to]; plain {
if _, also := m.SecretsMany[to]; also {
problems = append(problems, fmt.Sprintf(
"%s keeps the credential for %q both as one file and as several", m.Module, to))
}
}
for _, f := range m.SecretFiles(to) {
if !strings.HasPrefix(f.Path, "/") {
problems = append(problems, fmt.Sprintf(
"%s keeps the credential for %q at %q, which is not an absolute path",
m.Module, SecretLocal(to, f.Local), f.Path))
}
if f.Local != "" && !name.MatchString(f.Local) {
problems = append(problems, fmt.Sprintf(
"%s keeps a credential for %q under %q, which is not a usable name",
m.Module, to, f.Local))
}
// A local name is what `${secret:<name>}` says, so it may not be another requirement's
// name or one of the module's own secrets — the file would hold the wrong credential
// while every check passed.
if f.Local != "" {
if _, own := m.OwnSecrets[f.Local]; own {
problems = append(problems, fmt.Sprintf(
"%s keeps a credential for %q under %q, which is also one of its own secrets",
m.Module, to, f.Local))
}
for _, w := range m.Wants() {
if w == f.Local {
problems = append(problems, fmt.Sprintf(
"%s keeps a credential for %q under %q, which is also something it requires",
m.Module, to, f.Local))
}
}
}
}
if len(m.SecretsMany[to]) == 0 && m.Secrets[to] == "" {
if _, many := m.SecretsMany[to]; many {
problems = append(problems, fmt.Sprintf(
"%s keeps the credential for %q as several files and names none", m.Module, to))
}
}
var wanted bool
for _, w := range m.Wants() {
@@ -1119,8 +1291,10 @@ func (m Manifest) undeclaredMounts() []string {
for _, where := range m.OwnSecrets {
claim(where)
}
for _, where := range m.Secrets {
claim(where)
for _, to := range m.SecretRequirements() {
for _, f := range m.SecretFiles(to) {
claim(f.Path)
}
}
for _, where := range m.Receives {
claim(where)