feat: provision shared database as hidden infrastructure

The shared dcntrlized-db Postgres service is now treated as Maestro
infrastructure: it is hidden from the service grid and auto-started
before any app container when already installed.

- Container model gains role/hidden/capabilities; registry items gain
  hidden; installed metadata persists manifest capabilities so stopped
  services still surface them.
- Registry marks the shared database image as hidden (OCI annotation,
  label, or name fallback).
- Compose label rewrite merges existing service labels instead of
  replacing them, so org.dcntrlized.role/hidden survive.
- New infra helpers ensure the external dcntrlized_net network and
  start the database on the prod path and before each StartContainer.
- Watcher skips manifest fetch for db-role containers and persists
  capabilities from the manifest.
- spec.yaml documents the new fields.
This commit is contained in:
2026-08-10 16:27:58 +01:00
parent d2fb10ca44
commit c1ad85b65c
10 changed files with 622 additions and 38 deletions
+15 -8
View File
@@ -71,16 +71,23 @@ func loadComposeProject(ctx context.Context, serviceName string) (*types.Project
// on the project + oneoff labels). Without them, Up() creates containers but the
// subsequent start() step fails to find them. Also normalize the dcntrlized
// service marker to the lowercase project name so label lookups are consistent.
//
// Labels are merged, never replaced: the compose file may carry its own
// dcntrlized labels (e.g. org.dcntrlized.role=db, org.dcntrlized.hidden=true
// for the shared database) that must survive onto the running container.
for name, s := range project.Services {
s.CustomLabels = map[string]string{
api.ProjectLabel: project.Name,
api.ServiceLabel: name,
api.VersionLabel: api.ComposeVersion,
api.WorkingDirLabel: project.WorkingDir,
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
api.OneoffLabel: "False",
"org.dcntrlized.service": project.Name,
labels := types.Labels{}
for k, v := range s.Labels {
labels[k] = v
}
labels[api.ProjectLabel] = project.Name
labels[api.ServiceLabel] = name
labels[api.VersionLabel] = api.ComposeVersion
labels[api.WorkingDirLabel] = project.WorkingDir
labels[api.ConfigFilesLabel] = strings.Join(project.ComposeFiles, ",")
labels[api.OneoffLabel] = "False"
labels["org.dcntrlized.service"] = project.Name
s.CustomLabels = labels
project.Services[name] = s
}
+97
View File
@@ -0,0 +1,97 @@
// 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 containers
import (
"context"
"fmt"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
)
const (
// DCNTRLIZEDNetwork is the external Docker network every dcntrlized service joins.
DCNTRLIZEDNetwork = "dcntrlized_net"
// DatabaseServiceName is the shared database service (image, container and DNS name).
DatabaseServiceName = "dcntrlized-db"
)
// EnsureDCNTRLIZEDNetwork creates the external dcntrlized_net network when it is
// missing. Every service compose file declares it as external, so it must exist
// before any compose project is started.
func EnsureDCNTRLIZEDNetwork(ctx context.Context, dc *docker.DockerClient) error {
if _, err := dc.NetworkInspect(ctx, DCNTRLIZEDNetwork, network.InspectOptions{}); err == nil {
return nil
}
if _, err := dc.NetworkCreate(ctx, DCNTRLIZEDNetwork, network.CreateOptions{}); err != nil {
return fmt.Errorf("failed to create network %s: %w", DCNTRLIZEDNetwork, err)
}
logger.Info("Created external Docker network %s", DCNTRLIZEDNetwork)
return nil
}
// EnsureDatabaseRunning ensures the shared database container is running. It
// starts the database only when it is already installed; otherwise it logs and
// leaves installation to the UI. The database is infrastructure, so this must
// never be invoked for the database service itself (StartContainer guards that).
func EnsureDatabaseRunning(ctx context.Context) error {
dc, err := docker.NewDockerClient()
if err != nil {
return fmt.Errorf("failed to initialize docker client: %w", err)
}
defer dc.Close()
if err := EnsureDCNTRLIZEDNetwork(ctx, dc); err != nil {
return err
}
if databaseRunning(ctx, dc) {
return nil
}
if !IsInstalled(DatabaseServiceName) {
logger.Info("Shared database not installed; install %s from the registry to enable database-backed services", DatabaseServiceName)
return nil
}
logger.Info("Starting shared database %s...", DatabaseServiceName)
if err := StartContainer(ctx, types.RegistryContainer{Name: DatabaseServiceName}); err != nil {
return fmt.Errorf("failed to start shared database %s: %w", DatabaseServiceName, err)
}
return nil
}
// databaseRunning reports whether the shared database container is currently up.
func databaseRunning(ctx context.Context, dc *docker.DockerClient) bool {
filter := filters.NewArgs()
filter.Add("label", "org.dcntrlized.service="+DatabaseServiceName)
containers, err := dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
if err != nil {
logger.Warn("Failed to list database containers: %v", err)
return false
}
for _, c := range containers {
if c.State == "running" || c.State == "restarting" {
return true
}
}
return false
}
+34 -11
View File
@@ -59,17 +59,19 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
}
cache.AddService(types.Container{
ServiceName: name,
Name: metadata.Name,
Version: metadata.Version,
Image: metadata.Image,
Status: "installed",
CreatedAt: metadata.CreatedAt,
Title: metadata.Title,
Vendor: metadata.Vendor,
Author: metadata.Author,
Description: metadata.Description,
Source: metadata.Source,
ServiceName: name,
Name: metadata.Name,
Version: metadata.Version,
Image: metadata.Image,
Status: "installed",
CreatedAt: metadata.CreatedAt,
Hidden: metadata.Hidden,
Capabilities: metadata.Capabilities,
Title: metadata.Title,
Vendor: metadata.Vendor,
Author: metadata.Author,
Description: metadata.Description,
Source: metadata.Source,
})
logger.Info("Loaded installed container: %s", name)
}
@@ -77,6 +79,27 @@ func (cache *ContainerCache) LoadInstalledContainers() error {
return nil
}
// updateInstalledMetadataCapabilities persists the capabilities reported by a
// service manifest so installed-but-stopped services can still surface them
// (e.g. the database chip) without running.
func updateInstalledMetadataCapabilities(serviceName string, capabilities []string) {
if len(capabilities) == 0 {
return
}
dir, err := getContainerDataDir(serviceName)
if err != nil {
return
}
metadata, err := readInstalledMetadata(dir)
if err != nil {
return
}
metadata.Capabilities = capabilities
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
logger.Warn("Failed to persist capabilities for %s: %v", serviceName, err)
}
}
// readInstalledMetadata reads and parses the metadata.json of an install dir.
func readInstalledMetadata(dir string) (*types.InstalledMetadata, error) {
data, err := os.ReadFile(filepath.Join(dir, "metadata.json"))
+12
View File
@@ -42,6 +42,18 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
return err
}
// Infrastructure first: the external dcntrlized_net network must exist
// before any compose project is started, and services that depend on the
// shared database need it running before they boot.
if err := EnsureDCNTRLIZEDNetwork(ctx, dc); err != nil {
return err
}
if serviceName != DatabaseServiceName {
if err := EnsureDatabaseRunning(ctx); err != nil {
logger.Warn("Failed to ensure shared database before starting %s: %v", serviceName, err)
}
}
// The compose file may reference the image by a short name (e.g.
// "testservice") while install pulled it under its full registry reference.
// Make sure those short names exist so Up() can find them.
+40 -19
View File
@@ -89,33 +89,48 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
ver, _ := docker.GetContainerVersion(ctx, dc, containerID)
// Fetch Manifest (best effort; fall back to basic info if it fails)
host := containerID[:12]
port := event.Actor.Attributes["org.dcntrlized.port"]
manifest, err := docker.FetchManifestWithRetry(ctx, host, port, 20, 50*time.Millisecond)
if err != nil {
log.Printf("Error fetching manifest for container %s: %v", containerID, err)
// Infrastructure containers (e.g. the shared database) have no HTTP
// manifest endpoint; keep their role marker and skip the fetch.
role := event.Actor.Attributes["org.dcntrlized.role"]
var manifest *types.Manifest
if role != "db" {
host := containerID[:12]
port := event.Actor.Attributes["org.dcntrlized.port"]
var fetchErr error
manifest, fetchErr = docker.FetchManifestWithRetry(ctx, host, port, 20, 50*time.Millisecond)
if fetchErr != nil {
log.Printf("Error fetching manifest for container %s: %v", containerID, fetchErr)
}
}
// Save to Cache
log.Printf("Service started. Adding container %s - %s to cache", host, containerName)
log.Printf("Service started. Adding container %s - %s to cache", containerID[:12], containerName)
existing, _ := cache.services[key]
if existing.Version != "" && existing.Version != "latest" && (ver == "" || ver == "latest") {
ver = existing.Version
}
capabilities := existing.Capabilities
if manifest != nil {
capabilities = manifest.Capabilities
updateInstalledMetadataCapabilities(serviceLabel, manifest.Capabilities)
}
container := types.Container{
ID: containerID,
ServiceName: serviceLabel,
DockerName: containerName,
Image: event.Actor.Attributes["image"],
CreatedAt: fmt.Sprint(event.TimeNano),
Network: containerNetwork,
Status: "running",
Version: ver,
Title: existing.Title,
Vendor: existing.Vendor,
Author: existing.Author,
Description: existing.Description,
Source: existing.Source,
ID: containerID,
ServiceName: serviceLabel,
DockerName: containerName,
Image: event.Actor.Attributes["image"],
CreatedAt: fmt.Sprint(event.TimeNano),
Network: containerNetwork,
Status: "running",
Version: ver,
Role: role,
Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true",
Capabilities: capabilities,
Title: existing.Title,
Vendor: existing.Vendor,
Author: existing.Author,
Description: existing.Description,
Source: existing.Source,
}
if manifest != nil {
container.Name = manifest.Name
@@ -220,6 +235,12 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
entry.Status = normalizeDockerState(c.State)
entry.ID = c.ID
entry.DockerName = strings.TrimPrefix(c.Names[0], "/")
if role := c.Labels["org.dcntrlized.role"]; role != "" {
entry.Role = role
}
if c.Labels["org.dcntrlized.hidden"] == "true" {
entry.Hidden = true
}
cache.services[key] = entry
cache.Unlock()
}
+10
View File
@@ -50,6 +50,16 @@ type Container struct {
CreatedAt string `json:"created_at"`
Network Network `json:"network"`
// Role marks infrastructure containers (e.g. "db"); non-infrastructure
// services have an empty role.
Role string `json:"role,omitempty"`
// Hidden marks infrastructure services that Maestro manages but excludes
// from the service grid (e.g. the shared database).
Hidden bool `json:"hidden,omitempty"`
// Capabilities are the service capabilities declared by its manifest
// (e.g. ["database"]), surfaced so the UI can render capability chips.
Capabilities []string `json:"capabilities,omitempty"`
Title string `json:"title,omitempty"`
Vendor string `json:"vendor,omitempty"`
Author string `json:"author,omitempty"`
+6
View File
@@ -31,6 +31,9 @@ type RegistryContainer struct {
URL string `json:"url,omitempty"`
Documentation string `json:"documentation,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
// Hidden marks infrastructure images (e.g. the shared database) that are
// managed by Maestro but excluded from the service grid.
Hidden bool `json:"hidden,omitempty"`
}
type RawPackage struct {
@@ -47,4 +50,7 @@ type RawPackage struct {
type InstalledMetadata struct {
RegistryContainer
InstalledAt string `json:"installed_at,omitempty"`
// Capabilities are persisted from the service manifest once the service has
// run, so installed-but-stopped services can still surface them.
Capabilities []string `json:"capabilities,omitempty"`
}