diff --git a/src/handlers/containers/install.go b/src/handlers/containers/install.go
index 6f9f566..b13eb43 100644
--- a/src/handlers/containers/install.go
+++ b/src/handlers/containers/install.go
@@ -99,7 +99,7 @@ func UninstallContainerHandler(cache *libcontainers.ContainerCache) http.Handler
if err := libcontainers.UninstallContainer(r.Context(), req); err != nil {
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
}
diff --git a/src/handlers/containers/lifecycle.go b/src/handlers/containers/lifecycle.go
index c8bbdaa..35ca072 100644
--- a/src/handlers/containers/lifecycle.go
+++ b/src/handlers/containers/lifecycle.go
@@ -17,6 +17,7 @@ package containers
import (
"encoding/json"
+ "errors"
"net/http"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
@@ -24,6 +25,17 @@ import (
"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.
func StartContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc {
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 {
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
}
@@ -87,7 +99,7 @@ func StopContainerHandler(cache *libcontainers.ContainerCache) http.HandlerFunc
if err := libcontainers.StopContainer(r.Context(), req); err != nil {
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
}
diff --git a/src/handlers/containers/lifecycle_test.go b/src/handlers/containers/lifecycle_test.go
new file mode 100644
index 0000000..8b4e552
--- /dev/null
+++ b/src/handlers/containers/lifecycle_test.go
@@ -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 .
+
+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)
+ }
+}
diff --git a/src/lib/containers/external.go b/src/lib/containers/external.go
new file mode 100644
index 0000000..9de87f8
--- /dev/null
+++ b/src/lib/containers/external.go
@@ -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 .
+
+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
+}
diff --git a/src/lib/containers/external_test.go b/src/lib/containers/external_test.go
new file mode 100644
index 0000000..cc25291
--- /dev/null
+++ b/src/lib/containers/external_test.go
@@ -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 .
+
+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)
+ }
+}
diff --git a/src/lib/containers/install.go b/src/lib/containers/install.go
index 2f15051..193e5a3 100644
--- a/src/lib/containers/install.go
+++ b/src/lib/containers/install.go
@@ -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
- // attestation over mTLS and store the fingerprint in the install record so
- // the container gets AUTH_PINNED_FINGERPRINT at start. Best-effort — when
- // the auth service is not reachable yet the pin stays empty and is retried
- // on start.
+ // attestation over plaintext HTTP and store the fingerprint in the install
+ // record so the container gets AUTH_PINNED_FINGERPRINT at start. This is an
+ // app-level fingerprint comparison, not a transport identity. Best-effort —
+ // when the auth service is not reachable yet the pin stays empty and is
+ // retried on start.
if att, err := fetchAuthAttestation(ctx); err == nil {
metadata.AuthFingerprint = att.Fingerprint
metadata.AuthKid = att.Kid
@@ -176,7 +177,8 @@ func getDataRoot() (string, error) {
}
// 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) {
return getDataRoot()
}
@@ -500,8 +502,9 @@ func writeMetadata(path string, metadata types.InstalledMetadata) error {
}
// fetchAuthAttestation fetches the auth service's public key statement over
-// mTLS (Maestro's own identity certificate). It backs the M2 §5 TOFU pin: the
-// returned fingerprint is what consumer containers are pinned to.
+// plaintext HTTP (rev 9 removed the mTLS identity layer). It backs the M2 §5
+// app-level pin: the returned fingerprint is what consumer containers are
+// pinned to.
func fetchAuthAttestation(ctx context.Context) (*authmgmt.Attestation, error) {
client, err := authmgmt.New()
if err != nil {
diff --git a/src/lib/containers/start.go b/src/lib/containers/start.go
index 2bc4985..e880dfd 100644
--- a/src/lib/containers/start.go
+++ b/src/lib/containers/start.go
@@ -18,24 +18,20 @@ package containers
import (
"context"
"fmt"
+ "net/url"
"os"
"path/filepath"
"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/logger"
- "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
composetypes "github.com/compose-spec/compose-go/v2/types"
"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
// service's docker-compose.yaml.
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()
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)
if err != nil {
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
- // CA and mount it plus the CA bundle into the container. The compose file
- // downloaded from git never declares these mounts — Maestro injects them
- // (security theme M1 §2). Failing to provision identity is fatal: a peer
- // without a cert cannot authenticate on the mTLS edges. Database-capable
- // 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 {
+ // Inject the service's runtime env (rev 9): identity env names, the
+ // dedicated config-signing public key when the service declares a config
+ // block, and the plaintext shared-database DSN for database-capable
+ // services. There is no PKI identity, no cert mount and no mTLS.
+ if err := injectServiceRuntimeEnv(ctx, project, serviceName); err != nil {
return err
}
// 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
}
-// injectServiceIdentity issues (or refreshes) the service's identity
-// certificate from the instance CA and injects the CA bundle, the identity
-// 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.
+// injectServiceRuntimeEnv injects the runtime env every non-oneoff compose
+// service needs after rev 9:
//
-// It also injects AUTH_PINNED_FINGERPRINT (M2 §5 TOFU): the auth signing key's
-// fingerprint recorded at install. A service that already has the pin skips the
-// fetch; one installed before this feature (or whose install could not reach
-// the auth service) backfills it here, best-effort.
-//
-// For services declaring the database capability (M2 §4), when the shared
-// database runs mTLS-armed, every app service additionally receives a
-// per-service DATABASE_URL (role = the service's cert CN, verify-full against
-// the instance CA), overriding the shared-superuser URL their compose file
-// ships. One-off helpers (migrate/seed) get the identity mount plus libpq SSL
-// env so they can reach the armed database under the same role.
-func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, serviceName string) error {
+// - SERVICE_NAME / DCNTRLIZED_SERVICE_NAME: the compose service name.
+// - DCNTRLIZED_CONFIG_PUBKEY: the base64 DER of Maestro's DEDICATED
+// config-signing public key, injected only into services that declare a
+// `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.
+// - DATABASE_URL: a plaintext per-service DSN for database-capable services
+// (rev 9 removed the DSN mTLS; the shared database now uses
+// scram-sha-256 with a password).
+// - AUTH_PINNED_FINGERPRINT: the app-level TOFU pin of the auth service's
+// signing key (unchanged; it is not an mTLS control).
+func injectServiceRuntimeEnv(ctx context.Context, project *composetypes.Project, serviceName string) error {
dataRoot, err := getDataRoot()
if err != nil {
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{
- "SERVICE_NAME": serviceName,
- "AUTH_CA_BUNDLE": identityMountDir + "/ca.crt",
- "AUTH_CLIENT_CERT": identityMountDir + "/tls.crt",
- "AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
+ "SERVICE_NAME": serviceName,
+ "DCNTRLIZED_SERVICE_NAME": serviceName,
}
- // Per-service database role DSN (M2 §4). Only on an armed database — a
- // plain (compose-managed dev) Postgres cannot satisfy verify-full, and its
- // stock trust rule needs no per-service roles anyway. Best-effort: without
- // the capability in the install record (pre-manifest installs) nothing is
- // injected and the compose defaults apply.
- dbEnv := map[string]string{}
- if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) {
- if dbctl.New(dc).Armed(ctx) {
- 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 config-signing public key is only useful to services that declare a
+ // config block. declaredConfigFields errors for services without install
+ // metadata (e.g. externally-managed dev containers); that is not fatal.
+ if declared, err := declaredConfigFields(serviceName); err == nil && len(declared) > 0 {
+ signer, err := configsign.LoadOrCreate(dataRoot)
+ 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) {
+ env["DATABASE_URL"] = plaintextDatabaseURL()
}
// 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 {
if isOneoff(s) {
// Migrate/seed helpers talk libpq/psycopg straight to the shared
- // database; on an armed database they need the identity mount plus
- // the same per-service role (their compose file's PGUSER is the
- // plain-dev default).
- if len(dbEnv) > 0 {
- s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
- s.Environment = mergeEnv(s.Environment, dbEnv)
+ // database; they need the same plaintext DSN and role.
+ if dsn, ok := env["DATABASE_URL"]; ok {
+ s.Environment = mergeEnv(s.Environment, map[string]string{
+ "DATABASE_URL": dsn,
+ "PGUSER": databaseUser,
+ })
project.Services[name] = s
}
continue
}
- s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
s.Environment = mergeEnv(s.Environment, env)
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
}
-// mtlsDatabaseURL builds the per-service connection URL for the shared
-// database: role = the service's cert CN, no password (clientcert=verify-full
-// makes the certificate the credential), TLS verified against the instance CA.
-func mtlsDatabaseURL(serviceName string) string {
- return fmt.Sprintf(
- "postgres://%s@%s:5432/%s?sslmode=verify-full&sslrootcert=%s/ca.crt&sslcert=%s/tls.crt&sslkey=%s/tls.key",
- serviceName, DatabaseServiceName, dbctl.DBName,
- identityMountDir, identityMountDir, identityMountDir,
- )
+// databaseName / databaseUser / defaultDatabasePassword are the shared
+// database's plaintext credentials after rev 9. The dcntrlized-db compose
+// declares POSTGRES_HOST_AUTH_METHOD=scram-sha-256 and
+// POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dcntrlized}; Maestro injects the same
+// password (overridable with DCNTRLIZED_DB_PASSWORD) so every service connects
+// as postgres instead of the old cert-CN role.
+const (
+ 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
@@ -337,22 +328,6 @@ func hasDatabaseCapability(serviceName string) bool {
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
// 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
@@ -388,24 +363,6 @@ func pinAuthFingerprint(ctx context.Context, serviceName string) string {
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
// migrate container) via the org.dcntrlized.oneoff label the compose file
// declares.
diff --git a/src/lib/containers/start_test.go b/src/lib/containers/start_test.go
index 50d1b32..ac4dda2 100644
--- a/src/lib/containers/start_test.go
+++ b/src/lib/containers/start_test.go
@@ -16,32 +16,112 @@
package containers
import (
+ "context"
"encoding/json"
"os"
"path/filepath"
+ "strings"
"testing"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
composetypes "github.com/compose-spec/compose-go/v2/types"
)
-func TestHostBindSource(t *testing.T) {
- t.Setenv("DCNTRLIZED_HOST_DATA_ROOT", "")
- got := hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/services/auth/tls.crt")
- if got != "/data/.dcntrlized/pki/services/auth/tls.crt" {
- t.Fatalf("without host root the path must pass through, got %q", got)
+// writeTestMetadataFull is writeTestMetadata plus declared capabilities, used
+// by the rev-9 runtime-env test (database DSN injection).
+func writeTestMetadataFull(t *testing.T, dataRoot, serviceName string, config []types.ConfigItem, capabilities []string) {
+ t.Helper()
+ 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)
+ }
+}
+
+// TestInjectServiceRuntimeEnv covers the rev-9 runtime env: plaintext service
+// names on every service, the config-signing public key only for services that
+// declare config fields, a plaintext password DSN for database-capable
+// 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)
}
- t.Setenv("DCNTRLIZED_HOST_DATA_ROOT", "/home/user/.dcntrlized")
- got = hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/services/auth/tls.crt")
- if want := "/home/user/.dcntrlized/pki/services/auth/tls.crt"; got != want {
- t.Fatalf("host root must replace the container-internal prefix, got %q want %q", got, want)
+ app := project.Services["app"]
+ if got := envValue(app.Environment, "DCNTRLIZED_SERVICE_NAME"); got != "keepnote" {
+ 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)
+ }
}
- got = hostBindSource("/data/.dcntrlized", "/data/.dcntrlized/pki/ca.crt")
- if want := "/home/user/.dcntrlized/pki/ca.crt"; got != want {
- t.Fatalf("ca path must be remapped too, got %q want %q", got, want)
+ // 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)
}
}
diff --git a/src/lib/containers/stop.go b/src/lib/containers/stop.go
index 44dac44..cd5af28 100644
--- a/src/lib/containers/stop.go
+++ b/src/lib/containers/stop.go
@@ -35,6 +35,14 @@ func StopContainer(ctx context.Context, item types.RegistryContainer) error {
defer dc.Close()
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)
if err != nil {
return err
diff --git a/src/lib/containers/uninstall.go b/src/lib/containers/uninstall.go
index f17f257..7053711 100644
--- a/src/lib/containers/uninstall.go
+++ b/src/lib/containers/uninstall.go
@@ -21,11 +21,8 @@ import (
"os"
"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/logger"
- "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/docker/api/types/container"
@@ -43,26 +40,22 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
defer dc.Close()
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)
if err != nil {
return fmt.Errorf("failed to determine data directory: %w", err)
}
- // Revoke the service's identity certificate and publish the CRL entry
- // (security theme M1 §2 / M3 §2). A revoked service stops authenticating
- // on the mTLS edges within one CRL freshness window. The authorization in
- // 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)
+ // Rev 9: there is no identity certificate, CRL or per-service database
+ // grant to revoke — the PKI layer and the DB role model are removed. The
+ // container, data directory and image are all that is left to clean up.
if _, err := os.Stat(targetDir); err == nil {
// 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
}
-// 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
// org.dcntrlized.service label for the given service.
func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) {
diff --git a/src/lib/containers/watcher.go b/src/lib/containers/watcher.go
index 18fac0c..f135a20 100644
--- a/src/lib/containers/watcher.go
+++ b/src/lib/containers/watcher.go
@@ -113,6 +113,7 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
hasAuth := existing.HasAuth
publicKey := existing.PublicKey
config := existing.Config
+ web := existing.Web
if manifest != nil {
capabilities = manifest.Capabilities
hasAuth = manifest.HasAuth
@@ -120,8 +121,19 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
if manifest.Config != nil {
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)
}
+ // 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{
ID: containerID,
ServiceName: serviceLabel,
@@ -133,10 +145,12 @@ func (cache *ContainerCache) WatchContainers(ctx context.Context, dc *docker.Doc
Version: ver,
Role: role,
Hidden: existing.Hidden || event.Actor.Attributes["org.dcntrlized.hidden"] == "true",
+ External: external,
Capabilities: capabilities,
HasAuth: hasAuth,
PublicKey: publicKey,
Config: config,
+ Web: web,
Title: existing.Title,
Vendor: existing.Vendor,
Author: existing.Author,
@@ -234,6 +248,11 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
key := normalizeName(serviceLabel)
cache.Lock()
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 {
entry = types.Container{
ServiceName: serviceLabel,
@@ -241,7 +260,10 @@ func (cache *ContainerCache) ReconcileContainers(ctx context.Context, dc *docker
Image: c.Image,
Version: imageTag(c.Image),
Status: "installed",
+ External: external,
}
+ } else {
+ entry.External = entry.External && external
}
entry.Status = normalizeDockerState(c.State)
entry.ID = c.ID
diff --git a/src/lib/types/docker.go b/src/lib/types/docker.go
index 3fba7d1..59ed38c 100644
--- a/src/lib/types/docker.go
+++ b/src/lib/types/docker.go
@@ -56,6 +56,10 @@ type Container struct {
// Hidden marks infrastructure services that Maestro manages but excludes
// from the service grid (e.g. the shared database).
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
// (e.g. ["database"]), surfaced so the UI can render capability chips.
Capabilities []string `json:"capabilities,omitempty"`
@@ -71,6 +75,11 @@ type Container struct {
// a per-service settings list.
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"`
Vendor string `json:"vendor,omitempty"`
Author string `json:"author,omitempty"`