[feat](maestro): adopt externally managed services without owning them

The dashboard sees containers Maestro did not create (each repo dev debug compose), and treating them as installs made start/uninstall/config act on a project file that never created them. Mark them external, refuse start/update/uninstall with a message pointing at their own compose project, and let stop work at the container level. Inject runtime env (config-signing public key, plaintext shared-DB DSN) on the managed start path, and keep the external flag a property of the service so a debug sibling cannot flip a managed service.
This commit is contained in:
2026-10-19 02:40:05 +01:00
parent 7afd019215
commit 13f6d14521
12 changed files with 738 additions and 183 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ func UninstallContainerHandler(cache *libcontainers.ContainerCache) http.Handler
if err := libcontainers.UninstallContainer(r.Context(), req); err != nil { if err := libcontainers.UninstallContainer(r.Context(), req); err != nil {
logger.Error("Error uninstalling container '%s': %v", req.Name, err) logger.Error("Error uninstalling container '%s': %v", req.Name, err)
http.Error(w, "Failed to uninstall container: "+err.Error(), http.StatusInternalServerError) writeLifecycleError(w, "Failed to uninstall container: ", err)
return return
} }
+14 -2
View File
@@ -17,6 +17,7 @@ package containers
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
@@ -24,6 +25,17 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
) )
// writeLifecycleError reports a failed lifecycle action with a status that
// reflects the cause: a service Maestro has neither a compose project nor a
// container for is a conflict (409), not an internal error.
func writeLifecycleError(w http.ResponseWriter, prefix string, err error) {
if errors.Is(err, libcontainers.ErrNotInstalled) {
http.Error(w, prefix+err.Error(), http.StatusConflict)
return
}
http.Error(w, prefix+err.Error(), http.StatusInternalServerError)
}
// StartContainerHandler starts a previously installed service container. // StartContainerHandler starts a previously installed service container.
func StartContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc { func StartContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -48,7 +60,7 @@ func StartContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc
if err := libcontainers.StartContainer(r.Context(), req); err != nil { if err := libcontainers.StartContainer(r.Context(), req); err != nil {
logger.Error("Error starting container '%s': %v", req.Name, err) logger.Error("Error starting container '%s': %v", req.Name, err)
http.Error(w, "Failed to start container: "+err.Error(), http.StatusInternalServerError) writeLifecycleError(w, "Failed to start container: ", err)
return return
} }
@@ -87,7 +99,7 @@ func StopContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc
if err := libcontainers.StopContainer(r.Context(), req); err != nil { if err := libcontainers.StopContainer(r.Context(), req); err != nil {
logger.Error("Error stopping container '%s': %v", req.Name, err) logger.Error("Error stopping container '%s': %v", req.Name, err)
http.Error(w, "Failed to stop container: "+err.Error(), http.StatusInternalServerError) writeLifecycleError(w, "Failed to stop container: ", err)
return return
} }
+53
View File
@@ -0,0 +1,53 @@
// 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 (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
)
// A service Maestro has neither a compose project nor a container for is a
// conflict the operator can act on — not an internal error.
func TestWriteLifecycleErrorNotInstalledIsConflict(t *testing.T) {
rec := httptest.NewRecorder()
err := fmt.Errorf("%w: store", libcontainers.ErrNotInstalled)
writeLifecycleError(rec, "Failed to stop container: ", err)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
}
if body := rec.Body.String(); !strings.Contains(body, "not installed by Maestro") {
t.Fatalf("body = %q, want the ErrNotInstalled message", body)
}
}
// Every other failure keeps the historical 500.
func TestWriteLifecycleErrorOtherIsInternal(t *testing.T) {
rec := httptest.NewRecorder()
writeLifecycleError(rec, "Failed to start container: ", fmt.Errorf("boom"))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
}
+215
View File
@@ -0,0 +1,215 @@
// 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"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
)
// ErrNotInstalled is returned by a lifecycle action when the service has no
// Maestro-owned compose project and no existing container to operate on. It lets
// the HTTP layer answer a conflict instead of a generic internal error.
var ErrNotInstalled = errors.New("service is not installed by Maestro")
// composeProjectAvailable reports whether Maestro owns (has downloaded) the
// compose project file for a service. The dashboard can also show containers
// that Maestro did not install — e.g. the dev debug fleet, started from each
// repo's own docker-compose.debug.yaml, which still carry the
// org.dcntrlized.service label. Those must be driven at the container level.
func composeProjectAvailable(serviceName string) bool {
dir, err := getContainerDataDir(serviceName)
if err != nil {
return false
}
info, err := os.Stat(filepath.Join(dir, "docker-compose.yaml"))
return err == nil && !info.IsDir()
}
// serviceContainers returns the containers a lifecycle action should target.
// An exact docker-name match for the requested name wins (the dashboard card is
// keyed by container name); otherwise every container of the resolved compose
// project or matching dcntrlized service label is targeted.
func serviceContainers(ctx context.Context, dc *docker.DockerClient, serviceName, requestedName string) ([]container.Summary, error) {
filter := filters.NewArgs()
filter.Add("label", "org.dcntrlized.service")
all, err := dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
return selectServiceContainers(all, serviceName, requestedName), nil
}
// selectServiceContainers picks the containers a lifecycle action targets from
// a listing. An exact docker-name match for the requested name wins (the
// dashboard card is keyed by container name); otherwise every container of the
// resolved compose project or matching dcntrlized service label is targeted.
func selectServiceContainers(all []container.Summary, serviceName, requestedName string) []container.Summary {
var exact, labeled []container.Summary
for _, c := range all {
if containerNameMatches(c, requestedName) {
exact = append(exact, c)
continue
}
if containerBelongsToService(c, serviceName, requestedName) {
labeled = append(labeled, c)
}
}
if len(exact) > 0 {
return exact
}
return labeled
}
// containerNameMatches reports whether the container's docker name equals the
// requested name (case-insensitive).
func containerNameMatches(c container.Summary, requestedName string) bool {
if requestedName == "" {
return false
}
for _, n := range c.Names {
if strings.EqualFold(filepath.Base(n), requestedName) {
return true
}
}
return false
}
// containerBelongsToService reports whether the container belongs to the
// resolved compose project or carries a matching dcntrlized service label.
func containerBelongsToService(c container.Summary, serviceName, requestedName string) bool {
if project := c.Labels["com.docker.compose.project"]; project != "" {
if strings.EqualFold(project, serviceName) {
return true
}
}
label := c.Labels["org.dcntrlized.service"]
if label == "" {
return false
}
return strings.EqualFold(label, serviceName) || strings.EqualFold(label, requestedName)
}
// containerLabel returns a human-readable identifier for logs and errors.
func containerLabel(c container.Summary) string {
if len(c.Names) > 0 {
return filepath.Base(c.Names[0])
}
if id := c.ID; len(id) > 12 {
return id[:12]
}
return c.ID
}
// stopExternalContainers stops the containers of a service Maestro does not
// manage through compose.
func stopExternalContainers(ctx context.Context, dc *docker.DockerClient, serviceName, requestedName string) error {
containers, err := serviceContainers(ctx, dc, serviceName, requestedName)
if err != nil {
return err
}
if len(containers) == 0 {
return fmt.Errorf("%w: %s", ErrNotInstalled, serviceName)
}
for _, c := range containers {
if !strings.EqualFold(c.State, "running") && !strings.EqualFold(c.State, "restarting") {
continue
}
name := containerLabel(c)
if err := dc.ContainerStop(ctx, c.ID, container.StopOptions{}); err != nil {
return fmt.Errorf("failed to stop container %s: %w", name, err)
}
logger.Info("Stopped externally managed container %s (service %s)", name, serviceName)
}
return nil
}
// installedForAny reports whether Maestro has install metadata for any of the
// candidate names. The dcntrlized service label may carry a display name
// ("KeepNote") while install dirs are lowercase ("keepnote"), so both forms are
// tried.
func installedForAny(names ...string) bool {
for _, n := range names {
if n == "" {
continue
}
if IsInstalled(n) || IsInstalled(strings.ToLower(n)) {
return true
}
}
return false
}
// composeConfigUnderDataRoot reports whether a container's compose provenance
// label points at a compose file inside Maestro's data root — i.e. the file
// Maestro itself downloaded for the service. A container created from a repo's
// own compose file (e.g. a debug compose in the checkout) fails this check.
func composeConfigUnderDataRoot(configFiles string) bool {
if configFiles == "" {
return false
}
root, err := getDataRoot()
if err != nil {
return false
}
root = filepath.Clean(root)
for _, f := range strings.Split(configFiles, ",") {
dir := filepath.Clean(filepath.Dir(strings.TrimSpace(f)))
if dir == root || strings.HasPrefix(dir, root+string(os.PathSeparator)) {
return true
}
}
return false
}
// externalFromLabels decides ownership from a container's labels alone (the
// watcher has no other context): a service Maestro has install metadata for,
// whose container came from Maestro's own compose file, is managed; anything
// else was started outside Maestro.
func externalFromLabels(serviceName string, labels map[string]string) bool {
if !installedForAny(serviceName) {
return true
}
return !composeConfigUnderDataRoot(labels["com.docker.compose.project.config_files"])
}
// isExternalService reports whether every container of a service was started
// outside Maestro. Install metadata is authoritative; the compose provenance
// label catches a container recreated from another project after an install. A
// service with no container at all is external unless Maestro installed it.
func isExternalService(ctx context.Context, dc *docker.DockerClient, serviceName, requestedName string) bool {
cs, err := serviceContainers(ctx, dc, serviceName, requestedName)
if err != nil || len(cs) == 0 {
return !installedForAny(serviceName, requestedName)
}
for _, c := range cs {
if !externalFromLabels(c.Labels["org.dcntrlized.service"], c.Labels) {
return false
}
}
return true
}
+228
View File
@@ -0,0 +1,228 @@
// 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"
"errors"
"os"
"path/filepath"
"testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"github.com/docker/docker/api/types/container"
)
func summary(id string, names []string, labels map[string]string) container.Summary {
return container.Summary{ID: id, Names: names, Labels: labels, State: "exited"}
}
// A dashboard card is keyed by container name, so an exact docker-name match
// must win over sibling containers sharing the dcntrlized service label. This is
// the dev debug case: `keepnote-debug` (and a stale `keepnote`) both carry
// org.dcntrlized.service=KeepNote.
func TestSelectServiceContainersPrefersExactName(t *testing.T) {
all := []container.Summary{
summary("aaa", []string{"/keepnote-debug"}, map[string]string{
"org.dcntrlized.service": "KeepNote",
"com.docker.compose.project": "keepnote",
}),
summary("bbb", []string{"/keepnote"}, map[string]string{
"org.dcntrlized.service": "KeepNote",
"com.docker.compose.project": "keepnote",
}),
}
got := selectServiceContainers(all, "keepnote", "keepnote-debug")
if len(got) != 1 || got[0].ID != "aaa" {
t.Fatalf("selected %+v, want only the keepnote-debug container", got)
}
}
// Without an exact name match, every container of the resolved project or the
// matching service label is targeted.
func TestSelectServiceContainersFallsBackToProject(t *testing.T) {
all := []container.Summary{
summary("aaa", []string{"/store-debug"}, map[string]string{
"org.dcntrlized.service": "Store",
"com.docker.compose.project": "store",
}),
summary("bbb", []string{"/store-migrate"}, map[string]string{
"org.dcntrlized.service": "Store",
"com.docker.compose.project": "store",
}),
summary("ccc", []string{"/unrelated"}, map[string]string{
"org.dcntrlized.service": "Other",
"com.docker.compose.project": "other",
}),
}
got := selectServiceContainers(all, "store", "")
if len(got) != 2 {
t.Fatalf("selected %d containers, want 2 (%+v)", len(got), got)
}
for _, c := range got {
if c.ID == "ccc" {
t.Fatalf("unrelated container selected: %+v", c)
}
}
}
// A display-name request (the manifest name, e.g. "Store") matches by label.
func TestSelectServiceContainersMatchesDisplayNameLabel(t *testing.T) {
all := []container.Summary{
summary("aaa", []string{"/store-debug"}, map[string]string{
"org.dcntrlized.service": "Store",
"com.docker.compose.project": "store",
}),
}
got := selectServiceContainers(all, "store", "Store")
if len(got) != 1 || got[0].ID != "aaa" {
t.Fatalf("selected %+v, want the Store container", got)
}
}
func TestSelectServiceContainersNoMatch(t *testing.T) {
all := []container.Summary{
summary("aaa", []string{"/other"}, map[string]string{"org.dcntrlized.service": "Other"}),
}
if got := selectServiceContainers(all, "store", "store-debug"); len(got) != 0 {
t.Fatalf("selected %+v, want none", got)
}
}
// TestExternalFromLabels locks the ownership rule: a service Maestro has install
// metadata for, whose container came from Maestro's own compose file, is
// managed; everything else (the dev debug fleet) is external and may only be
// stopped from the console.
func TestExternalFromLabels(t *testing.T) {
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
managed := map[string]string{
"com.docker.compose.project.config_files": filepath.Join(root, "keepnote", "docker-compose.yaml"),
}
foreign := map[string]string{
"com.docker.compose.project.config_files": "/home/dev/projects/dcntrlized/KeepNote/docker-compose.debug.yaml",
}
// No install metadata at all -> external, whatever the labels say.
if !externalFromLabels("KeepNote", managed) {
t.Fatalf("service without install metadata must be external")
}
if !externalFromLabels("KeepNote", foreign) {
t.Fatalf("service without install metadata must be external")
}
// Install metadata present (lowercase dir, display-name label).
if err := os.MkdirAll(filepath.Join(root, "keepnote"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "keepnote", "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
if externalFromLabels("KeepNote", managed) {
t.Fatalf("installed service with Maestro's compose file must be managed")
}
if !externalFromLabels("KeepNote", foreign) {
t.Fatalf("installed service recreated from a foreign compose file must be external")
}
if !externalFromLabels("KeepNote", nil) {
t.Fatalf("installed service with no compose provenance must be external")
}
}
// TestInstalledForAny covers the display-name/lowercase pair (the label carries
// "KeepNote" while the install dir is "keepnote").
func TestInstalledForAny(t *testing.T) {
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
if installedForAny("KeepNote") {
t.Fatalf("no install dir yet, got installed")
}
if err := os.MkdirAll(filepath.Join(root, "keepnote"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "keepnote", "metadata.json"), []byte("{}"), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
if !installedForAny("KeepNote") {
t.Fatalf("display-name label must resolve to the lowercase install dir")
}
if installedForAny("", " ") {
t.Fatalf("blank names must never count as installed")
}
}
// composeProjectAvailable is the gate between the compose path and the
// container-level fallback.
func TestComposeProjectAvailable(t *testing.T) {
root := t.TempDir()
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
if composeProjectAvailable("store") {
t.Fatalf("available with no data dir")
}
dir := filepath.Join(root, "store")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if composeProjectAvailable("store") {
t.Fatalf("available with an empty data dir")
}
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
t.Fatalf("write compose: %v", err)
}
if !composeProjectAvailable("store") {
t.Fatalf("not available with a compose file present")
}
}
func TestContainerLabel(t *testing.T) {
c := summary("0123456789abcdef0123", []string{"/store-debug"}, nil)
if got := containerLabel(c); got != "store-debug" {
t.Fatalf("label = %q, want store-debug", got)
}
c = summary("0123456789abcdef0123", nil, nil)
if got := containerLabel(c); got != "0123456789ab" {
t.Fatalf("label = %q, want 0123456789ab", got)
}
}
// TestLiveExternalLifecycle drives the external lifecycle against a real Docker
// daemon. It is opt-in: set DCNTRLIZED_LIVE_CONTAINER to a labeled container
// name to run it. Externally managed services are stop-only: StartContainer must
// refuse them with ErrNotInstalled.
func TestLiveExternalLifecycle(t *testing.T) {
name := os.Getenv("DCNTRLIZED_LIVE_CONTAINER")
if name == "" {
t.Skip("set DCNTRLIZED_LIVE_CONTAINER to run the live Docker probe")
}
ctx := context.Background()
if err := StopContainer(ctx, types.RegistryContainer{Name: name}); err != nil {
t.Fatalf("StopContainer(%s): %v", name, err)
}
err := StartContainer(ctx, types.RegistryContainer{Name: name})
if !errors.Is(err, ErrNotInstalled) {
t.Fatalf("StartContainer(%s) on an external service = %v, want ErrNotInstalled", name, err)
}
}
+10 -7
View File
@@ -73,10 +73,11 @@ func InstallContainer(ctx context.Context, item types.RegistryContainer) error {
} }
// 2.6. Pin the auth service's signing key (security theme M2 §5): fetch its // 2.6. Pin the auth service's signing key (security theme M2 §5): fetch its
// attestation over mTLS and store the fingerprint in the install record so // attestation over plaintext HTTP and store the fingerprint in the install
// the container gets AUTH_PINNED_FINGERPRINT at start. Best-effort — when // record so the container gets AUTH_PINNED_FINGERPRINT at start. This is an
// the auth service is not reachable yet the pin stays empty and is retried // app-level fingerprint comparison, not a transport identity. Best-effort —
// on start. // when the auth service is not reachable yet the pin stays empty and is
// retried on start.
if att, err := fetchAuthAttestation(ctx); err == nil { if att, err := fetchAuthAttestation(ctx); err == nil {
metadata.AuthFingerprint = att.Fingerprint metadata.AuthFingerprint = att.Fingerprint
metadata.AuthKid = att.Kid metadata.AuthKid = att.Kid
@@ -176,7 +177,8 @@ func getDataRoot() (string, error) {
} }
// DataRoot is the exported accessor for the storage root shared by installed // DataRoot is the exported accessor for the storage root shared by installed
// container metadata and the instance PKI (CA + service identities). // container metadata and Maestro's private key material (instance identity,
// session/secrets keys, config-signing keypair).
func DataRoot() (string, error) { func DataRoot() (string, error) {
return getDataRoot() return getDataRoot()
} }
@@ -500,8 +502,9 @@ func writeMetadata(path string, metadata types.InstalledMetadata) error {
} }
// fetchAuthAttestation fetches the auth service's public key statement over // fetchAuthAttestation fetches the auth service's public key statement over
// mTLS (Maestro's own identity certificate). It backs the M2 §5 TOFU pin: the // plaintext HTTP (rev 9 removed the mTLS identity layer). It backs the M2 §5
// returned fingerprint is what consumer containers are pinned to. // app-level pin: the returned fingerprint is what consumer containers are
// pinned to.
func fetchAuthAttestation(ctx context.Context) (*authmgmt.Attestation, error) { func fetchAuthAttestation(ctx context.Context) (*authmgmt.Attestation, error) {
client, err := authmgmt.New() client, err := authmgmt.New()
if err != nil { if err != nil {
+73 -116
View File
@@ -18,24 +18,20 @@ package containers
import ( import (
"context" "context"
"fmt" "fmt"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db" "git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets" "git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
composetypes "github.com/compose-spec/compose-go/v2/types" composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/api"
) )
// identityMountDir is where Maestro bind-mounts the instance CA and the
// service's identity certificate inside every peer container.
const identityMountDir = "/dcntrlized/pki"
// StartContainer creates and starts the service containers defined in the // StartContainer creates and starts the service containers defined in the
// service's docker-compose.yaml. // service's docker-compose.yaml.
func StartContainer(ctx context.Context, item types.RegistryContainer) error { func StartContainer(ctx context.Context, item types.RegistryContainer) error {
@@ -46,6 +42,16 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
defer dc.Close() defer dc.Close()
serviceName := resolveServiceName(ctx, dc, item.Name) serviceName := resolveServiceName(ctx, dc, item.Name)
// Containers the dashboard shows are not always Maestro installs: the dev
// debug fleet (started from each repo's own docker-compose.debug.yaml) also
// carries the dcntrlized service label. Those are not Maestro's to run —
// the console can stop them, but starting them stays a manual action in
// their own compose project.
if isExternalService(ctx, dc, serviceName, item.Name) {
return fmt.Errorf("%w: %s is externally managed — start it from its own compose project", ErrNotInstalled, serviceName)
}
project, err := loadComposeProject(ctx, serviceName) project, err := loadComposeProject(ctx, serviceName)
if err != nil { if err != nil {
return err return err
@@ -63,15 +69,11 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
} }
} }
// Issue (or refresh) the service's identity certificate from the instance // Inject the service's runtime env (rev 9): identity env names, the
// CA and mount it plus the CA bundle into the container. The compose file // dedicated config-signing public key when the service declares a config
// downloaded from git never declares these mounts — Maestro injects them // block, and the plaintext shared-database DSN for database-capable
// (security theme M1 §2). Failing to provision identity is fatal: a peer // services. There is no PKI identity, no cert mount and no mTLS.
// without a cert cannot authenticate on the mTLS edges. Database-capable if err := injectServiceRuntimeEnv(ctx, project, serviceName); err != nil {
// services additionally get their per-service database role DSN here (M2
// §4): on an armed (mTLS) shared database the shared-superuser URL from
// their compose file cannot connect at all.
if err := injectServiceIdentity(ctx, dc, project, serviceName); err != nil {
return err return err
} }
// Operator-configured env (Service Config & Secrets theme M1): inject the // Operator-configured env (Service Config & Secrets theme M1): inject the
@@ -117,68 +119,45 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
return nil return nil
} }
// injectServiceIdentity issues (or refreshes) the service's identity // injectServiceRuntimeEnv injects the runtime env every non-oneoff compose
// certificate from the instance CA and injects the CA bundle, the identity // service needs after rev 9:
// cert/key, and the mTLS env vars into every non-oneoff compose service. The
// env vars follow the shared-module contract (AUTH_CA_BUNDLE/AUTH_CLIENT_CERT/
// AUTH_CLIENT_KEY) so consumers need no per-service config.
// //
// It also injects AUTH_PINNED_FINGERPRINT (M2 §5 TOFU): the auth signing key's // - SERVICE_NAME / DCNTRLIZED_SERVICE_NAME: the compose service name.
// fingerprint recorded at install. A service that already has the pin skips the // - DCNTRLIZED_CONFIG_PUBKEY: the base64 DER of Maestro's DEDICATED
// fetch; one installed before this feature (or whose install could not reach // config-signing public key, injected only into services that declare a
// the auth service) backfills it here, best-effort. // `config` block (they are the only ones that can ever receive a /config
// // delivery). The service verifies tokens with it and can never mint them.
// For services declaring the database capability (M2 §4), when the shared // - DATABASE_URL: a plaintext per-service DSN for database-capable services
// database runs mTLS-armed, every app service additionally receives a // (rev 9 removed the DSN mTLS; the shared database now uses
// per-service DATABASE_URL (role = the service's cert CN, verify-full against // scram-sha-256 with a password).
// the instance CA), overriding the shared-superuser URL their compose file // - AUTH_PINNED_FINGERPRINT: the app-level TOFU pin of the auth service's
// ships. One-off helpers (migrate/seed) get the identity mount plus libpq SSL // signing key (unchanged; it is not an mTLS control).
// env so they can reach the armed database under the same role. func injectServiceRuntimeEnv(ctx context.Context, project *composetypes.Project, serviceName string) error {
func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, serviceName string) error {
dataRoot, err := getDataRoot() dataRoot, err := getDataRoot()
if err != nil { if err != nil {
return fmt.Errorf("resolve data root: %w", err) return fmt.Errorf("resolve data root: %w", err)
} }
ca, err := pki.EnsureCA(dataRoot)
if err != nil {
return fmt.Errorf("ensure instance CA: %w", err)
}
identity, err := pki.EnsureServiceIdentity(ca, serviceName)
if err != nil {
return fmt.Errorf("provision identity for %s: %w", serviceName, err)
}
mounts := []string{
fmt.Sprintf("%s:%s/ca.crt:ro", hostBindSource(dataRoot, pki.CertPath(dataRoot)), identityMountDir),
fmt.Sprintf("%s:%s/tls.crt:ro", hostBindSource(dataRoot, identity.CertPath), identityMountDir),
fmt.Sprintf("%s:%s/tls.key:ro", hostBindSource(dataRoot, identity.KeyPath), identityMountDir),
}
env := map[string]string{ env := map[string]string{
"SERVICE_NAME": serviceName, "SERVICE_NAME": serviceName,
"AUTH_CA_BUNDLE": identityMountDir + "/ca.crt", "DCNTRLIZED_SERVICE_NAME": serviceName,
"AUTH_CLIENT_CERT": identityMountDir + "/tls.crt",
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
} }
// Per-service database role DSN (M2 §4). Only on an armed database — a // The config-signing public key is only useful to services that declare a
// plain (compose-managed dev) Postgres cannot satisfy verify-full, and its // config block. declaredConfigFields errors for services without install
// stock trust rule needs no per-service roles anyway. Best-effort: without // metadata (e.g. externally-managed dev containers); that is not fatal.
// the capability in the install record (pre-manifest installs) nothing is if declared, err := declaredConfigFields(serviceName); err == nil && len(declared) > 0 {
// injected and the compose defaults apply. signer, err := configsign.LoadOrCreate(dataRoot)
dbEnv := map[string]string{} if err != nil {
return fmt.Errorf("load config signing key: %w", err)
}
env[configsign.PublicKeyEnv] = signer.PublicKeyBase64()
}
// Plaintext database DSN for database-capable services (never the DB
// itself, whose compose file owns its own credentials).
if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) { if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) {
if dbctl.New(dc).Armed(ctx) { env["DATABASE_URL"] = plaintextDatabaseURL()
env["DATABASE_URL"] = mtlsDatabaseURL(serviceName)
dbEnv = map[string]string{
"PGUSER": serviceName,
"PGSSLMODE": "verify-full",
"PGSSLCERT": identityMountDir + "/tls.crt",
"PGSSLKEY": identityMountDir + "/tls.key",
"PGSSLROOTCERT": identityMountDir + "/ca.crt",
}
logger.Info("Injected per-service database DSN for %s (armed shared database)", serviceName)
}
} }
// The auth service is the attester itself — nothing to pin there. For every // The auth service is the attester itself — nothing to pin there. For every
@@ -192,33 +171,45 @@ func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project
for name, s := range project.Services { for name, s := range project.Services {
if isOneoff(s) { if isOneoff(s) {
// Migrate/seed helpers talk libpq/psycopg straight to the shared // Migrate/seed helpers talk libpq/psycopg straight to the shared
// database; on an armed database they need the identity mount plus // database; they need the same plaintext DSN and role.
// the same per-service role (their compose file's PGUSER is the if dsn, ok := env["DATABASE_URL"]; ok {
// plain-dev default). s.Environment = mergeEnv(s.Environment, map[string]string{
if len(dbEnv) > 0 { "DATABASE_URL": dsn,
s.Volumes = append(s.Volumes, composeVolumes(mounts)...) "PGUSER": databaseUser,
s.Environment = mergeEnv(s.Environment, dbEnv) })
project.Services[name] = s project.Services[name] = s
} }
continue continue
} }
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
s.Environment = mergeEnv(s.Environment, env) s.Environment = mergeEnv(s.Environment, env)
project.Services[name] = s project.Services[name] = s
} }
logger.Info("Injected service identity for %s (fingerprint %s)", serviceName, identity.Meta.Fingerprint) logger.Info("Injected runtime env for %s", serviceName)
return nil return nil
} }
// mtlsDatabaseURL builds the per-service connection URL for the shared // databaseName / databaseUser / defaultDatabasePassword are the shared
// database: role = the service's cert CN, no password (clientcert=verify-full // database's plaintext credentials after rev 9. The dcntrlized-db compose
// makes the certificate the credential), TLS verified against the instance CA. // declares POSTGRES_HOST_AUTH_METHOD=scram-sha-256 and
func mtlsDatabaseURL(serviceName string) string { // POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dcntrlized}; Maestro injects the same
return fmt.Sprintf( // password (overridable with DCNTRLIZED_DB_PASSWORD) so every service connects
"postgres://%s@%s:5432/%s?sslmode=verify-full&sslrootcert=%s/ca.crt&sslcert=%s/tls.crt&sslkey=%s/tls.key", // as postgres instead of the old cert-CN role.
serviceName, DatabaseServiceName, dbctl.DBName, const (
identityMountDir, identityMountDir, identityMountDir, databaseName = "dcntrlized"
databaseUser = "postgres"
defaultDatabasePassword = "dcntrlized"
) )
// plaintextDatabaseURL builds the shared database DSN: plaintext TCP with
// scram-sha-256 password auth (sslmode=disable). No certs, no per-service
// role.
func plaintextDatabaseURL() string {
pw := os.Getenv("DCNTRLIZED_DB_PASSWORD")
if pw == "" {
pw = defaultDatabasePassword
}
return fmt.Sprintf("postgres://%s@%s:5432/%s?sslmode=disable",
url.UserPassword(databaseUser, pw).String(), DatabaseServiceName, databaseName)
} }
// injectConfigSecrets injects the operator-configured env values (Service // injectConfigSecrets injects the operator-configured env values (Service
@@ -337,22 +328,6 @@ func hasDatabaseCapability(serviceName string) bool {
return false return false
} }
// hostBindSource maps a dataRoot-relative path to a bind-mount source the
// Docker daemon can resolve. Bind sources are resolved on the daemon's host
// namespace, but inside the dev container the data root lives at a
// container-internal path (/data/.dcntrlized via XDG_DATA_HOME=/data) that
// does not exist on the host — the daemon would silently create it as an
// empty directory. DCNTRLIZED_HOST_DATA_ROOT (compose-injected, e.g.
// $HOME/.dcntrlized) carries the host path backing the data root; when set,
// the container-internal prefix is swapped for it. On a host install the path
// is already host-resolvable and returned unchanged.
func hostBindSource(dataRoot, p string) string {
if host := os.Getenv("DCNTRLIZED_HOST_DATA_ROOT"); host != "" && strings.HasPrefix(p, dataRoot) {
return filepath.Join(host, strings.TrimPrefix(p, dataRoot))
}
return p
}
// pinAuthFingerprint returns the service's pinned auth fingerprint, fetching // pinAuthFingerprint returns the service's pinned auth fingerprint, fetching
// and persisting it when the install record does not have one yet (M2 §5). // and persisting it when the install record does not have one yet (M2 §5).
// Best-effort: when the auth service is unreachable, it returns "" and the // Best-effort: when the auth service is unreachable, it returns "" and the
@@ -388,24 +363,6 @@ func pinAuthFingerprint(ctx context.Context, serviceName string) string {
return att.Fingerprint return att.Fingerprint
} }
// composeVolumes converts "src:dst:ro" mount strings to compose volume configs.
func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig {
out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts))
for _, m := range mounts {
parts := strings.SplitN(m, ":", 3)
v := composetypes.ServiceVolumeConfig{
Type: "bind",
Source: parts[0],
Target: parts[1],
}
if len(parts) == 3 && strings.Contains(parts[2], "ro") {
v.ReadOnly = true
}
out = append(out, v)
}
return out
}
// isOneoff reports whether a compose service is a one-shot helper (e.g. the // isOneoff reports whether a compose service is a one-shot helper (e.g. the
// migrate container) via the org.dcntrlized.oneoff label the compose file // migrate container) via the org.dcntrlized.oneoff label the compose file
// declares. // declares.
+92 -12
View File
@@ -16,32 +16,112 @@
package containers package containers
import ( import (
"context"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets" "git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
composetypes "github.com/compose-spec/compose-go/v2/types" composetypes "github.com/compose-spec/compose-go/v2/types"
) )
func TestHostBindSource(t *testing.T) { // writeTestMetadataFull is writeTestMetadata plus declared capabilities, used
t.Setenv("DCNTRLIZED_HOST_DATA_ROOT", "") // by the rev-9 runtime-env test (database DSN injection).
got := hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/services/auth/tls.crt") func writeTestMetadataFull(t *testing.T, dataRoot, serviceName string, config []types.ConfigItem, capabilities []string) {
if got != "/data/.dcntrlized/pki/services/auth/tls.crt" { t.Helper()
t.Fatalf("without host root the path must pass through, got %q", got) dir := filepath.Join(dataRoot, serviceName)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("MkdirAll %s: %v", dir, err)
}
m := types.InstalledMetadata{
RegistryContainer: types.RegistryContainer{Name: serviceName},
Capabilities: capabilities,
Config: config,
}
data, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), data, 0o600); err != nil {
t.Fatalf("write metadata.json: %v", err)
}
} }
t.Setenv("DCNTRLIZED_HOST_DATA_ROOT", "/home/user/.dcntrlized") // TestInjectServiceRuntimeEnv covers the rev-9 runtime env: plaintext service
got = hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/services/auth/tls.crt") // names on every service, the config-signing public key only for services that
if want := "/home/user/.dcntrlized/pki/services/auth/tls.crt"; got != want { // declare config fields, a plaintext password DSN for database-capable
t.Fatalf("host root must replace the container-internal prefix, got %q want %q", got, want) // services (app + one-off helpers), and NO mTLS env anywhere.
func TestInjectServiceRuntimeEnv(t *testing.T) {
root := t.TempDir()
t.Setenv("XDG_DATA_HOME", root)
t.Setenv("DCNTRLIZED_DB_PASSWORD", "")
// Point the auth pin fetch at a closed port so it fails fast and
// deterministically instead of resolving a container DNS name.
t.Setenv("AUTH_SERVICE_URL", "http://127.0.0.1:1")
dataRoot := filepath.Join(root, ".dcntrlized")
writeTestMetadataFull(t, dataRoot, "keepnote",
[]types.ConfigItem{{"api_key": {Type: types.ConfigCleartext, Key: "API_KEY"}}},
[]string{"database"})
// A service with neither config nor database capability.
writeTestMetadataFull(t, dataRoot, "plain", nil, nil)
project := &composetypes.Project{Services: map[string]composetypes.ServiceConfig{
"app": {Environment: map[string]*string{}},
"migrate": {CustomLabels: map[string]string{"org.dcntrlized.oneoff": "true"}, Environment: map[string]*string{}},
}}
if err := injectServiceRuntimeEnv(context.Background(), project, "keepnote"); err != nil {
t.Fatalf("injectServiceRuntimeEnv: %v", err)
} }
got = hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/ca.crt") app := project.Services["app"]
if want := "/home/user/.dcntrlized/pki/ca.crt"; got != want { if got := envValue(app.Environment, "DCNTRLIZED_SERVICE_NAME"); got != "keepnote" {
t.Fatalf("ca path must be remapped too, got %q want %q", got, want) t.Errorf("DCNTRLIZED_SERVICE_NAME = %q, want keepnote", got)
}
if got := envValue(app.Environment, "SERVICE_NAME"); got != "keepnote" {
t.Errorf("SERVICE_NAME = %q, want keepnote", got)
}
if got := envValue(app.Environment, configsign.PublicKeyEnv); got == "" {
t.Error("config-declaring service must receive the config-signing public key")
}
dsn := envValue(app.Environment, "DATABASE_URL")
if !strings.Contains(dsn, "postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable") {
t.Errorf("DATABASE_URL = %q, want the plaintext scram DSN", dsn)
}
// No mTLS material may leak into the container env.
for _, banned := range []string{"AUTH_CA_BUNDLE", "AUTH_CLIENT_CERT", "AUTH_CLIENT_KEY", "PGSSLMODE", "PGSSLCERT"} {
if got := envValue(app.Environment, banned); got != "" {
t.Errorf("%s must not be injected after the mTLS removal, got %q", banned, got)
}
}
// One-off helpers get the DSN + role so migrate/seed can reach the DB.
migrate := project.Services["migrate"]
if got := envValue(migrate.Environment, "PGUSER"); got != "postgres" {
t.Errorf("one-off PGUSER = %q, want postgres", got)
}
if got := envValue(migrate.Environment, "DATABASE_URL"); got == "" {
t.Error("one-off helper must receive DATABASE_URL")
}
// A service with no config block gets no signing key; no database
// capability means no DSN.
plainProject := &composetypes.Project{Services: map[string]composetypes.ServiceConfig{
"app": {Environment: map[string]*string{}},
}}
if err := injectServiceRuntimeEnv(context.Background(), plainProject, "plain"); err != nil {
t.Fatalf("injectServiceRuntimeEnv(plain): %v", err)
}
plain := plainProject.Services["app"]
if got := envValue(plain.Environment, configsign.PublicKeyEnv); got != "" {
t.Errorf("non-config service must not receive a signing key, got %q", got)
}
if got := envValue(plain.Environment, "DATABASE_URL"); got != "" {
t.Errorf("non-database service must not receive a DSN, got %q", got)
} }
} }
+8
View File
@@ -35,6 +35,14 @@ func StopContainer(ctx context.Context, item types.RegistryContainer) error {
defer dc.Close() defer dc.Close()
serviceName := resolveServiceName(ctx, dc, item.Name) serviceName := resolveServiceName(ctx, dc, item.Name)
// Externally managed containers (dev debug fleet) are not Maestro's to
// compose: stop them at the container level instead of failing, and never
// run compose down against a project file that did not create them.
if isExternalService(ctx, dc, serviceName, item.Name) || !composeProjectAvailable(serviceName) {
return stopExternalContainers(ctx, dc, serviceName, item.Name)
}
project, err := loadComposeProject(ctx, serviceName) project, err := loadComposeProject(ctx, serviceName)
if err != nil { if err != nil {
return err return err
+11 -43
View File
@@ -21,11 +21,8 @@ import (
"os" "os"
"strings" "strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/api"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@@ -43,26 +40,22 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
defer dc.Close() defer dc.Close()
serviceName := resolveServiceName(ctx, dc, item.Name) serviceName := resolveServiceName(ctx, dc, item.Name)
// Externally managed services have no Maestro install record: refuse instead
// of revoking an identity Maestro never issued and deleting the image while
// leaving the container behind.
if isExternalService(ctx, dc, serviceName, item.Name) {
return fmt.Errorf("%w: %s is externally managed — remove it from its own compose project", ErrNotInstalled, serviceName)
}
targetDir, err := getContainerDataDir(serviceName) targetDir, err := getContainerDataDir(serviceName)
if err != nil { if err != nil {
return fmt.Errorf("failed to determine data directory: %w", err) return fmt.Errorf("failed to determine data directory: %w", err)
} }
// Revoke the service's identity certificate and publish the CRL entry // Rev 9: there is no identity certificate, CRL or per-service database
// (security theme M1 §2 / M3 §2). A revoked service stops authenticating // grant to revoke — the PKI layer and the DB role model are removed. The
// on the mTLS edges within one CRL freshness window. The authorization in // container, data directory and image are all that is left to clean up.
// the auth service's trust store is revoked too (M2 §3 step 5) — the
// fingerprint is read before the identity files are removed.
fingerprint := serviceFingerprint(serviceName)
if err := revokeServiceIdentity(serviceName); err != nil {
logger.Warn("Failed to revoke identity for %s: %v", serviceName, err)
}
if fingerprint != "" {
authmgmt.RevokeServiceBestEffort(ctx, serviceName, fingerprint)
}
// Drop the database CONNECT grant so the revoked peer cannot open a session
// against the shared database either (M2 §4).
dbctl.New(dc).RevokeForService(ctx, serviceName)
if _, err := os.Stat(targetDir); err == nil { if _, err := os.Stat(targetDir); err == nil {
// Stop and remove the project containers/networks via compose down when // Stop and remove the project containers/networks via compose down when
@@ -103,31 +96,6 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
return nil return nil
} }
// serviceFingerprint reads the service's identity fingerprint (the public key
// fingerprint the auth service's trust store is keyed on), or "" when the
// identity was never issued.
func serviceFingerprint(serviceName string) string {
dataRoot, err := getDataRoot()
if err != nil {
return ""
}
meta, err := pki.LoadServiceMeta(dataRoot, serviceName)
if err != nil {
return ""
}
return meta.Fingerprint
}
// revokeServiceIdentity revokes the service's certificate in the instance CA.
// Best-effort: uninstall proceeds even if the CA or identity is missing.
func revokeServiceIdentity(serviceName string) error {
dataRoot, err := getDataRoot()
if err != nil {
return err
}
return pki.Revoke(dataRoot, serviceName)
}
// stopAndRemoveByLabel force-stops and removes every container carrying the // stopAndRemoveByLabel force-stops and removes every container carrying the
// org.dcntrlized.service label for the given service. // org.dcntrlized.service label for the given service.
func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) { func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) {
+22
View File
@@ -113,6 +113,7 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
hasAuth := existing.HasAuth hasAuth := existing.HasAuth
publicKey := existing.PublicKey publicKey := existing.PublicKey
config := existing.Config config := existing.Config
web := existing.Web
if manifest != nil { if manifest != nil {
capabilities = manifest.Capabilities capabilities = manifest.Capabilities
hasAuth = manifest.HasAuth hasAuth = manifest.HasAuth
@@ -120,8 +121,19 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
if manifest.Config != nil { if manifest.Config != nil {
config = manifest.Config config = manifest.Config
} }
// Honesty rule (contract §16.1): the manifest only carries a
// `web` block while the listener is enabled and serving, so
// the live value replaces the cached one (including with nil).
web = manifest.Web
updateInstalledMetadataFromManifest(serviceLabel, manifest) updateInstalledMetadataFromManifest(serviceLabel, manifest)
} }
// Ownership is a property of the service, not of one container:
// the dev fleet can share a service label with a Maestro-managed
// container (auth-debug alongside dcntrlized-auth), so a start
// event from that sibling must not flip the service to external.
// Ask the same question the lifecycle guards ask, so the card and
// the API can never disagree.
external := isExternalService(ctx, dc, serviceLabel, containerName)
container := types.Container{ container := types.Container{
ID: containerID, ID: containerID,
ServiceName: serviceLabel, ServiceName: serviceLabel,
@@ -133,10 +145,12 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
Version: ver, Version: ver,
Role: role, Role: role,
Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true", Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true",
External: external,
Capabilities: capabilities, Capabilities: capabilities,
HasAuth: hasAuth, HasAuth: hasAuth,
PublicKey: publicKey, PublicKey: publicKey,
Config: config, Config: config,
Web: web,
Title: existing.Title, Title: existing.Title,
Vendor: existing.Vendor, Vendor: existing.Vendor,
Author: existing.Author, Author: existing.Author,
@@ -234,6 +248,11 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
key := normalizeName(serviceLabel) key := normalizeName(serviceLabel)
cache.Lock() cache.Lock()
entry, has := cache.services[key] entry, has := cache.services[key]
// Ownership is a property of the service, not of a single container. The
// dev fleet shares service labels with managed containers (auth-debug
// alongside dcntrlized-auth, the one-off auth-migrate), so the service is
// external only when none of its containers is Maestro-managed.
external := externalFromLabels(serviceLabel, c.Labels)
if !has { if !has {
entry = types.Container{ entry = types.Container{
ServiceName: serviceLabel, ServiceName: serviceLabel,
@@ -241,7 +260,10 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
Image: c.Image, Image: c.Image,
Version: imageTag(c.Image), Version: imageTag(c.Image),
Status: "installed", Status: "installed",
External: external,
} }
} else {
entry.External = entry.External && external
} }
entry.Status = normalizeDockerState(c.State) entry.Status = normalizeDockerState(c.State)
entry.ID = c.ID entry.ID = c.ID
+9
View File
@@ -56,6 +56,10 @@ type Container struct {
// Hidden marks infrastructure services that Maestro manages but excludes // Hidden marks infrastructure services that Maestro manages but excludes
// from the service grid (e.g. the shared database). // from the service grid (e.g. the shared database).
Hidden bool `json:"hidden,omitempty"` Hidden bool `json:"hidden,omitempty"`
// External marks a container started outside Maestro (e.g. a repo's own
// docker-compose.debug.yaml): the console can stop it but must not start,
// uninstall or reconfigure it.
External bool `json:"external,omitempty"`
// Capabilities are the service capabilities declared by its manifest // Capabilities are the service capabilities declared by its manifest
// (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"`
@@ -71,6 +75,11 @@ type Container struct {
// a per-service settings list. // a per-service settings list.
Config []ConfigItem `json:"config,omitempty"` Config []ConfigItem `json:"config,omitempty"`
// Web is the Web App capability declared by the service manifest (contract
// rev 8.2 §16.4). Href is resolved server-side per request and populated
// only on API responses; the cached value never carries it.
Web *WebApp `json:"web,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"`