Files
Maestro/src/handlers/config/config.go
T

192 lines
6.0 KiB
Go

// 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)
// Runtime write-back (D6): best-effort delivery to the running service's
// POST /config endpoint. The goroutine is fire-and-forget; values always
// also take effect at next start via injectConfigSecrets (M1).
go libcontainers.DeliverConfigValues(r.Context(), container.ServiceName, []libcontainers.ConfigDelivery{
{Field: field, Key: spec.Key, Type: spec.Type, Value: action.Value},
})
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)
}