feat(auth): surface manifest has_auth/public_key and ensure shared auth running
Parse has_auth/public_key from the service manifest onto Container (persisted to InstalledMetadata), fetch manifests for running non-db containers during reconciliation, hide role=auth containers from the grid, and ensure dcntrlized-auth starts with the shared database.
This commit is contained in:
@@ -123,6 +123,18 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
example: "database"
|
example: "database"
|
||||||
description: Capabilities declared by the service manifest (e.g. database).
|
description: Capabilities declared by the service manifest (e.g. database).
|
||||||
|
has_auth:
|
||||||
|
type: boolean
|
||||||
|
description: >-
|
||||||
|
Whether the service consumes the shared auth service (manifest
|
||||||
|
has_auth). True for dcntrlized-auth itself and for auth-consuming
|
||||||
|
services.
|
||||||
|
public_key:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
The auth service's RS256 public key (manifest public_key); the hook
|
||||||
|
for a future Maestro dashboard auth that validates dcntrlized-auth
|
||||||
|
tokens.
|
||||||
docker_name:
|
docker_name:
|
||||||
type: string
|
type: string
|
||||||
image:
|
image:
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ function isMaestro(name?: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isHiddenContainer(c: Container): boolean {
|
function isHiddenContainer(c: Container): boolean {
|
||||||
return Boolean(c.hidden) || (c.role ?? '').toLowerCase() === 'db'
|
const role = (c.role ?? '').toLowerCase()
|
||||||
|
return Boolean(c.hidden) || role === 'db' || role === 'auth'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHiddenRegistry(r: RegistryItem): boolean {
|
function isHiddenRegistry(r: RegistryItem): boolean {
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export interface Container {
|
|||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
/** Capabilities declared by the service manifest (e.g. ["database"]). */
|
/** Capabilities declared by the service manifest (e.g. ["database"]). */
|
||||||
capabilities?: string[]
|
capabilities?: string[]
|
||||||
|
/** Whether the service consumes the shared auth service (manifest has_auth). */
|
||||||
|
has_auth?: boolean
|
||||||
|
/** The auth service's RS256 public key (manifest public_key). */
|
||||||
|
public_key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A remote catalog entry, as served by GET /api/registry. */
|
/** A remote catalog entry, as served by GET /api/registry. */
|
||||||
|
|||||||
+27
-10
@@ -32,6 +32,8 @@ const (
|
|||||||
DCNTRLIZEDNetwork = "dcntrlized_net"
|
DCNTRLIZEDNetwork = "dcntrlized_net"
|
||||||
// DatabaseServiceName is the shared database service (image, container and DNS name).
|
// DatabaseServiceName is the shared database service (image, container and DNS name).
|
||||||
DatabaseServiceName = "dcntrlized-db"
|
DatabaseServiceName = "dcntrlized-db"
|
||||||
|
// AuthServiceName is the shared auth service (image, container and DNS name).
|
||||||
|
AuthServiceName = "dcntrlized-auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureDCNTRLIZEDNetwork creates the external dcntrlized_net network when it is
|
// EnsureDCNTRLIZEDNetwork creates the external dcntrlized_net network when it is
|
||||||
@@ -53,6 +55,21 @@ func EnsureDCNTRLIZEDNetwork(ctx context.Context, dc *docker.DockerClient) error
|
|||||||
// leaves installation to the UI. The database is infrastructure, so this must
|
// leaves installation to the UI. The database is infrastructure, so this must
|
||||||
// never be invoked for the database service itself (StartContainer guards that).
|
// never be invoked for the database service itself (StartContainer guards that).
|
||||||
func EnsureDatabaseRunning(ctx context.Context) error {
|
func EnsureDatabaseRunning(ctx context.Context) error {
|
||||||
|
return ensureInfraRunning(ctx, DatabaseServiceName, "shared database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAuthRunning ensures the shared auth service container is running. The
|
||||||
|
// auth service is hidden infrastructure: every consumer proxies /auth/* to it
|
||||||
|
// and validates action tokens with its key, so it must be up before services
|
||||||
|
// that depend on it start. Only starts when already installed.
|
||||||
|
func EnsureAuthRunning(ctx context.Context) error {
|
||||||
|
return ensureInfraRunning(ctx, AuthServiceName, "shared auth service")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureInfraRunning starts a hidden infrastructure container (shared database,
|
||||||
|
// shared auth service) when it is installed but not running. It never installs
|
||||||
|
// infrastructure itself — the UI does that from the registry.
|
||||||
|
func ensureInfraRunning(ctx context.Context, serviceName, label string) error {
|
||||||
dc, err := docker.NewDockerClient()
|
dc, err := docker.NewDockerClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize docker client: %w", err)
|
return fmt.Errorf("failed to initialize docker client: %w", err)
|
||||||
@@ -63,29 +80,29 @@ func EnsureDatabaseRunning(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if databaseRunning(ctx, dc) {
|
if infraRunning(ctx, dc, serviceName) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !IsInstalled(DatabaseServiceName) {
|
if !IsInstalled(serviceName) {
|
||||||
logger.Info("Shared database not installed; install %s from the registry to enable database-backed services", DatabaseServiceName)
|
logger.Info("%s not installed; install %s from the registry to enable services that need it", label, serviceName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("Starting shared database %s...", DatabaseServiceName)
|
logger.Info("Starting %s %s...", label, serviceName)
|
||||||
if err := StartContainer(ctx, types.RegistryContainer{Name: DatabaseServiceName}); err != nil {
|
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
|
||||||
return fmt.Errorf("failed to start shared database %s: %w", DatabaseServiceName, err)
|
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// databaseRunning reports whether the shared database container is currently up.
|
// infraRunning reports whether the named infrastructure container is currently up.
|
||||||
func databaseRunning(ctx context.Context, dc *docker.DockerClient) bool {
|
func infraRunning(ctx context.Context, dc *docker.DockerClient, serviceName string) bool {
|
||||||
filter := filters.NewArgs()
|
filter := filters.NewArgs()
|
||||||
filter.Add("label", "org.dcntrlized.service="+DatabaseServiceName)
|
filter.Add("label", "org.dcntrlized.service="+serviceName)
|
||||||
|
|
||||||
containers, err := dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
|
containers, err := dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("Failed to list database containers: %v", err)
|
logger.Warn("Failed to list %s containers: %v", serviceName, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
|
|||||||
CreatedAt: metadata.CreatedAt,
|
CreatedAt: metadata.CreatedAt,
|
||||||
Hidden: metadata.Hidden,
|
Hidden: metadata.Hidden,
|
||||||
Capabilities: metadata.Capabilities,
|
Capabilities: metadata.Capabilities,
|
||||||
|
HasAuth: metadata.HasAuth,
|
||||||
|
PublicKey: metadata.PublicKey,
|
||||||
Title: metadata.Title,
|
Title: metadata.Title,
|
||||||
Vendor: metadata.Vendor,
|
Vendor: metadata.Vendor,
|
||||||
Author: metadata.Author,
|
Author: metadata.Author,
|
||||||
@@ -79,11 +81,12 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateInstalledMetadataCapabilities persists the capabilities reported by a
|
// updateInstalledMetadataFromManifest persists the capabilities, has_auth flag
|
||||||
// service manifest so installed-but-stopped services can still surface them
|
// and public key reported by a service manifest so installed-but-stopped
|
||||||
// (e.g. the database chip) without running.
|
// services can still surface them (e.g. the database chip, auth consumption)
|
||||||
func updateInstalledMetadataCapabilities(serviceName string, capabilities []string) {
|
// without running.
|
||||||
if len(capabilities) == 0 {
|
func updateInstalledMetadataFromManifest(serviceName string, m *types.Manifest) {
|
||||||
|
if m == nil || len(m.Capabilities) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dir, err := getContainerDataDir(serviceName)
|
dir, err := getContainerDataDir(serviceName)
|
||||||
@@ -94,9 +97,11 @@ func updateInstalledMetadataCapabilities(serviceName string, capabilities []stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
metadata.Capabilities = capabilities
|
metadata.Capabilities = m.Capabilities
|
||||||
|
metadata.HasAuth = m.HasAuth
|
||||||
|
metadata.PublicKey = m.PublicKey
|
||||||
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
|
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
|
||||||
logger.Warn("Failed to persist capabilities for %s: %v", serviceName, err)
|
logger.Warn("Failed to persist manifest metadata for %s: %v", serviceName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,9 +110,13 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
|||||||
ver = existing.Version
|
ver = existing.Version
|
||||||
}
|
}
|
||||||
capabilities := existing.Capabilities
|
capabilities := existing.Capabilities
|
||||||
|
hasAuth := existing.HasAuth
|
||||||
|
publicKey := existing.PublicKey
|
||||||
if manifest != nil {
|
if manifest != nil {
|
||||||
capabilities = manifest.Capabilities
|
capabilities = manifest.Capabilities
|
||||||
updateInstalledMetadataCapabilities(serviceLabel, manifest.Capabilities)
|
hasAuth = manifest.HasAuth
|
||||||
|
publicKey = manifest.PublicKey
|
||||||
|
updateInstalledMetadataFromManifest(serviceLabel, manifest)
|
||||||
}
|
}
|
||||||
container := types.Container{
|
container := types.Container{
|
||||||
ID: containerID,
|
ID: containerID,
|
||||||
@@ -126,6 +130,8 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
|
|||||||
Role: role,
|
Role: role,
|
||||||
Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true",
|
Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true",
|
||||||
Capabilities: capabilities,
|
Capabilities: capabilities,
|
||||||
|
HasAuth: hasAuth,
|
||||||
|
PublicKey: publicKey,
|
||||||
Title: existing.Title,
|
Title: existing.Title,
|
||||||
Vendor: existing.Vendor,
|
Vendor: existing.Vendor,
|
||||||
Author: existing.Author,
|
Author: existing.Author,
|
||||||
@@ -241,6 +247,19 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
|
|||||||
if c.Labels["org.dcntrlized.hidden"] == "true" {
|
if c.Labels["org.dcntrlized.hidden"] == "true" {
|
||||||
entry.Hidden = true
|
entry.Hidden = true
|
||||||
}
|
}
|
||||||
|
// Running containers get their manifest fields (capabilities, has_auth,
|
||||||
|
// public_key) fetched best-effort so a Maestro restart surfaces them even
|
||||||
|
// when the container was already up before the watcher subscribed.
|
||||||
|
if c.State == "running" && c.Labels["org.dcntrlized.role"] != "db" {
|
||||||
|
if manifest, err := docker.FetchManifestWithRetry(ctx, c.ID[:12], c.Labels["org.dcntrlized.port"], 20, 50*time.Millisecond); err != nil {
|
||||||
|
log.Printf("Error fetching manifest for container %s: %v", c.ID[:12], err)
|
||||||
|
} else if manifest != nil {
|
||||||
|
entry.Capabilities = manifest.Capabilities
|
||||||
|
entry.HasAuth = manifest.HasAuth
|
||||||
|
entry.PublicKey = manifest.PublicKey
|
||||||
|
updateInstalledMetadataFromManifest(serviceLabel, manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
cache.services[key] = entry
|
cache.services[key] = entry
|
||||||
cache.Unlock()
|
cache.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ type Container struct {
|
|||||||
// (e.g. ["database"]), surfaced so the UI can render capability chips.
|
// (e.g. ["database"]), surfaced so the UI can render capability chips.
|
||||||
Capabilities []string `json:"capabilities,omitempty"`
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
|
||||||
|
// HasAuth reports whether the service consumes the shared auth service
|
||||||
|
// (manifest has_auth). PublicKey is the auth service's RS256 public key
|
||||||
|
// (manifest public_key), the hook for a future Maestro dashboard auth.
|
||||||
|
HasAuth bool `json:"has_auth,omitempty"`
|
||||||
|
PublicKey string `json:"public_key,omitempty"`
|
||||||
|
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Vendor string `json:"vendor,omitempty"`
|
Vendor string `json:"vendor,omitempty"`
|
||||||
Author string `json:"author,omitempty"`
|
Author string `json:"author,omitempty"`
|
||||||
|
|||||||
@@ -53,4 +53,9 @@ type InstalledMetadata struct {
|
|||||||
// Capabilities are persisted from the service manifest once the service has
|
// Capabilities are persisted from the service manifest once the service has
|
||||||
// run, so installed-but-stopped services can still surface them.
|
// run, so installed-but-stopped services can still surface them.
|
||||||
Capabilities []string `json:"capabilities,omitempty"`
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
// HasAuth and PublicKey are persisted from the service manifest (the shared
|
||||||
|
// auth contract, see dcntrlized-auth). They let installed-but-stopped
|
||||||
|
// services keep advertising auth consumption and its key.
|
||||||
|
HasAuth bool `json:"has_auth,omitempty"`
|
||||||
|
PublicKey string `json:"public_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// 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 types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestManifestParsesAuthPayload covers the dcntrlized-auth /_manifest payload:
|
||||||
|
// capabilities ["auth"], has_auth true and the RS256 public_key PEM. Maestro
|
||||||
|
// stores these so a later milestone can authenticate its dashboard against the
|
||||||
|
// shared auth service.
|
||||||
|
func TestManifestParsesAuthPayload(t *testing.T) {
|
||||||
|
const pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtestonly\n-----END PUBLIC KEY-----\n"
|
||||||
|
|
||||||
|
raw := `{
|
||||||
|
"name": "dcntrlized-auth",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"author": "Dcntrlized",
|
||||||
|
"capabilities": ["auth"],
|
||||||
|
"has_auth": true,
|
||||||
|
"public_key": ` + marshalJSONString(t, pem) + `
|
||||||
|
}`
|
||||||
|
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal auth manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Name != "dcntrlized-auth" {
|
||||||
|
t.Errorf("Name = %q, want dcntrlized-auth", m.Name)
|
||||||
|
}
|
||||||
|
if !m.HasAuth {
|
||||||
|
t.Errorf("HasAuth = false, want true")
|
||||||
|
}
|
||||||
|
if m.PublicKey != pem {
|
||||||
|
t.Errorf("PublicKey mismatch: got %q", m.PublicKey)
|
||||||
|
}
|
||||||
|
if len(m.Capabilities) != 1 || m.Capabilities[0] != "auth" {
|
||||||
|
t.Errorf("Capabilities = %v, want [auth]", m.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestParsesRegularService(t *testing.T) {
|
||||||
|
// A regular consumer (e.g. KeepNote) declares has_auth only when it
|
||||||
|
// consumes shared auth; has_auth and public_key must default to zero
|
||||||
|
// values when absent.
|
||||||
|
raw := `{
|
||||||
|
"name": "KeepNote",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"author": "Dcntrlized",
|
||||||
|
"capabilities": ["database"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.HasAuth {
|
||||||
|
t.Errorf("HasAuth = true, want false")
|
||||||
|
}
|
||||||
|
if m.PublicKey != "" {
|
||||||
|
t.Errorf("PublicKey = %q, want empty", m.PublicKey)
|
||||||
|
}
|
||||||
|
if len(m.Capabilities) != 1 || m.Capabilities[0] != "database" {
|
||||||
|
t.Errorf("Capabilities = %v, want [database]", m.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalJSONString renders s as a quoted JSON string, avoiding a missing
|
||||||
|
// encoding/json roundtrip in the test literals above.
|
||||||
|
func marshalJSONString(t *testing.T, s string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal string: %v", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
+7
-4
@@ -55,13 +55,16 @@ func main() {
|
|||||||
if err := cache.LoadInstalledContainers(); err != nil {
|
if err := cache.LoadInstalledContainers(); err != nil {
|
||||||
log.Fatalf("Failed to load installed containers: %v", err)
|
log.Fatalf("Failed to load installed containers: %v", err)
|
||||||
}
|
}
|
||||||
// Ensure the shared database is running before reconciling, so its
|
// Ensure the shared database and auth service are running before
|
||||||
// container is picked up by the reconciliation below. Failures to start
|
// reconciling, so their containers are picked up by the reconciliation
|
||||||
// the database are not fatal: Maestro keeps running and the UI surfaces
|
// below. Failures are not fatal: Maestro keeps running and the UI
|
||||||
// the database status for manual action.
|
// surfaces infrastructure status for manual action.
|
||||||
if err := libcontainers.EnsureDatabaseRunning(ctx); err != nil {
|
if err := libcontainers.EnsureDatabaseRunning(ctx); err != nil {
|
||||||
log.Printf("Warning: failed to ensure shared database: %v", err)
|
log.Printf("Warning: failed to ensure shared database: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := libcontainers.EnsureAuthRunning(ctx); err != nil {
|
||||||
|
log.Printf("Warning: failed to ensure shared auth service: %v", err)
|
||||||
|
}
|
||||||
cache.ReconcileContainers(ctx, dc)
|
cache.ReconcileContainers(ctx, dc)
|
||||||
go cache.WatchContainers(ctx, dc)
|
go cache.WatchContainers(ctx, dc)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user