feat(config): per-service operator config + secrets (theme M1)
Services can declare an operator-configurable config block in /_manifest
(ordered array of single-key objects, pure schema: type cleartext|password,
description?, key = container env var name). Maestro:
- lib/secrets: AES-256-GCM FileStore; dedicated secrets.key (32 bytes,
chmod 600, separate from session.key); secrets.json rows keyed
service/field; cleartext read-write, password write-only & encrypted at
rest, decrypted only at injection. Footure key rotation = delete the key.
- handlers/config (owner-gated via auth.RequireSession):
GET /api/services/{service}/config (merged schema+set view, password
values never returned), PUT/DELETE /api/services/{service}/config/{key}
(uniform ERR_NOT_FOUND / ERR_INVALID_PAYLOAD error shape).
- start.go: injectConfigSecrets runs after injectServiceIdentity; only
operator-set rows become env on non-oneoff services; unset fields keep
the compose default.
- Manifest.Config carried onto Container/RegistryContainer/InstalledMetadata
via updateInstalledMetadataFromManifest + watcher.
- Frontend: per-service Settings dialog (ServiceConfigDialog) + api
helpers; cleartext editable, password set/unset indicator only.
- spec.yaml documents the new endpoints/schemas.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package config serves Maestro's owner-gated Service Config & Secrets API
|
||||
// (Service Config & Secrets theme M1). The service manifest's `config` block
|
||||
// is the pure schema; the encrypted store holds the operator-entered values;
|
||||
// this handler merges them for the UI and receives value changes.
|
||||
//
|
||||
// Routes (all owner-gated via auth.RequireSession in main.go):
|
||||
//
|
||||
// - GET /api/services/{service}/config merged view (never a password value)
|
||||
// - PUT /api/services/{service}/config/{key} set a value
|
||||
// - DELETE /api/services/{service}/config/{key} clear a value
|
||||
//
|
||||
// Errors follow the uniform {"error":{"code":…}} shape.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
// Handler serves the service config + secrets API against the install cache
|
||||
// (which carries each service's manifest config schema) and the encrypted
|
||||
// store.
|
||||
type Handler struct {
|
||||
store secrets.Store
|
||||
cache *libcontainers.ContainerCache
|
||||
}
|
||||
|
||||
// NewHandler builds the config API handler.
|
||||
func NewHandler(store secrets.Store, cache *libcontainers.ContainerCache) *Handler {
|
||||
return &Handler{store: store, cache: cache}
|
||||
}
|
||||
|
||||
// Entry is the merged per-field UI view: schema (type/key/description) plus the
|
||||
// operator's set state. Password values are never included; a cleartext value
|
||||
// (read-write) is.
|
||||
type Entry struct {
|
||||
Field string `json:"field"`
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Set bool `json:"set"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// List serves GET /api/services/{service}/config: the merged view built from
|
||||
// the service's manifest config schema and the stored rows.
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
service := r.PathValue("service")
|
||||
container, ok := h.cache.GetService(service)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
|
||||
stored := map[string]secrets.PublicView{}
|
||||
for _, v := range h.store.All(container.ServiceName) {
|
||||
stored[v.Field] = v
|
||||
}
|
||||
|
||||
entries := make([]Entry, 0, len(container.Config))
|
||||
for _, item := range container.Config {
|
||||
field := item.FieldName()
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
spec := item[field]
|
||||
entry := Entry{
|
||||
Field: field,
|
||||
Key: spec.Key,
|
||||
Type: spec.Type,
|
||||
Description: spec.Description,
|
||||
}
|
||||
if row, ok := stored[field]; ok && row.Set {
|
||||
entry.Set = true
|
||||
if row.Type == types.ConfigCleartext {
|
||||
entry.Value = row.Value
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// valueAction is the PUT request body: {value}.
|
||||
type valueAction struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Value serves PUT and DELETE on /api/services/{service}/config/{key}: set or
|
||||
// clear a stored value. The {key} is the manifest config field name.
|
||||
func (h *Handler) Value(w http.ResponseWriter, r *http.Request) {
|
||||
service := r.PathValue("service")
|
||||
field := r.PathValue("key")
|
||||
|
||||
container, ok := h.cache.GetService(service)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
spec, known := h.field(container, field)
|
||||
if !known {
|
||||
writeError(w, http.StatusNotFound, "ERR_NOT_FOUND")
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if err := h.store.Delete(container.ServiceName, field); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
|
||||
case http.MethodPut:
|
||||
var action valueAction
|
||||
if err := json.NewDecoder(r.Body).Decode(&action); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
|
||||
return
|
||||
}
|
||||
if spec.Type == types.ConfigPassword && action.Value == "" {
|
||||
writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD")
|
||||
return
|
||||
}
|
||||
view, err := h.store.Set(container.ServiceName, field, spec, action.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED")
|
||||
}
|
||||
}
|
||||
|
||||
// field looks a manifest config field up by its field name.
|
||||
func (h *Handler) field(container types.Container, field string) (types.ConfigField, bool) {
|
||||
for _, item := range container.Config {
|
||||
if name := item.FieldName(); name == field {
|
||||
return item[name], true
|
||||
}
|
||||
}
|
||||
return types.ConfigField{}, false
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code string) {
|
||||
var body errorBody
|
||||
body.Error.Code = code
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) (*Handler, *libcontainers.ContainerCache) {
|
||||
t.Helper()
|
||||
store, err := secrets.NewFileStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileStore: %v", err)
|
||||
}
|
||||
cache := libcontainers.NewContainerCache()
|
||||
cache.AddService(types.Container{
|
||||
ServiceName: "svc",
|
||||
Name: "svc",
|
||||
Status: "installed",
|
||||
Config: []types.ConfigItem{
|
||||
{"owner_email": {Type: types.ConfigCleartext, Key: "OWNER_EMAIL", Description: "ops email"}},
|
||||
{"db_password": {Type: types.ConfigPassword, Key: "DB_PASSWORD", Description: "db password"}},
|
||||
},
|
||||
})
|
||||
return NewHandler(store, cache), cache
|
||||
}
|
||||
|
||||
func TestListMergedView(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/services/svc/config", nil)
|
||||
req.SetPathValue("service", "svc")
|
||||
rr := httptest.NewRecorder()
|
||||
h.List(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
var entries []Entry
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &entries); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries = %d, want 2", len(entries))
|
||||
}
|
||||
byField := map[string]Entry{}
|
||||
for _, e := range entries {
|
||||
byField[e.Field] = e
|
||||
}
|
||||
if e := byField["owner_email"]; e.Type != types.ConfigCleartext || e.Set != false {
|
||||
t.Errorf("owner_email = %+v, want cleartext unset", e)
|
||||
}
|
||||
if e := byField["db_password"]; e.Type != types.ConfigPassword || e.Set != false {
|
||||
t.Errorf("db_password = %+v, want password unset", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutCleartextReadableBack(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
body := bytes.NewReader([]byte(`{"value":"[email protected]"}`))
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/services/svc/config/owner_email", body)
|
||||
req.SetPathValue("service", "svc")
|
||||
req.SetPathValue("key", "owner_email")
|
||||
rr := httptest.NewRecorder()
|
||||
h.Value(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
// GET now reports set with the read-write value.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/services/svc/config", nil)
|
||||
req.SetPathValue("service", "svc")
|
||||
rr = httptest.NewRecorder()
|
||||
h.List(rr, req)
|
||||
var entries []Entry
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &entries); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Field == "owner_email" {
|
||||
found = true
|
||||
if !e.Set {
|
||||
t.Errorf("owner_email set = false")
|
||||
}
|
||||
if e.Value != "[email protected]" {
|
||||
t.Errorf("owner_email value = %q, want [email protected]", e.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("owner_email missing from merged view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutPasswordNeverReturned(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
body := bytes.NewReader([]byte(`{"value":"s3cr3t-pw"}`))
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/services/svc/config/db_password", body)
|
||||
req.SetPathValue("service", "svc")
|
||||
req.SetPathValue("key", "db_password")
|
||||
rr := httptest.NewRecorder()
|
||||
h.Value(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), "s3cr3t-pw") {
|
||||
t.Errorf("PUT response leaks password value")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/services/svc/config", nil)
|
||||
req.SetPathValue("service", "svc")
|
||||
rr = httptest.NewRecorder()
|
||||
h.List(rr, req)
|
||||
resp := rr.Body.String()
|
||||
if strings.Contains(resp, "s3cr3t-pw") {
|
||||
t.Errorf("GET merged view leaks password value")
|
||||
}
|
||||
var entries []Entry
|
||||
if err := json.Unmarshal([]byte(resp), &entries); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Field == "db_password" {
|
||||
if !e.Set {
|
||||
t.Errorf("db_password set = false")
|
||||
}
|
||||
if e.Value != "" {
|
||||
t.Errorf("db_password value leaked: %q", e.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutUnknownFieldNotFound(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
body := bytes.NewReader([]byte(`{"value":"x"}`))
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/services/svc/config/nope", body)
|
||||
req.SetPathValue("service", "svc")
|
||||
req.SetPathValue("key", "nope")
|
||||
rr := httptest.NewRecorder()
|
||||
h.Value(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "ERR_NOT_FOUND") {
|
||||
t.Errorf("body = %s, want ERR_NOT_FOUND", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteClears(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
body := bytes.NewReader([]byte(`{"value":"[email protected]"}`))
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/services/svc/config/owner_email", body)
|
||||
req.SetPathValue("service", "svc")
|
||||
req.SetPathValue("key", "owner_email")
|
||||
h.Value(httptest.NewRecorder(), req)
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/services/svc/config/owner_email", nil)
|
||||
req.SetPathValue("service", "svc")
|
||||
req.SetPathValue("key", "owner_email")
|
||||
rr := httptest.NewRecorder()
|
||||
h.Value(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/services/svc/config", nil)
|
||||
req.SetPathValue("service", "svc")
|
||||
rr = httptest.NewRecorder()
|
||||
h.List(rr, req)
|
||||
var entries []Entry
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &entries); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Field == "owner_email" && e.Set {
|
||||
t.Errorf("owner_email set=true after DELETE")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user