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