Remove the MinIO/S3 storage backend entirely. The filesystem backend is fully implemented, already used in production, and a migrate-storage tool exists for any remaining MinIO deployments to migrate beforehand. Changes: - Delete MinIO client implementation (internal/storage/storage.go) - Delete migrate-storage tool (cmd/migrate-storage, scripts/migrate-storage.sh) - Remove MinIO service, volumes, and env vars from all Docker Compose files - Simplify StorageConfig: remove Endpoint, AccessKey, SecretKey, Bucket, UseSSL, Region fields; add SILO_STORAGE_ROOT_DIR env override - Change all SQL COALESCE defaults from 'minio' to 'filesystem' - Add migration 020 to update column defaults to 'filesystem' - Remove minio-go/v7 dependency (go mod tidy) - Update all config examples, setup scripts, docs, and tests
315 lines
8.9 KiB
Go
315 lines
8.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/kindredsystems/silo/internal/auth"
|
|
)
|
|
|
|
// HandleGetAllSettings returns the full config grouped by module with secrets redacted.
|
|
func (s *Server) HandleGetAllSettings(w http.ResponseWriter, r *http.Request) {
|
|
resp := map[string]any{
|
|
"core": s.buildCoreSettings(),
|
|
"schemas": s.buildSchemasSettings(),
|
|
"storage": s.buildStorageSettings(r.Context()),
|
|
"database": s.buildDatabaseSettings(r.Context()),
|
|
"auth": s.buildAuthSettings(),
|
|
"projects": map[string]any{"enabled": s.modules.IsEnabled("projects")},
|
|
"audit": map[string]any{"enabled": s.modules.IsEnabled("audit")},
|
|
"odoo": s.buildOdooSettings(),
|
|
"freecad": s.buildFreecadSettings(),
|
|
"jobs": s.buildJobsSettings(),
|
|
"dag": map[string]any{"enabled": s.modules.IsEnabled("dag")},
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// HandleGetModuleSettings returns settings for a single module.
|
|
func (s *Server) HandleGetModuleSettings(w http.ResponseWriter, r *http.Request) {
|
|
module := chi.URLParam(r, "module")
|
|
|
|
var settings any
|
|
switch module {
|
|
case "core":
|
|
settings = s.buildCoreSettings()
|
|
case "schemas":
|
|
settings = s.buildSchemasSettings()
|
|
case "storage":
|
|
settings = s.buildStorageSettings(r.Context())
|
|
case "database":
|
|
settings = s.buildDatabaseSettings(r.Context())
|
|
case "auth":
|
|
settings = s.buildAuthSettings()
|
|
case "projects":
|
|
settings = map[string]any{"enabled": s.modules.IsEnabled("projects")}
|
|
case "audit":
|
|
settings = map[string]any{"enabled": s.modules.IsEnabled("audit")}
|
|
case "odoo":
|
|
settings = s.buildOdooSettings()
|
|
case "freecad":
|
|
settings = s.buildFreecadSettings()
|
|
case "jobs":
|
|
settings = s.buildJobsSettings()
|
|
case "dag":
|
|
settings = map[string]any{"enabled": s.modules.IsEnabled("dag")}
|
|
default:
|
|
writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
// HandleUpdateModuleSettings handles module toggle and config overrides.
|
|
func (s *Server) HandleUpdateModuleSettings(w http.ResponseWriter, r *http.Request) {
|
|
module := chi.URLParam(r, "module")
|
|
|
|
// Validate module exists
|
|
if s.modules.Get(module) == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Unknown module: "+module)
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
|
|
user := auth.UserFromContext(r.Context())
|
|
username := "system"
|
|
if user != nil {
|
|
username = user.Username
|
|
}
|
|
|
|
var updated []string
|
|
restartRequired := false
|
|
|
|
// Handle module toggle
|
|
if enabledVal, ok := body["enabled"]; ok {
|
|
enabled, ok := enabledVal.(bool)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "invalid_value", "'enabled' must be a boolean")
|
|
return
|
|
}
|
|
|
|
if err := s.modules.SetEnabled(module, enabled); err != nil {
|
|
writeError(w, http.StatusBadRequest, "toggle_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
if err := s.settings.SetModuleState(r.Context(), module, enabled, username); err != nil {
|
|
s.logger.Error().Err(err).Str("module", module).Msg("failed to persist module state")
|
|
writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save module state")
|
|
return
|
|
}
|
|
|
|
updated = append(updated, module+".enabled")
|
|
}
|
|
|
|
// Handle config overrides (future use — persisted but not hot-reloaded)
|
|
for key, value := range body {
|
|
if key == "enabled" {
|
|
continue
|
|
}
|
|
|
|
fullKey := module + "." + key
|
|
if err := s.settings.SetOverride(r.Context(), fullKey, value, username); err != nil {
|
|
s.logger.Error().Err(err).Str("key", fullKey).Msg("failed to persist setting override")
|
|
writeError(w, http.StatusInternalServerError, "persist_failed", "Failed to save setting: "+key)
|
|
return
|
|
}
|
|
updated = append(updated, fullKey)
|
|
|
|
// These namespaces require a restart to take effect
|
|
if strings.HasPrefix(fullKey, "database.") ||
|
|
strings.HasPrefix(fullKey, "storage.") ||
|
|
strings.HasPrefix(fullKey, "server.") ||
|
|
strings.HasPrefix(fullKey, "schemas.") {
|
|
restartRequired = true
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"updated": updated,
|
|
"restart_required": restartRequired,
|
|
})
|
|
|
|
// Publish SSE event
|
|
s.broker.Publish("settings.changed", mustMarshal(map[string]any{
|
|
"module": module,
|
|
"changed_keys": updated,
|
|
"updated_by": username,
|
|
}))
|
|
}
|
|
|
|
// HandleTestModuleConnectivity tests external connectivity for a module.
|
|
func (s *Server) HandleTestModuleConnectivity(w http.ResponseWriter, r *http.Request) {
|
|
module := chi.URLParam(r, "module")
|
|
|
|
start := time.Now()
|
|
var success bool
|
|
var message string
|
|
|
|
switch module {
|
|
case "database":
|
|
if err := s.db.Pool().Ping(r.Context()); err != nil {
|
|
success = false
|
|
message = "Database ping failed: " + err.Error()
|
|
} else {
|
|
success = true
|
|
message = "Database connection OK"
|
|
}
|
|
case "storage":
|
|
if s.storage == nil {
|
|
success = false
|
|
message = "Storage not configured"
|
|
} else if err := s.storage.Ping(r.Context()); err != nil {
|
|
success = false
|
|
message = "Storage ping failed: " + err.Error()
|
|
} else {
|
|
success = true
|
|
message = "Storage connection OK"
|
|
}
|
|
case "auth", "odoo":
|
|
success = false
|
|
message = "Connectivity test not implemented for " + module
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "not_testable", "No connectivity test available for module: "+module)
|
|
return
|
|
}
|
|
|
|
latency := time.Since(start).Milliseconds()
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"success": success,
|
|
"message": message,
|
|
"latency_ms": latency,
|
|
})
|
|
}
|
|
|
|
// --- build helpers (read config, redact secrets) ---
|
|
|
|
func redact(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
return "****"
|
|
}
|
|
|
|
func (s *Server) buildCoreSettings() map[string]any {
|
|
return map[string]any{
|
|
"enabled": true,
|
|
"host": s.cfg.Server.Host,
|
|
"port": s.cfg.Server.Port,
|
|
"base_url": s.cfg.Server.BaseURL,
|
|
"readonly": s.cfg.Server.ReadOnly,
|
|
}
|
|
}
|
|
|
|
func (s *Server) buildSchemasSettings() map[string]any {
|
|
return map[string]any{
|
|
"enabled": true,
|
|
"directory": s.cfg.Schemas.Directory,
|
|
"default": s.cfg.Schemas.Default,
|
|
"count": len(s.schemas),
|
|
}
|
|
}
|
|
|
|
func (s *Server) buildStorageSettings(ctx context.Context) map[string]any {
|
|
result := map[string]any{
|
|
"enabled": true,
|
|
"backend": "filesystem",
|
|
"root_dir": s.cfg.Storage.Filesystem.RootDir,
|
|
}
|
|
if s.storage != nil {
|
|
if err := s.storage.Ping(ctx); err != nil {
|
|
result["status"] = "unavailable"
|
|
} else {
|
|
result["status"] = "ok"
|
|
}
|
|
} else {
|
|
result["status"] = "not_configured"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Server) buildDatabaseSettings(ctx context.Context) map[string]any {
|
|
result := map[string]any{
|
|
"enabled": true,
|
|
"host": s.cfg.Database.Host,
|
|
"port": s.cfg.Database.Port,
|
|
"name": s.cfg.Database.Name,
|
|
"user": s.cfg.Database.User,
|
|
"password": redact(s.cfg.Database.Password),
|
|
"sslmode": s.cfg.Database.SSLMode,
|
|
"max_connections": s.cfg.Database.MaxConnections,
|
|
}
|
|
if err := s.db.Pool().Ping(ctx); err != nil {
|
|
result["status"] = "unavailable"
|
|
} else {
|
|
result["status"] = "ok"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Server) buildAuthSettings() map[string]any {
|
|
return map[string]any{
|
|
"enabled": s.modules.IsEnabled("auth"),
|
|
"session_secret": redact(s.cfg.Auth.SessionSecret),
|
|
"local": map[string]any{
|
|
"enabled": s.cfg.Auth.Local.Enabled,
|
|
"default_admin_username": s.cfg.Auth.Local.DefaultAdminUsername,
|
|
"default_admin_password": redact(s.cfg.Auth.Local.DefaultAdminPassword),
|
|
},
|
|
"ldap": map[string]any{
|
|
"enabled": s.cfg.Auth.LDAP.Enabled,
|
|
"url": s.cfg.Auth.LDAP.URL,
|
|
"base_dn": s.cfg.Auth.LDAP.BaseDN,
|
|
"bind_dn": s.cfg.Auth.LDAP.BindDN,
|
|
"bind_password": redact(s.cfg.Auth.LDAP.BindPassword),
|
|
},
|
|
"oidc": map[string]any{
|
|
"enabled": s.cfg.Auth.OIDC.Enabled,
|
|
"issuer_url": s.cfg.Auth.OIDC.IssuerURL,
|
|
"client_id": s.cfg.Auth.OIDC.ClientID,
|
|
"client_secret": redact(s.cfg.Auth.OIDC.ClientSecret),
|
|
"redirect_url": s.cfg.Auth.OIDC.RedirectURL,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Server) buildOdooSettings() map[string]any {
|
|
return map[string]any{
|
|
"enabled": s.modules.IsEnabled("odoo"),
|
|
"url": s.cfg.Odoo.URL,
|
|
"database": s.cfg.Odoo.Database,
|
|
"username": s.cfg.Odoo.Username,
|
|
"api_key": redact(s.cfg.Odoo.APIKey),
|
|
}
|
|
}
|
|
|
|
func (s *Server) buildFreecadSettings() map[string]any {
|
|
return map[string]any{
|
|
"enabled": s.modules.IsEnabled("freecad"),
|
|
"uri_scheme": s.cfg.FreeCAD.URIScheme,
|
|
"executable": s.cfg.FreeCAD.Executable,
|
|
}
|
|
}
|
|
|
|
func (s *Server) buildJobsSettings() map[string]any {
|
|
return map[string]any{
|
|
"enabled": s.modules.IsEnabled("jobs"),
|
|
"directory": s.cfg.Jobs.Directory,
|
|
"runner_timeout": s.cfg.Jobs.RunnerTimeout,
|
|
"job_timeout_check": s.cfg.Jobs.JobTimeoutCheck,
|
|
"default_priority": s.cfg.Jobs.DefaultPriority,
|
|
"definitions_count": len(s.jobDefs),
|
|
}
|
|
}
|