diff --git a/cmd/mesh-control/acts.go b/cmd/mesh-control/acts.go new file mode 100644 index 0000000..1a83690 --- /dev/null +++ b/cmd/mesh-control/acts.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "fmt" +) + +// The things the mesh can be asked to do, separated from how it was asked. +// +// **A surface is an adapter with no decisions in it** (novox/hq ADR 0035). The command line and +// the command API call the same functions here, so an assignment refused at one is refused at the +// other for the same reason and in the same words. The moment a surface can accept something +// another would reject, the mesh has two answers to one question and people learn which to trust. +// +// Each returns what happened as text a person can read and a caller can pass on. Neither surface +// composes its own explanation, because two explanations of one refusal drift. + +// assign puts a module on a node, and says at once whether the whole set still resolves. +// +// The assignment is kept even when it does not: it is what a person meant, and the refusal is +// about the set rather than about this one. That is a decision, so it lives here rather than in +// whichever surface asked. +func assign(ctx context.Context, open *stores, node, module string) (string, error) { + if err := open.inventory.Assign(ctx, node, module); err != nil { + return "", err + } + said := fmt.Sprintf("%s is assigned %s", node, module) + if _, _, err := planFor(ctx, open, node); err != nil { + // Kept, and still refused. Both halves are the answer. + return said, err + } + return said + fmt.Sprintf("\n run `push %s` to send it", node), nil +} + +// unassign takes a module off a node. What it leaves behind is the host's business: a directory +// holding anything the mesh did not put there is kept (novox/hq ADR 0030). +func unassign(ctx context.Context, open *stores, node, module string) (string, error) { + if err := open.inventory.Unassign(ctx, node, module); err != nil { + return "", err + } + return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so", + node, module, node), nil +} diff --git a/cmd/mesh-control/api.go b/cmd/mesh-control/api.go new file mode 100644 index 0000000..1d97030 --- /dev/null +++ b/cmd/mesh-control/api.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "strings" + "time" +) + +// The command API: what the mesh can be asked to do, over a network. +// +// **An adapter and nothing else** (novox/hq ADR 0035). Every route here calls the same function +// the command line calls, so a refusal is the same refusal in the same words. Nothing is decided +// in this file — the moment it validates something the command line does not, the mesh has two +// answers to one question. +// +// **It refuses everything unless it was told how to know who is asking.** The board is published +// on a public name, and this is what stands behind it: an unauthenticated command surface reachable +// from the internet is authority over the mesh handed to whoever finds it. So there is no +// permissive default and no flag that removes the check — a mesh that has not been told how to +// authenticate serves nothing, loudly. +// +// The intended authenticator is an OAuth2 provider (ADR 0035), which is an ordinary module. Until +// one is configured this refuses, which is the correct behaviour rather than a placeholder: a +// surface that worked without authentication would be one somebody left running. +func apiCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("api", flag.ContinueOnError) + listen := set.String("listen", "127.0.0.1:8081", "where to serve it") + issuer := set.String("issuer", "", + "the OAuth2 issuer whose tokens this accepts; without it, nothing is served") + if _, err := parseAround(set, args); err != nil { + return err + } + if strings.TrimSpace(*issuer) == "" { + // Refused at start rather than per request, so it is discovered by whoever ran it rather + // than by whoever finds it. + return errors.New( + "--issuer is not set, and this serves commands rather than pages: it will not run " + + "without being told whose tokens to believe. An OAuth2 provider is an ordinary " + + "module (novox/hq ADR 0035)") + } + + server := &http.Server{ + Addr: *listen, + ReadHeaderTimeout: 10 * time.Second, + Handler: commands(mustAuthenticate(*issuer)), + } + fmt.Printf("the command API is on http://%s\n", *listen) + fmt.Printf(" it accepts tokens from %s and refuses everything else\n", *issuer) + fmt.Printf(" every route calls what the command line calls\n") + + go func() { + <-ctx.Done() + closing, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(closing) + }() + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +// Authenticator says whether a request may act, and as whom. +// +// An interface so the check can be driven by a test without an identity provider, and so the one +// real implementation is the only thing that has to be right. +type Authenticator interface { + // Who returns the subject a request is acting as, or an error naming why it may not. + Who(r *http.Request) (string, error) +} + +// mustAuthenticate is the real one: a bearer token from the configured issuer. +// +// **Not yet verifying the signature**, and it says so rather than pretending. Verification needs +// the issuer's keys, which needs an identity provider to exist — so this refuses every request +// until that is built, which is the same answer as having no API at all and is honest about why. +func mustAuthenticate(issuer string) Authenticator { return notYet{issuer: issuer} } + +type notYet struct{ issuer string } + +func (n notYet) Who(*http.Request) (string, error) { + return "", fmt.Errorf( + "this mesh has no way to verify a token from %s yet: the identity provider is a module "+ + "and none is running. Use the command line, which authenticates through nothing "+ + "because it is already behind the machine's own login", n.issuer) +} + +// commands is the routing, separate so a test can drive it without a listener. +func commands(who Authenticator) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("POST /assign", acting(who, func(ctx context.Context, open *stores, in request) (string, error) { + return assign(ctx, open, in.Node, in.Module) + })) + mux.HandleFunc("POST /unassign", acting(who, func(ctx context.Context, open *stores, in request) (string, error) { + return unassign(ctx, open, in.Node, in.Module) + })) + + // Anything else is said plainly, because a command surface answering 404 to a verb somebody + // expected is indistinguishable from one that is down. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + refuse(w, http.StatusNotFound, fmt.Errorf( + "%s %s is not something this mesh can be asked; it accepts POST /assign and "+ + "POST /unassign", r.Method, r.URL.Path)) + }) + return mux +} + +type request struct { + Node string `json:"node"` + Module string `json:"module"` +} + +// acting is the shape every route shares: authenticate, read, act, answer. +func acting( + who Authenticator, + do func(context.Context, *stores, request) (string, error), +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, err := who.Who(r); err != nil { + refuse(w, http.StatusUnauthorized, err) + return + } + var in request + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + refuse(w, http.StatusBadRequest, fmt.Errorf("this is not a request this understands: %w", err)) + return + } + if in.Node == "" || in.Module == "" { + refuse(w, http.StatusBadRequest, errors.New(`both "node" and "module" are needed`)) + return + } + + open, err := openStores(r.Context()) + if err != nil { + refuse(w, http.StatusServiceUnavailable, err) + return + } + defer open.Close() + + said, err := do(r.Context(), open, in) + if err != nil { + // **The refusal the command line would have given, unchanged.** Carrying `said` with + // it matters: an assignment that was kept and still does not resolve is two facts, + // and dropping either makes the answer wrong. + answer(w, http.StatusConflict, map[string]any{"said": said, "refused": err.Error()}) + return + } + answer(w, http.StatusOK, map[string]any{"said": said}) + } +} + +func answer(w http.ResponseWriter, status int, body map[string]any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func refuse(w http.ResponseWriter, status int, err error) { + answer(w, status, map[string]any{"refused": err.Error()}) +} diff --git a/cmd/mesh-control/api_test.go b/cmd/mesh-control/api_test.go new file mode 100644 index 0000000..876c5a9 --- /dev/null +++ b/cmd/mesh-control/api_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type letIn struct{} + +func (letIn) Who(*http.Request) (string, error) { return "somebody", nil } + +func asking(t *testing.T, who Authenticator, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + recorded := httptest.NewRecorder() + commands(who).ServeHTTP(recorded, httptest.NewRequest(method, path, strings.NewReader(body))) + return recorded +} + +// **Nothing is served to somebody the mesh cannot identify**, and that is the default rather than +// a setting. The board this stands behind is published on a public name (novox/hq 11-a-board), so +// an unauthenticated command surface is authority over the mesh handed to whoever finds it. +func TestAnUnauthenticatedRequestIsRefused(t *testing.T) { + got := asking(t, mustAuthenticate("https://identity.example/realms/mesh"), + "POST", "/assign", `{"node":"anchor","module":"umami"}`) + if got.Code != http.StatusUnauthorized { + t.Fatalf("an unidentified caller got %d", got.Code) + } + // And it says what to do instead, because the answer is not "give up". + if !strings.Contains(got.Body.String(), "command line") { + t.Errorf("the refusal does not say what still works: %s", got.Body.String()) + } +} + +// The API refuses to start at all without being told whose tokens to believe. +// +// At start rather than per request, so it is found by whoever ran it rather than by whoever +// finds it. +func TestTheApiWillNotRunWithoutAnIssuer(t *testing.T) { + err := apiCommand(context.Background(), []string{}) + if err == nil { + t.Fatal("it served commands without being told who may give them") + } + if !strings.Contains(err.Error(), "issuer") { + t.Errorf("the refusal does not name what is missing: %v", err) + } +} + +// A request missing what it acts on is refused before anything is opened. +func TestARequestThatNamesNothingIsRefused(t *testing.T) { + // **The exact refusal, not merely "not accepted".** Asserting non-200 passes even when the + // request got as far as opening a store and failing there, which proves nothing about whether + // anything was checked — that is what the first version of this test did. + for _, body := range []string{`{}`, `{"node":"anchor"}`, `{"module":"umami"}`, `not json`} { + got := asking(t, letIn{}, "POST", "/assign", body) + if got.Code != http.StatusBadRequest { + t.Errorf("%s got %d, and a request naming nothing is a bad request", body, got.Code) + } + } +} + +// A verb nobody implemented is said plainly. A command surface answering 404 to something +// somebody expected is indistinguishable from one that is down. +func TestAnUnknownRouteSaysWhatIsAccepted(t *testing.T) { + got := asking(t, letIn{}, "POST", "/rotate", `{}`) + if got.Code != http.StatusNotFound { + t.Fatalf("got %d", got.Code) + } + var said map[string]any + _ = json.Unmarshal(got.Body.Bytes(), &said) + if !strings.Contains(said["refused"].(string), "/assign") { + t.Errorf("it does not say what it does accept: %v", said) + } +} diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 67aacec..43a58d6 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -62,6 +62,8 @@ func run() error { return builderCommand(ctx, args[1:]) case "board": return boardCommand(ctx, args[1:]) + case "api": + return apiCommand(ctx, args[1:]) case "licence": return licenceCommand(ctx, args[1:]) case "rotate": @@ -135,6 +137,7 @@ func usage() { module forget remove one, unless a node is running it status [--json] what is wrong, what is quiet, and what is out of date board [--listen ADDR] the same three questions, as a page that holds nothing + api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here assign put a module on a node unassign take it off settings set what a module's config should say, for the whole mesh diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index 53cdf19..cfa81cd 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -208,28 +208,21 @@ func assignCommand(ctx context.Context, verb string, args []string) error { return err } defer open.Close() - inv := open.inventory + // The act itself is in acts.go, so the command API refuses exactly what this refuses + // (novox/hq ADR 0035). What differs between the surfaces is how the answer is printed. + act := assign if verb == "unassign" { - if err := inv.Unassign(ctx, args[0], args[1]); err != nil { - return err - } - fmt.Printf("%s no longer runs %s — run `push %s` to make it so\n", args[0], args[1], args[0]) - return nil + act = unassign } - if err := inv.Assign(ctx, args[0], args[1]); err != nil { - return err + said, err := act(ctx, open, args[0], args[1]) + if said != "" { + fmt.Println(said) } - fmt.Printf("%s is assigned %s\n", args[0], args[1]) - - // Resolved immediately, because an assignment that cannot be applied should be said now - // rather than at the next push. The assignment is kept either way: it is what a person meant, - // and the refusal is about the set rather than about this one. - if _, _, err := planFor(ctx, open, args[0]); err != nil { + if err != nil { fmt.Println() return err } - fmt.Printf(" run `push %s` to send it\n", args[0]) return nil } diff --git a/examples/modules/gitea.json b/examples/modules/gitea.json new file mode 100644 index 0000000..9a329e9 --- /dev/null +++ b/examples/modules/gitea.json @@ -0,0 +1,30 @@ +{ + "module": "gitea", + "version": "1", + + "requires": ["postgres-database"], + "contributes": { + "postgres-database": {"name": "gitea"} + }, + "binds": {"postgres-database": "/var/lib/gitea/database.json"}, + "secrets": {"postgres-database": "/var/lib/gitea/database.env"}, + + "capabilities": ["container-runtime"], + + "listens": [ + {"port": 3000, "protocol": "tcp", "from": "mesh", "why": "the forge, over http"}, + {"port": 2222, "protocol": "tcp", "from": "mesh", + "why": "git over ssh. Not 22: the machine's own daemon holds that, and a module does not take it"} + ], + + "resources": [ + {"id": "state", "type": "directory", "path": "/var/lib/gitea", "mode": "0700"}, + + {"id": "server", "type": "container", "name": "gitea", + "image": "gitea@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "env": {"DB_TYPE": "postgres", "USER_UID": "1000", "USER_GID": "1000"}, + "env-file": ["/var/lib/gitea/database.env"], + "ports": ["3000:3000", "2222:22"], + "volumes": ["/services/gitea/gitea:/data"]} + ] +} diff --git a/examples/modules/keycloak.json b/examples/modules/keycloak.json new file mode 100644 index 0000000..28aa76b --- /dev/null +++ b/examples/modules/keycloak.json @@ -0,0 +1,55 @@ +{ + "module": "keycloak", + "version": "1", + + "requires": ["postgres-database"], + "contributes": { + "postgres-database": {"name": "keycloak"} + }, + "binds": {"postgres-database": "/var/lib/keycloak/database.json"}, + "secrets": {"postgres-database": "/var/lib/keycloak/database.env"}, + + "provides": [{"name": "oidc-client", "scope": "mesh"}], + "capabilities": ["container-runtime"], + + "listens": [ + {"port": 8080, "protocol": "tcp", "from": "mesh", + "why": "anything the mesh runs that authenticates a person"} + ], + + "serves": { + "oidc-client": {"port": 8080, "realm": "mesh", "scheme": "http"} + }, + + "receives": {"oidc-client": "/var/lib/keycloak/grants/mesh.json"}, + "grants": {"oidc-client": "/var/lib/keycloak/grants"}, + + "own-secrets": {"admin": "/var/lib/keycloak/admin.env"}, + + "resources": [ + {"id": "state", "type": "directory", "path": "/var/lib/keycloak", "mode": "0700"}, + {"id": "grants", "type": "directory", "path": "/var/lib/keycloak/grants", "mode": "0700"}, + + {"id": "net", "type": "network", "name": "keycloak"}, + + {"id": "server", "type": "container", "name": "keycloak", + "image": "keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "keycloak", + "args": ["start-dev"], + "env": {"KC_DB": "postgres", "KC_HTTP_ENABLED": "true", "KC_HEALTH_ENABLED": "true"}, + "env-file": ["/var/lib/keycloak/admin.env", "/var/lib/keycloak/database.env"], + "ports": ["8080:8080"]}, + + {"id": "provisioner", "type": "container", "name": "mesh-provision-keycloak", + "image": "mesh-provision-keycloak@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "keycloak", + "env": { + "GRANTS": "/var/lib/keycloak/grants", + "MESH_KEYCLOAK_URL": "http://keycloak:8080", + "MESH_KEYCLOAK_REALM": "mesh" + }, + "env-file": ["/var/lib/keycloak/admin.env"], + "volumes": ["/var/lib/keycloak/grants:/var/lib/keycloak/grants:ro"], + "restart-on": ["grants"]} + ] +} diff --git a/examples/modules/mailu.json b/examples/modules/mailu.json new file mode 100644 index 0000000..23267b7 --- /dev/null +++ b/examples/modules/mailu.json @@ -0,0 +1,90 @@ +{ + "module": "mailu", + "version": "1", + + "capabilities": ["container-runtime"], + + "listens": [ + {"port": 25, "protocol": "tcp", "from": "anywhere", "why": "mail from other mail servers"}, + {"port": 465, "protocol": "tcp", "from": "anywhere", "why": "submission over TLS"}, + {"port": 587, "protocol": "tcp", "from": "anywhere", "why": "submission"}, + {"port": 993, "protocol": "tcp", "from": "anywhere", "why": "IMAP over TLS"}, + {"port": 7080, "protocol": "tcp", "from": "mesh", "why": "the web interface, behind a proxy"} + ], + + "own-secrets": { + "secret-key": "/var/lib/mailu/secret.env", + "database": "/var/lib/mailu/database.env", + "admin": "/var/lib/mailu/admin.env" + }, + + "resources": [ + {"id": "state", "type": "directory", "path": "/var/lib/mailu", "mode": "0700"}, + + {"id": "net", "type": "network", "name": "mailu"}, + + {"id": "resolver", "type": "container", "name": "mailu-resolver", + "image": "mailu-unbound@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "env-file": ["/var/lib/mailu/secret.env"]}, + + {"id": "redis", "type": "container", "name": "mailu-redis", + "image": "redis@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "volumes": ["/services/mailu/data/redis:/data"]}, + + {"id": "admindb", "type": "container", "name": "mailu-admindb", + "image": "postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "env": {"PGDATA": "/var/lib/postgresql/data/pgdata"}, + "env-file": ["/var/lib/mailu/database.env"], + "volumes": ["/services/mailu/data/data/psql_admindb/pgdata:/var/lib/postgresql/data/pgdata"]}, + + {"id": "admin", "type": "container", "name": "mailu-admin", + "image": "mailu-admin@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "env-file": ["/var/lib/mailu/secret.env", "/var/lib/mailu/database.env", "/var/lib/mailu/admin.env"], + "volumes": [ + "/services/mailu/data/data:/data", + "/services/mailu/data/dkim:/dkim" + ]}, + + {"id": "imap", "type": "container", "name": "mailu-imap", + "image": "mailu-dovecot@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "env-file": ["/var/lib/mailu/secret.env"], + "volumes": [ + "/services/mailu/data/mail:/mail", + "/services/mailu/data/overrides/dovecot:/overrides:ro" + ]}, + + {"id": "smtp", "type": "container", "name": "mailu-smtp", + "image": "mailu-postfix@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "env-file": ["/var/lib/mailu/secret.env"], + "volumes": ["/services/mailu/data/mailqueue:/queue"]}, + + {"id": "antispam", "type": "container", "name": "mailu-antispam", + "image": "mailu-rspamd@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "env-file": ["/var/lib/mailu/secret.env"], + "volumes": ["/services/mailu/data/filter:/var/lib/rspamd"]}, + + {"id": "webmail", "type": "container", "name": "mailu-webmail", + "image": "mailu-roundcube@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "env-file": ["/var/lib/mailu/secret.env"], + "volumes": ["/services/mailu/data/webmail:/data"]}, + + {"id": "front", "type": "container", "name": "mailu-front", + "image": "mailu-nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "mailu", + "env-file": ["/var/lib/mailu/secret.env"], + "ports": ["25:25", "465:465", "587:587", "993:993", "7080:80"], + "volumes": [ + "/services/mailu/data/certs:/certs", + "/services/mailu/data/overrides/nginx:/overrides:ro" + ], + "restart-on": ["imap", "smtp", "admin"]} + ] +} diff --git a/examples/modules/minio.json b/examples/modules/minio.json new file mode 100644 index 0000000..b4ed12a --- /dev/null +++ b/examples/modules/minio.json @@ -0,0 +1,50 @@ +{ + "module": "minio", + "version": "1", + + "provides": [{"name": "s3-bucket", "scope": "mesh"}], + "capabilities": ["container-runtime"], + + "listens": [ + {"port": 9000, "protocol": "tcp", "from": "mesh", "why": "the S3 endpoint"} + ], + + "serves": { + "s3-bucket": {"port": 9000, "scheme": "http", "region": "us-east-1"} + }, + + "receives": {"s3-bucket": "/var/lib/minio/grants/mesh.json"}, + "grants": {"s3-bucket": "/var/lib/minio/grants"}, + + "own-secrets": {"root": "/var/lib/minio/root.env"}, + + "resources": [ + {"id": "state", "type": "directory", "path": "/var/lib/minio", "mode": "0700"}, + {"id": "grants", "type": "directory", "path": "/var/lib/minio/grants", "mode": "0700"}, + + {"id": "net", "type": "network", "name": "minio"}, + + {"id": "server", "type": "container", "name": "minio", + "image": "minio@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "minio", + "args": ["server", "/data", "--console-address", ":9001"], + "env-file": ["/var/lib/minio/root.env"], + "ports": ["9000:9000"], + "volumes": ["/services/minio/data/data1-1:/data"]}, + + {"id": "provisioner", "type": "container", "name": "mesh-provision-objectstore", + "image": "mesh-provision-objectstore@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "minio", + "env": { + "GRANTS": "/var/lib/minio/grants", + "MESH_OBJECTSTORE_URL": "http://minio:9000", + "MESH_OBJECTSTORE_ROOT_USER": "meshroot", + "MESH_OBJECTSTORE_ROOT_PASSWORD_FILE": "/run/secrets/root" + }, + "volumes": [ + "/var/lib/minio/grants:/var/lib/minio/grants:ro", + "/var/lib/minio/root.env:/run/secrets/root:ro" + ], + "restart-on": ["grants"]} + ] +} diff --git a/examples/modules/postgres.json b/examples/modules/postgres.json new file mode 100644 index 0000000..6760b67 --- /dev/null +++ b/examples/modules/postgres.json @@ -0,0 +1,50 @@ +{ + "module": "postgres", + "version": "1", + + "provides": [{"name": "postgres-database", "scope": "mesh"}], + "capabilities": ["container-runtime"], + + "listens": [ + {"port": 5432, "protocol": "tcp", "from": "mesh", + "why": "modules on any machine that were granted a database"} + ], + + "serves": { + "postgres-database": {"port": 5432} + }, + + "receives": {"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + "grants": {"postgres-database": "/var/lib/postgres/grants"}, + + "own-secrets": {"superuser": "/var/lib/postgres/superuser.env"}, + + "resources": [ + {"id": "state", "type": "directory", "path": "/var/lib/postgres", "mode": "0700"}, + {"id": "grants", "type": "directory", "path": "/var/lib/postgres/grants", "mode": "0700"}, + + {"id": "net", "type": "network", "name": "postgres"}, + + {"id": "server", "type": "container", "name": "postgres", + "image": "postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "postgres", + "env": {"POSTGRES_USER": "postgres", "POSTGRES_DB": "postgres"}, + "env-file": ["/var/lib/postgres/superuser.env"], + "ports": ["5432:5432"], + "volumes": ["/services/postgres/db-data:/var/lib/postgresql/data"]}, + + {"id": "provisioner", "type": "container", "name": "mesh-provision-postgres", + "image": "mesh-provision-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "postgres", + "env": { + "GRANTS": "/var/lib/postgres/grants", + "MESH_PROVISION_POSTGRES": "postgres://postgres@postgres:5432/postgres?sslmode=disable" + }, + "env-file": ["/var/lib/postgres/superuser.env"], + "volumes": [ + "/var/lib/postgres/grants:/var/lib/postgres/grants:ro", + "/var/lib/postgres/superuser.env:/var/lib/postgres/superuser.env:ro" + ], + "restart-on": ["grants"]} + ] +}