From 13f6d1452198410fce40033e1397c5abdb7af00e Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 02:40:05 +0100 Subject: [PATCH] [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. --- src/handlers/containers/install.go | 2 +- src/handlers/containers/lifecycle.go | 16 +- src/handlers/containers/lifecycle_test.go | 53 +++++ src/lib/containers/external.go | 215 ++++++++++++++++++++ src/lib/containers/external_test.go | 228 ++++++++++++++++++++++ src/lib/containers/install.go | 17 +- src/lib/containers/start.go | 193 +++++++----------- src/lib/containers/start_test.go | 104 ++++++++-- src/lib/containers/stop.go | 8 + src/lib/containers/uninstall.go | 54 ++--- src/lib/containers/watcher.go | 22 +++ src/lib/types/docker.go | 9 + 12 files changed, 738 insertions(+), 183 deletions(-) create mode 100644 src/handlers/containers/lifecycle_test.go create mode 100644 src/lib/containers/external.go create mode 100644 src/lib/containers/external_test.go 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"`