// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Package containers implements Maestro's runtime write-back delivery for the // Service Config & Secrets theme (D6). When the operator sets or clears a // config value via the Settings panel, Maestro delivers it to the running // service's POST /config endpoint on the service's single plaintext :8080 // listener, authenticated with a short-lived, single-use ES256 config token // minted from Maestro's dedicated config-signing keypair (contract rev 9 §17). // There is no mTLS and no peer port: the fleet-wide internal transport is // plaintext HTTP. A set carries {field, value, type}; a clear carries // {field, action:"clear"} — the service owns the schema and branches on what it // receives. // // Delivery is best-effort: failures are logged (with the field name, never // the value) and do not prevent the panel write from succeeding. Values always // also take effect at the next StartContainer via injectConfigSecrets (M1), // so delivery is an accelerator, not the sole path. package containers import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time" "git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" ) // configPort is the single port every service serves its API, /config and web // app on after rev 9. There is no peer listener and no compose label. const configPort = "8080" // ConfigDelivery is one key-value pair to deliver to a running service. type ConfigDelivery struct { Field string // manifest config field name Key string // container env var name Type string // "cleartext" | "password" Value string // the operator-entered value (never logged) } // configRequest is the JSON body POSTed to the service's /config endpoint. // Action is empty for a set (the existing contract) and "clear" for a clear. type configRequest struct { Field string `json:"field"` Value string `json:"value,omitempty"` Type string `json:"type,omitempty"` Action string `json:"action,omitempty"` } // DeliverConfigValues delivers the given config pairs to the running service. // If the service is not running or unreachable, the error is logged but not // returned — the values will take effect at next start via injectConfigSecrets. func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) { if len(items) == 0 { return } for _, item := range items { body, err := json.Marshal(configRequest{ Field: item.Field, Value: item.Value, Type: item.Type, }) if err != nil { logger.Warn("Config delivery to %s/%s: marshal failed: %v", serviceName, item.Field, err) continue } postConfig(ctx, serviceName, item.Field, "value", body) } } // DeliverConfigClear tells the running service to clear a config field. The // service decides what "clear" means (drop the value, fall back to defaults); // like sets, it is best-effort and covered by the next start. func DeliverConfigClear(ctx context.Context, serviceName, field string) { body, err := json.Marshal(configRequest{Field: field, Action: "clear"}) if err != nil { logger.Warn("Config clear delivery to %s/%s: marshal failed: %v", serviceName, field, err) return } postConfig(ctx, serviceName, field, "clear", body) } // postConfig performs one best-effort POST of body to the running service's // /config endpoint on the single :8080 listener, authenticated with a freshly // minted ES256 config token. NotFound/unreachable cases are logged at debug // (the write still applies at next start); transport errors carry the field // name only, never a value. func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) { dc, err := docker.NewDockerClient() if err != nil { logger.Warn("Config delivery to %s/%s: cannot open docker client: %v", serviceName, field, err) return } defer dc.Close() inspectData, err := docker.ResolveContainer(ctx, dc, serviceName) if err != nil { logger.Debug("Config delivery to %s/%s: service not running or not found (%v); will apply at next start", serviceName, field, err) return } network, _, err := docker.ExtractNetworkInfo(ctx, dc, inspectData) if err != nil { logger.Debug("Config delivery to %s/%s: cannot extract network info; will apply at next start", serviceName, field) return } token, err := authmgmt.ConfigToken(serviceName) if err != nil { logger.Warn("Config delivery to %s/%s: cannot mint config token: %v", serviceName, field, err) return } client := &http.Client{Timeout: 5 * time.Second} url := fmt.Sprintf("http://%s:%s/config", network.IPAddress.Address, configPort) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err) return } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { logger.Debug("Config delivery to %s/%s: service unreachable (%v); will apply at next start", serviceName, field, err) return } resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { logger.Info("Delivered operator config %s/%s (%s) to running service", serviceName, field, verb) } else { logger.Debug("Config delivery to %s/%s: service responded %d; will apply at next start", serviceName, field, resp.StatusCode) } }