carlos / platform Public

Merge branch 'sidecar-engine-capability' into main

caps: doc-engine capability for document-conversion sidecars (Gotenberg, OCR)
claude agent, operated by paul@keymail.dev 91cf6888e2d6836d1e5138f8996c72291d0b2e26 parent 1b48ed5parent d913cbe
12 files changed, +619 −29
  • README.md +44 −8
  • cli.go +8 −4
  • cli_test.go +8 −1
  • internal/caps/caps.go +23 −2
  • internal/caps/caps_test.go +28 −0
  • internal/caps/grants.go +41 −7
  • internal/caps/grants_test.go +82 −1
  • internal/caps/render.go +145 −6
  • internal/caps/render_test.go +209 −0
  • internal/caps/testdata/gotenberg.service.conf +11 −0
  • internal/host/caps_pass.go +8 −0
  • internal/host/caps_pass_test.go +12 −0

CI cancelled — superseded by a newer push — run details

diff --git a/README.md b/README.md
index fb49dafa2..44f103eec 100644
--- a/README.md
+++ b/README.md
@@ -522,10 +522,10 @@ renders them yet — they are the history a later trend view will read.
A handful of apps need more from the platform than the `carlos-app@<host>`
template gives every instance for free — a subcommand other than `serve`, a
-privileged port, the box's cloud instance role. Rather than hand-provision a
-bespoke unit per app, a release **declares** what it needs and the platform
-decides how — and whether — to provide it, from a closed vocabulary of seven
-names. See
+privileged port, the box's cloud instance role, or the resource headroom a
+document-conversion engine needs. Rather than hand-provision a bespoke unit
+per app, a release **declares** what it needs and the platform decides how —
+and whether — to provide it, from a closed vocabulary of eight names. See
the `2026-08-03-app-capabilities-design.md` design spec (private `platform-infrastructure` repo)
for the full design; this section is the operator-facing summary.
@@ -544,12 +544,26 @@ as "none", today's behaviour byte for byte):
"host_role": true
}
+A document-conversion engine (Gotenberg, an OCR service) declares a different
+shape — `host_role` and `doc_engine` are mutually exclusive and a manifest
+carrying both is refused at ship, because `host_role` re-opens the cloud
+metadata address and `doc_engine` exists to fence the engine's egress away
+from private networks:
+
+ "capabilities": {
+ "v": 1,
+ "daemon": true,
+ "children": true,
+ "doc_engine": true
+ }
+
`command`, `daemon`, `children`, `runtime_dir` and `data` are self-service —
they shape only the app's own sandbox and materialize from the manifest
-alone. `ports` and `host_role` are operator-consented: declaring one asks for
-it, but nothing materializes until an operator has granted it **on that
-specific box**, because the resource (a privileged port, the box's IAM role)
-is box-local, not fleet-wide.
+alone. `ports`, `host_role` and `doc_engine` are operator-consented: declaring
+one asks for it, but nothing materializes until an operator has granted it
+**on that specific box**, because the resource (a privileged port, the box's
+IAM role, the private-network egress fence that pays for a conversion
+engine's JIT and memory headroom) is box-local, not fleet-wide.
**Declaring a capability** rides `carlos ship --capabilities <file.json>`,
where the file holds exactly the `capabilities` object above (not a whole
@@ -564,6 +578,7 @@ because a flag vocabulary would drift from `internal/caps`'s own.
carlos route --host keymail.dev --backing unit # first: grants need it
carlos route --host keymail.dev --grant port:25
carlos route --host keymail.dev --grant host-role
+ carlos route --host gotenberg.docs.example.com --grant doc-engine
carlos route --host keymail.dev --revoke port:25
carlos route --host keymail.dev --hibernate=false
@@ -590,6 +605,27 @@ line is there too — a token this box has granted that the live release no
longer declares materializes nothing, and is named with the revoke command
rather than left sitting in the registry looking like live authority.
+**`doc-engine` carries two box preconditions** the platform cannot check for
+you, which is what makes granting it an operator decision rather than a
+checkbox. The grant buys the app JIT memory and a raised resource tier, and
+in the same drop-in fences its egress away from every private network range —
+RFC1918, link-local, CGNAT, IPv6 ULA and the NAT64 prefix — because a
+conversion engine fetches whatever URLs the documents handed to it name, and
+an unfenced one is an SSRF proxy into the VPC. Two consequences:
+
+- **The box must hand the tenant a loopback DNS resolver.** The fence
+ deliberately leaves 127.0.0.0/8 open — the engine drives its own Chromium
+ and LibreOffice over loopback — but it closes 10.0.0.0/8, and on AWS the
+ VPC resolver lives inside that range (VPC base + 2). A box running
+ systemd-resolved on 127.0.0.53 resolves through loopback and the fence is
+ transparent; a box pointing tenants straight at the VPC resolver gives them
+ an engine that cannot resolve a hostname.
+- **The box should be dedicated.** Loopback staying open means an SSRF'd
+ engine can still reach anything listening on 127.0.0.1 on its own box — a
+ co-tenant console, a co-tenant's loopback-bound upstream. The fence closes
+ the network half of that risk; not co-locating tenants closes the rest.
+ Run `doc-engine` routes on a box of their own.
+
**Deploy order is pinned**, same rule and reason as scoped signing keys: the
platform binaries that understand a capability name have to be on the box
*before* a release declaring it, never after. An older binary meeting a
diff --git a/cli.go b/cli.go
index ee9f8e20b..f6e7dceb3 100644
--- a/cli.go
+++ b/cli.go
@@ -451,6 +451,7 @@ func runInstances(args []string) {
//
// carlos route --host keymail.dev --grant port:25
// carlos route --host platform.carlosframework.com --grant host-role
+// carlos route --host gotenberg.docs.example.com --grant doc-engine
// carlos route --host keymail.dev --revoke port:25
// carlos route --host keymail.dev --hibernate=false
// carlos route --host console.carloku.com --unit console.service
@@ -479,7 +480,7 @@ func runRoute(args []string) {
channel := channelFlag(fs, "release channel to subscribe the route(s) to")
environment := environmentFlag(fs,
`named config environment to bind the route(s) to (must already exist in the bucket); "-" clears to default`)
- grant := fs.String("grant", "", "capability token to grant this route ON THIS BOX: port:<n> (privileged, never 22/80/443) | host-role")
+ grant := fs.String("grant", "", "capability token to grant this route ON THIS BOX: port:<n> (privileged, never 22/80/443) | host-role | doc-engine")
revoke := fs.String("revoke", "", "capability token to revoke from this route on this box")
hibernate := fs.Bool("hibernate", false, "hibernate this route when idle; --hibernate=false stops it hibernating")
backing := fs.String("backing", "", `who owns this route's process: "unit" (a carlos-app@<host> systemd unit) | "" (an exec child of the agent)`)
@@ -530,7 +531,7 @@ func runRoute(args []string) {
const usage = "usage: carlos route --host <host> [--channel <channel>] [--environment <name>] [--tunnel <name>] [--registry <db>] [--allow-version-change] [--dry-run]\n" +
" or: carlos route --app <app> [--channel <channel>] [--environment <name>] [--account <sqid|name|all>] [--account-id <sqid>] [--registry <db>] [--allow-version-change] [--dry-run]\n" +
" (--account-id names ONE account by sqid and supersedes --account, \"all\" included; --tunnel is --host only)\n" +
- " or: carlos route --host <host> (--grant | --revoke) <port:n|host-role> [--registry <db>] [--dry-run]\n" +
+ " or: carlos route --host <host> (--grant | --revoke) <port:n|host-role|doc-engine> [--registry <db>] [--dry-run]\n" +
" or: carlos route --host <host> --hibernate[=false] [--registry <db>] [--dry-run]\n" +
" or: carlos route --host <host> --backing <unit|\"\"> [--registry <db>] [--dry-run]\n" +
" or: carlos route --host <host> --unit <name.service|-> [--registry <db>] [--dry-run]\n" +
@@ -1331,13 +1332,16 @@ func grantRail(rt registry.Route, tok string) error {
//
// host-role is deliberately not conflict-checked: the box's instance role is
// not consumed by being used, and two apps holding it is two independent
-// operator decisions, not a collision.
+// operator decisions, not a collision. doc-engine is not conflict-checked for
+// the same reason and a sharper one: the capability is per-route unit text,
+// not a box resource, and a dedicated sidecar box running several engine
+// routes is the expected shape.
//
// A row whose caps column does not parse fails the check CLOSED, naming that
// row: "I could not find out whether this port is already claimed" is never a
// licence to claim it.
func portGrantConflict(routes []registry.Route, host, tok string) error {
- if tok == caps.HostRoleToken {
+ if tok == caps.HostRoleToken || tok == caps.DocEngineToken {
return nil
}
for _, other := range routes {
diff --git a/cli_test.go b/cli_test.go
index 7271f1c87..a98f75b85 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -211,7 +211,7 @@ func TestGrantRailRefusals(t *testing.T) {
}
})
}
- for _, tok := range []string{"port:25", "host-role"} {
+ for _, tok := range []string{"port:25", "host-role", "doc-engine"} {
if err := grantRail(unit, tok); err != nil {
t.Fatalf("grantRail(unit-backed instance, %q) = %v, want accepted", tok, err)
}
@@ -257,6 +257,13 @@ func TestPortGrantConflict(t *testing.T) {
if err := portGrantConflict(routes, "other.example.com", "host-role"); err != nil {
t.Fatalf("host-role = %v, want no conflict even though another route holds it", err)
}
+ // doc-engine is never conflict-checked either, and for a sharper reason:
+ // it is per-route unit text, not a box resource, and a dedicated sidecar
+ // box running several engine routes is the expected shape.
+ engineRoutes := []registry.Route{{Host: "gotenberg.docs.example.com", Caps: "doc-engine"}}
+ if err := portGrantConflict(engineRoutes, "ocr.docs.example.com", "doc-engine"); err != nil {
+ t.Fatalf("doc-engine = %v, want no conflict even though another route holds it", err)
+ }
// A row whose caps column does not parse fails CLOSED, naming that row:
// "I could not find out whether this port is claimed" is not a licence.
bad := []registry.Route{{Host: "handedited.example.com", Caps: "port:25,gpu"}}
diff --git a/internal/caps/caps.go b/internal/caps/caps.go
index ccc3a18f2..3ddc7c7ce 100644
--- a/internal/caps/caps.go
+++ b/internal/caps/caps.go
@@ -98,7 +98,7 @@ var reservedRuntimeDir = map[string]bool{
// (spec §7): the edge's own ports and the box's SSH.
var reservedPort = map[int]bool{22: true, 80: true, 443: true}
-// knownCapability is the closed vocabulary's seven names. A manifest key
+// knownCapability is the closed vocabulary's eight names. A manifest key
// under "capabilities" that isn't one of these (or "v") names a capability
// this binary does not know, and UnmarshalJSON refuses the whole block.
var knownCapability = map[string]bool{
@@ -109,6 +109,7 @@ var knownCapability = map[string]bool{
"ports": true,
"data": true,
"host_role": true,
+ "doc_engine": true,
}
// Set is a manifest's whole "capabilities" object. It is decoded only
@@ -124,6 +125,7 @@ type Set struct {
Ports []Port `json:"ports,omitempty"`
Data *Data `json:"data,omitempty"`
HostRole bool `json:"host_role,omitempty"`
+ DocEngine bool `json:"doc_engine,omitempty"`
notes []string // unknown-field-inside-a-known-capability lines; see Notes
}
@@ -165,7 +167,7 @@ type Data struct {
// - anything absent: the zero value, which means "not declared".
//
// It decodes into a map[string]json.RawMessage first so it can check every
-// key against the seven known names plus "v" before decoding anything
+// key against the eight known names plus "v" before decoding anything
// typed, then re-decodes each object-valued capability with a second
// map[string]json.RawMessage pass to collect notes AND to decode each known
// field one at a time by its exact key. That second point matters: a
@@ -235,6 +237,9 @@ func (s *Set) UnmarshalJSON(b []byte) error {
if err := decodeIfPresent(raw, "host_role", &out.HostRole); err != nil {
return fmt.Errorf("capabilities: host_role: %w", err)
}
+ if err := decodeIfPresent(raw, "doc_engine", &out.DocEngine); err != nil {
+ return fmt.Errorf("capabilities: doc_engine: %w", err)
+ }
if portsRaw, ok := raw["ports"]; ok {
var items []json.RawMessage
@@ -374,6 +379,22 @@ func ValidCapabilities(app string, s *Set) error {
return fmt.Errorf("capabilities: host_role: user %q is longer than the 32-character account-name limit — refused at ship, where a human can rename", u)
}
}
+ if s.HostRole && s.DocEngine {
+ // Refused as a declaration, before any grant is consulted, because the
+ // two contradict in the rendered bytes: host_role emits
+ // IPAddressAllow=169.254.169.254 to re-open the IMDS the template
+ // fenced, and in systemd's BPF firewall an allow-list match WINS over
+ // a deny-list match (internal/caps/render.go). doc_engine's whole
+ // purpose is to fence the engine's egress against private networks it
+ // can be SSRF'd into — so declaring both asks for a fence with a
+ // hole punched in it by the other half of the same drop-in.
+ //
+ // A document-conversion orchestrator has no business holding the
+ // box's cloud instance role anyway: it converts bytes a caller sent
+ // and needs no cloud credentials to do it. Refusing the pair is the
+ // honest answer to a manifest that asks for both.
+ return fmt.Errorf("capabilities: host_role and doc_engine contradict — doc_engine fences the unit's egress away from private networks, and host_role re-opens the cloud metadata address, which in systemd's BPF firewall an IPAddressAllow wins over any IPAddressDeny. A document-conversion engine needs no cloud instance role; drop host_role")
+ }
return nil
}
diff --git a/internal/caps/caps_test.go b/internal/caps/caps_test.go
index df9a86ed7..0e3245d59 100644
--- a/internal/caps/caps_test.go
+++ b/internal/caps/caps_test.go
@@ -169,6 +169,34 @@ func TestKeymailSpecBlockRoundTripsAndValidates(t *testing.T) {
}
}
+// TestDocEngineDecodesAsAKnownCapability pins the wire shape of the new
+// capability: "doc_engine" is one of the closed vocabulary's names, so it
+// decodes to Set.DocEngine and is NOT refused as unknown and NOT recorded as an
+// ignored field. An absent doc_engine leaves it false (not declared).
+func TestDocEngineDecodesAsAKnownCapability(t *testing.T) {
+ var s Set
+ if err := json.Unmarshal([]byte(`{"v":1,"doc_engine":true}`), &s); err != nil {
+ t.Fatalf("doc_engine refused as unknown: %v", err)
+ }
+ if !s.DocEngine {
+ t.Error("DocEngine = false, want true")
+ }
+ if n := s.Notes(); len(n) != 0 {
+ t.Errorf("doc_engine recorded as an ignored field: %v", n)
+ }
+ if err := ValidCapabilities("gotenberg", &s); err != nil {
+ t.Fatalf("ValidCapabilities(doc_engine) = %v, want nil", err)
+ }
+
+ var absent Set
+ if err := json.Unmarshal([]byte(`{"v":1}`), &absent); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if absent.DocEngine {
+ t.Error("an absent doc_engine decoded to true, want false (not declared)")
+ }
+}
+
// TestNilSetNotesDoesNotPanic pins review finding I1: every production
// manifest today decodes to a nil Set, and the first ranged Notes() call in
// Task 6's converge pass must not crash RunAdopt fleet-wide.
diff --git a/internal/caps/grants.go b/internal/caps/grants.go
index 82a00d136..7add04c84 100644
--- a/internal/caps/grants.go
+++ b/internal/caps/grants.go
@@ -10,6 +10,9 @@ package caps
//
// host-role the app's processes may use the box's instance role
// port:<n> the app may bind privileged port n (1..1023, never 22/80/443)
+// doc-engine the app is a document-conversion orchestrator (Chromium,
+// LibreOffice, Tesseract-class): JIT and resource headroom,
+// in exchange for a private-network egress fence
//
// Per route, per box, deliberately (decision 4): `:25` can be held by exactly
// one process on one machine, and the IMDS gate is that box's firewall. Two
@@ -28,6 +31,20 @@ import (
// in a manifest (spec §7).
const HostRoleToken = "host-role"
+// DocEngineToken is the grant that says this route runs a document-conversion
+// orchestrator — Gotenberg, an OCR service, anything driving Chromium or
+// LibreOffice over untrusted documents. It is a TRADE, not a gift: the app
+// gets the resource headroom those engines cannot run without, and in the
+// same drop-in the platform fences its egress away from every private network
+// the engine could otherwise be SSRF'd into. RenderService emits both halves
+// together or neither, so an operator cannot grant the headroom and forget
+// the fence.
+//
+// Not spelled "engine": `carlos store scan` already uses that word for the
+// antivirus engine throughout its operator-facing prose, and two meanings for
+// one token would make every caps column ambiguous to read.
+const DocEngineToken = "doc-engine"
+
// portTokenPrefix is the only other token shape. Kept as a constant so the
// parser and the formatter can never drift apart by a character.
const portTokenPrefix = "port:"
@@ -39,10 +56,12 @@ const portTokenPrefix = "port:"
func PortToken(p int) string { return portTokenPrefix + strconv.Itoa(p) }
// Grants is a parsed `caps` column: which privileged ports this route may
-// bind on this box, and whether it may use the box's instance role.
+// bind on this box, whether it may use the box's instance role, and whether
+// it may run as a document-conversion engine.
type Grants struct {
- Ports map[int]bool
- HostRole bool
+ Ports map[int]bool
+ HostRole bool
+ DocEngine bool
}
// Has reports whether g holds tok. The token is not validated — an
@@ -52,6 +71,9 @@ func (g Grants) Has(tok string) bool {
if tok == HostRoleToken {
return g.HostRole
}
+ if tok == DocEngineToken {
+ return g.DocEngine
+ }
if p, ok := parsePortToken(tok); ok {
return g.Ports[p]
}
@@ -68,6 +90,9 @@ func (g Grants) Has(tok string) bool {
// this value needs is that one set has exactly one spelling.
func (g Grants) String() string {
var toks []string
+ if g.DocEngine {
+ toks = append(toks, DocEngineToken)
+ }
if g.HostRole {
toks = append(toks, HostRoleToken)
}
@@ -105,6 +130,10 @@ func ParseGrants(col string) (Grants, error) {
g.HostRole = true
continue
}
+ if tok == DocEngineToken {
+ g.DocEngine = true
+ continue
+ }
p, _ := parsePortToken(tok) // ValidGrantToken proved the shape
if g.Ports == nil {
g.Ports = map[int]bool{}
@@ -117,7 +146,8 @@ func ParseGrants(col string) (Grants, error) {
// ValidGrantToken is the rail an operator meets at `carlos route -grant`,
// and the same rail ParseGrants applies to the stored column. It refuses:
//
-// - an unknown token shape (only "host-role" and "port:<n>" exist);
+// - an unknown token shape (only "host-role", "doc-engine" and "port:<n>"
+// exist);
// - a non-canonical port spelling ("port:025", "port:+25", "port: 25") —
// one grant has exactly one spelling;
// - a port outside 1..1023, because a high port needs no help from the
@@ -131,13 +161,13 @@ func ParseGrants(col string) (Grants, error) {
// keeps the comma-separated column unambiguous.
func ValidGrantToken(tok string) error {
if tok == "" {
- return fmt.Errorf("grant token is empty — want %q or %q", HostRoleToken, PortToken(25))
+ return fmt.Errorf("grant token is empty — want %q, %q or %q", HostRoleToken, DocEngineToken, PortToken(25))
}
- if tok == HostRoleToken {
+ if tok == HostRoleToken || tok == DocEngineToken {
return nil
}
if !strings.HasPrefix(tok, portTokenPrefix) {
- return fmt.Errorf("unknown grant token %q — the vocabulary is %q and %q", tok, HostRoleToken, portTokenPrefix+"<n>")
+ return fmt.Errorf("unknown grant token %q — the vocabulary is %q, %q and %q", tok, HostRoleToken, DocEngineToken, portTokenPrefix+"<n>")
}
p, ok := parsePortToken(tok)
if !ok {
@@ -182,6 +212,8 @@ func Grant(col, tok string) (string, error) {
}
if tok == HostRoleToken {
g.HostRole = true
+ } else if tok == DocEngineToken {
+ g.DocEngine = true
} else {
p, _ := parsePortToken(tok)
if g.Ports == nil {
@@ -207,6 +239,8 @@ func Revoke(col, tok string) (string, error) {
}
if tok == HostRoleToken {
g.HostRole = false
+ } else if tok == DocEngineToken {
+ g.DocEngine = false
} else if p, ok := parsePortToken(tok); ok {
delete(g.Ports, p)
}
diff --git a/internal/caps/grants_test.go b/internal/caps/grants_test.go
index a86c81931..ec4671d13 100644
--- a/internal/caps/grants_test.go
+++ b/internal/caps/grants_test.go
@@ -14,7 +14,7 @@ import (
// they are refused ABSOLUTELY, so an operator typo can never hand a tenant
// the edge's ports or the box's SSH (spec §7).
func TestValidGrantTokenRefusals(t *testing.T) {
- for _, tok := range []string{HostRoleToken, "port:25", "port:465", "port:1", "port:1023"} {
+ for _, tok := range []string{HostRoleToken, DocEngineToken, "port:25", "port:465", "port:1", "port:1023"} {
if err := ValidGrantToken(tok); err != nil {
t.Fatalf("ValidGrantToken(%q) = %v, want accepted", tok, err)
}
@@ -33,6 +33,11 @@ func TestValidGrantTokenRefusals(t *testing.T) {
{"port:-1", "1..1023"},
{"hostrole", "unknown grant token"},
{"host-role:1", "unknown grant token"},
+ // "engine" alone is NOT the token — it is the word `carlos store scan`
+ // already owns for the antivirus engine, so the caps token is spelled
+ // "doc-engine" to stay unambiguous in a caps column.
+ {"engine", "unknown grant token"},
+ {"docengine", "unknown grant token"},
{"", "empty"},
{"port:", "decimal port number"},
{"port:x", "decimal port number"},
@@ -168,6 +173,82 @@ func TestGrantRevokeIdempotent(t *testing.T) {
}
}
+// TestParseGrantsRoundTripsDocEngine pins the new token through the column:
+// parse, Has, String, and the canonical sort order (doc-engine first — "d"
+// sorts before "h" and "p", so a re-grant of the same set writes the same
+// bytes regardless of the order the operator granted them in).
+func TestParseGrantsRoundTripsDocEngine(t *testing.T) {
+ g, err := ParseGrants("doc-engine")
+ if err != nil {
+ t.Fatalf("ParseGrants: %v", err)
+ }
+ if !g.DocEngine || g.HostRole || len(g.Ports) != 0 {
+ t.Fatalf("ParseGrants(doc-engine) = %+v, want only DocEngine set", g)
+ }
+ if !g.Has(DocEngineToken) || g.Has(HostRoleToken) || g.Has("port:25") {
+ t.Fatalf("Has is wrong for %+v", g)
+ }
+ // Canonical order: doc-engine sorts before host-role and any port.
+ g, err = ParseGrants("port:25,host-role,doc-engine")
+ if err != nil {
+ t.Fatalf("ParseGrants: %v", err)
+ }
+ if got := g.String(); got != "doc-engine,host-role,port:25" {
+ t.Fatalf("String() = %q, want doc-engine,host-role,port:25", got)
+ }
+ // A duplicate doc-engine is refused like any other hand-edited duplicate.
+ if _, err := ParseGrants("doc-engine,doc-engine"); err == nil {
+ t.Fatal("ParseGrants accepted a duplicate doc-engine")
+ }
+}
+
+// TestGrantRevokeDocEngine: the new token grants, is idempotent, and revokes,
+// through the same String() canonicalisation the other tokens use.
+func TestGrantRevokeDocEngine(t *testing.T) {
+ col, err := Grant("", DocEngineToken)
+ if err != nil || col != "doc-engine" {
+ t.Fatalf(`Grant("", doc-engine) = (%q, %v)`, col, err)
+ }
+ again, err := Grant(col, DocEngineToken)
+ if err != nil || again != col {
+ t.Fatalf("re-granting doc-engine changed the column: %q -> %q (%v)", col, again, err)
+ }
+ col, err = Grant(col, "port:25")
+ if err != nil || col != "doc-engine,port:25" {
+ t.Fatalf("Grant(port:25) over doc-engine = (%q, %v), want canonical order", col, err)
+ }
+ col, err = Revoke(col, DocEngineToken)
+ if err != nil || col != "port:25" {
+ t.Fatalf("Revoke(doc-engine) = (%q, %v), want just port:25 left", col, err)
+ }
+ // Revoking an absent doc-engine is a quiet success.
+ if col, err = Revoke(col, DocEngineToken); err != nil || col != "port:25" {
+ t.Fatalf("revoking an absent doc-engine = (%q, %v), want a quiet no-op", col, err)
+ }
+}
+
+// TestValidCapabilitiesRefusesHostRoleWithDocEngine pins the contradiction
+// refusal: the two capabilities cannot both be declared, because host_role's
+// IPAddressAllow re-opens the IMDS address that doc_engine's fence exists to
+// close, and in systemd's BPF firewall an allow wins over a deny. The refusal
+// fires on the declaration alone, before any grant is consulted.
+func TestValidCapabilitiesRefusesHostRoleWithDocEngine(t *testing.T) {
+ err := ValidCapabilities("gotenberg", &Set{V: 1, HostRole: true, DocEngine: true})
+ if err == nil {
+ t.Fatal("ValidCapabilities accepted host_role + doc_engine together")
+ }
+ if !strings.Contains(err.Error(), "contradict") {
+ t.Fatalf("refusal %q does not say the two contradict", err)
+ }
+ // Each alone is fine.
+ if err := ValidCapabilities("gotenberg", &Set{V: 1, DocEngine: true}); err != nil {
+ t.Fatalf("doc_engine alone refused: %v", err)
+ }
+ if err := ValidCapabilities("keymail", &Set{V: 1, HostRole: true}); err != nil {
+ t.Fatalf("host_role alone refused: %v", err)
+ }
+}
+
// TestPortTokenMatchesTheManifestVocabulary: a declared port and its grant
// token must agree, or a grant an operator types could never satisfy the
// declaration it was typed for. ValidCapabilities and ValidGrantToken share
diff --git a/internal/caps/render.go b/internal/caps/render.go
index 65b9bd043..1e2c58b2a 100644
--- a/internal/caps/render.go
+++ b/internal/caps/render.go
@@ -22,6 +22,88 @@ import (
// brain (spec §4a).
const execStartPath = "/opt/carlos/live/%i"
+// docEngineMemoryMax and docEngineMemoryHigh are the resource band a
+// document-conversion orchestrator gets, in place of the template's 512M and
+// the children tier's 1G. Chromium and LibreOffice are not small: one
+// Chromium instance is dozens of processes and hundreds of megabytes before
+// it has rendered anything, and a LibreOffice conversion of a large document
+// is comparable. The default tier OOM-kills an engine mid-render, which is
+// the failure mode that would make this capability useless.
+//
+// High is 90% of Max, the same throttle-and-reclaim-before-the-kill band the
+// children tier uses (1G/920M) and internal/cgroup's highOf applies. (The
+// exec-backed path has no engine tier — internal/host's memtier knows only the
+// default and children bands — and doc_engine is unit-backed only: an exec
+// child that declared it would get these directives from no one. The grant
+// door's unit-backed rail is what makes that unreachable.)
+const (
+ docEngineMemoryMax = "4G"
+ docEngineMemoryHigh = "3680M"
+ docEngineTasksMax = "2048"
+)
+
+// docEngineEgressDeny is the private-network fence that pays for the
+// resource headroom above, and the reason this capability is a trade rather
+// than a gift.
+//
+// A conversion engine's entire job is fetching URLs a CALLER chose — every
+// <img>, <iframe>, CSS @import and JS fetch() in an HTML document, every
+// linked image in an Office file. That makes it an SSRF engine by
+// construction: hand it a document and it will happily dial whatever the
+// document names. The base template fences exactly two addresses
+// (carlos-app@.service's IPAddressDeny of the IMDS pair), so without this
+// list an engine reaches every other tenant's private-subnet upstream, the
+// whole VPC, and cloud internal services in the CGNAT range.
+//
+// What this fence does NOT do, stated plainly because a comment that hid it
+// would hide the real residual risk: LOOPBACK STAYS OPEN (see below), so
+// anything listening on 127.0.0.1 on the engine's own box — a co-tenant
+// console, a co-tenant's Addr-route upstream bound to loopback — remains
+// reachable by an SSRF'd engine. That is why the deployment rule for this
+// capability is a box with no NON-ENGINE co-tenants: several doc-engine
+// routes sharing one sidecar box is the expected shape (they run the same
+// hardened profile and hold nothing sensitive on loopback), but a mixed box
+// is not. The fence is one layer, and co-tenancy policy is the other.
+// Neither alone is the answer.
+//
+// Deliberately NOT fenced: 127.0.0.0/8 and ::1. Loopback is how Gotenberg
+// talks to its own Chromium and LibreOffice, and — the load-bearing half —
+// how it reaches a loopback DNS stub resolver. On AWS the VPC resolver lives
+// at VPC-base+2 (10.0.0.2 in a 10.0.0.0/16), INSIDE the 10.0.0.0/8 this list
+// denies, so an engine that resolved through it could not resolve anything
+// and the product would be dead on arrival. The sidecar box must therefore
+// run a loopback stub resolver (systemd-resolved on 127.0.0.53) and hand it
+// to the tenant. That is a BOX property this drop-in cannot assume or check —
+// it is a documented precondition of granting doc-engine (README's
+// capabilities section), and an adopt-time advisory that could detect its
+// absence is a known follow-up, not built here.
+//
+// The template's two IMDS addresses are repeated here even though
+// 169.254.0.0/16 and fc00::/7 already cover them. That redundancy is the
+// point: the security property is "this credential cannot reach the metadata
+// service", and AGENTS.md's object-storage lesson is that a property enforced
+// only by the absence of one thing cannot see the second door. Naming IMDS
+// explicitly keeps it greppable in the rendered bytes and still denied if a
+// future change narrows a range.
+var docEngineEgressDeny = []string{
+ // IPv4: this-host, RFC1918 (all three), IETF protocol assignments,
+ // link-local (IMDS lives here), CGNAT (where cloud internal services
+ // live), benchmarking (198.18.0.0/15 is also a common NAT/proxy
+ // testing range an SSRF probe would try), multicast, and reserved.
+ // Sorted, so the rendered line is deterministic.
+ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "169.254.0.0/16",
+ "169.254.169.254/32", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
+ "198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4",
+ // IPv6: NAT64's well-known prefix (it translates INTO private IPv4,
+ // so an unfenced NAT64 route to 10.x would walk around the IPv4 half
+ // of this list), ULA (fd00:ec2::254 — the IPv6 IMDS — lives here),
+ // link-local, site-local (deprecated but routable on some VPCs), and
+ // multicast. Sorted like the IPv4 half, so the rendered line is
+ // deterministic.
+ "64:ff9b::/96", "fc00::/7", "fd00:ec2::254/128", "fe80::/10",
+ "fec0::/10", "ff00::/8",
+}
+
// RenderService returns the [Service] drop-in body for the capabilities
// that are BOTH declared by s and (for the operator-consented ones)
// granted by g, or (nil, nil) when the effective set writes no [Service]
@@ -46,12 +128,23 @@ const execStartPath = "/opt/carlos/live/%i"
// renderer is a third caller of the same one, not a second copy of the
// rules.
//
-// Directive order is FIXED: command, then runtime_dir, then children, then
-// host_role. `daemon` and `data` are self-service capabilities that emit no
-// [Service] directive at all here — `daemon` is enable/start/restart
-// behaviour (Task 6 owns it), and `data`'s path is already the template's
-// own StateDirectory=carlos-app/%i. Ports are the socket unit's alone; see
+// Directive order is FIXED: command, then runtime_dir, then the resource
+// band, then host_role. The resource band is ONE of two shapes, never both:
+// a granted doc_engine's band (JIT enabled, raised memory/tasks, the egress
+// fence) or the children band (KillMode plus the 1G tier). A conversion
+// orchestrator declares both capabilities, and the engine band REPLACES the
+// children band's scalars rather than stacking on top of them, so no scalar
+// renders twice and the generated unit text never leans on systemd's
+// last-assignment-wins rule. KillMode renders whenever children is declared,
+// band or no band. `daemon` and `data` are self-service capabilities that
+// emit no [Service] directive at all here — `daemon` is enable/start/restart
+// behaviour (Task 6 owns it), and `data`'s path is already the template's own
+// StateDirectory=carlos-app/%i. Ports are the socket unit's alone; see
// RenderSocket.
+//
+// host_role and doc_engine cannot both be declared: ValidCapabilities refuses
+// the pair, so the host_role block below never coexists with an engine block
+// in one drop-in.
func RenderService(app string, s *Set, g Grants) ([]byte, error) {
if s == nil {
return nil, nil
@@ -85,10 +178,56 @@ func RenderService(app string, s *Set, g Grants) ([]byte, error) {
}
if s.Children {
+ // KillMode renders for children regardless of the resource band
+ // below: it is the "stop kills the whole tree" promise (systemd's
+ // default, stated because an orchestrator's orphans are exactly what
+ // a hibernation stop must not leave behind), and it has nothing to
+ // do with which tier the unit gets.
+ lines = append(lines, "KillMode=control-group")
+ }
+
+ if s.DocEngine && g.DocEngine {
+ // The engine band REPLACES the children band rather than overriding
+ // it: every scalar renders exactly once, so the generated unit text
+ // never depends on systemd's last-assignment-wins rule and a reader
+ // sees the effective value directly.
+ //
+ // V8's JIT needs memory that is both writable and executable, so the
+ // template's MemoryDenyWriteExecute=yes has to come off for Chromium
+ // to run at all. (--jitless is the alternative and it keeps the
+ // directive, but it is slow enough and incompatible enough with real
+ // pages that it is not what a conversion service can offer.)
+ //
+ // RestrictNamespaces=yes is deliberately LEFT ON. It is what kills
+ // Chromium's own namespace sandbox, so the engine must run
+ // --no-sandbox — and once Chromium's sandbox is off, relaxing the
+ // namespace restriction buys nothing but a wider attack surface for a
+ // renderer exploit. Keep the restriction, accept the engine's own
+ // sandbox is off, and let the egress fence below be the compensating
+ // control. This is the honest ceiling for running untrusted-document
+ // parsers, not a sandbox that holds.
+ lines = append(lines,
+ "MemoryDenyWriteExecute=no",
+ "MemoryMax="+docEngineMemoryMax,
+ "MemoryHigh="+docEngineMemoryHigh,
+ "TasksMax="+docEngineTasksMax,
+ )
+
+ // The fence that pays for all of the above. Empty assignment first
+ // resets the list — same documented systemd pattern the ExecStart
+ // reset uses — so these bytes are self-contained and do not depend on
+ // what the template or another drop-in already denied.
+ //
+ // Safe to reset despite the operator escape hatch: drop-ins are read
+ // in lexicographic order, and this file is 10-carlos-capabilities.conf,
+ // so an operator's 90-operator.conf is applied AFTER this reset and
+ // its own IPAddressDeny entries survive.
+ lines = append(lines, "IPAddressDeny=")
+ lines = append(lines, "IPAddressDeny="+strings.Join(docEngineEgressDeny, " "))
+ } else if s.Children {
// A platform-chosen tier, not a manifest-chosen number: `infinity`
// is unsayable and no number comes from a manifest.
lines = append(lines,
- "KillMode=control-group",
"TasksMax=1024",
"MemoryMax=1G",
// Throttle-and-reclaim before the kill, like the exec path's
diff --git a/internal/caps/render_test.go b/internal/caps/render_test.go
index e8f67a3e5..2e583c8ec 100644
--- a/internal/caps/render_test.go
+++ b/internal/caps/render_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"os"
+ "strings"
"testing"
)
@@ -48,6 +49,29 @@ func consoleGrants() Grants {
return Grants{HostRole: true}
}
+// gotenbergSet is the worked example for the doc_engine capability: a
+// document-conversion orchestrator that spawns children (Chromium,
+// LibreOffice) and is granted the engine trade.
+func gotenbergSet(t *testing.T) *Set {
+ t.Helper()
+ var s Set
+ raw := `{
+ "v": 1,
+ "command": "serve",
+ "daemon": true,
+ "children": true,
+ "doc_engine": true
+ }`
+ if err := json.Unmarshal([]byte(raw), &s); err != nil {
+ t.Fatalf("decode gotenberg set: %v", err)
+ }
+ return &s
+}
+
+func gotenbergGrants() Grants {
+ return Grants{DocEngine: true}
+}
+
func readGolden(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile("testdata/" + name)
@@ -90,6 +114,191 @@ func TestRenderServiceMatchesConsoleGolden(t *testing.T) {
}
}
+func TestRenderServiceMatchesGotenbergGolden(t *testing.T) {
+ got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants())
+ if err != nil {
+ t.Fatalf("RenderService: %v", err)
+ }
+ want := readGolden(t, "gotenberg.service.conf")
+ if !bytes.Equal(got, want) {
+ t.Fatalf("RenderService mismatch:\n got: %q\n want: %q", got, want)
+ }
+}
+
+// TestDocEngineTradeIsAtomic is the load-bearing property of this capability:
+// the resource relaxation and the egress fence land together or not at all. A
+// granted engine that got JIT and memory WITHOUT the private-network fence
+// would be an open SSRF proxy into the VPC with a bigger blast radius than the
+// stock tenant — the exact opposite of the trade the operator consented to. So
+// every relaxation directive must coexist with the fence in the same bytes.
+func TestDocEngineTradeIsAtomic(t *testing.T) {
+ got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants())
+ if err != nil {
+ t.Fatalf("RenderService: %v", err)
+ }
+ relaxations := []string{"MemoryDenyWriteExecute=no", "MemoryMax=" + docEngineMemoryMax, "TasksMax=" + docEngineTasksMax}
+ fence := "IPAddressDeny=0.0.0.0/8"
+ hasFence := bytes.Contains(got, []byte(fence))
+ if !hasFence {
+ t.Fatalf("engine drop-in has no egress fence:\n%s", got)
+ }
+ for _, r := range relaxations {
+ if !bytes.Contains(got, []byte(r)) {
+ t.Errorf("engine drop-in is missing relaxation %q (the fence is present, so the trade is half-written):\n%s", r, got)
+ }
+ }
+}
+
+// TestDocEngineFenceCoversThePrivateRangesAndNeverLoopback pins the two halves
+// of the fence that are each independently load-bearing and each easy to get
+// wrong:
+//
+// - every private/reserved range an SSRF'd engine would be pointed at is
+// denied, IMDS named explicitly (not only covered by its supernet);
+// - loopback is NEVER denied, because Gotenberg drives its own Chromium and
+// LibreOffice over 127.0.0.1 and reaches its DNS stub resolver there too.
+// Fencing loopback bricks the service and, on AWS, breaks name resolution
+// outright (the VPC resolver is at VPC-base+2, inside the denied RFC1918
+// ranges, so the box must hand the tenant a loopback stub).
+func TestDocEngineFenceCoversThePrivateRangesAndNeverLoopback(t *testing.T) {
+ got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants())
+ if err != nil {
+ t.Fatalf("RenderService: %v", err)
+ }
+ deny := ""
+ for _, line := range strings.Split(string(got), "\n") {
+ if strings.HasPrefix(line, "IPAddressDeny=") && line != "IPAddressDeny=" {
+ deny = line
+ }
+ }
+ for _, mustDeny := range []string{
+ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // RFC1918
+ "169.254.169.254/32", "fd00:ec2::254/128", // IMDS, named explicitly
+ "169.254.0.0/16", "fe80::/10", // link-local
+ "100.64.0.0/10", // CGNAT (cloud internal services)
+ "fc00::/7", // IPv6 ULA
+ "192.0.0.0/24", "240.0.0.0/4", // IETF assignments, reserved
+ "198.18.0.0/15", // benchmarking / common NAT-proxy range
+ "64:ff9b::/96", // NAT64 well-known prefix (translates into private IPv4)
+ "fec0::/10", // IPv6 site-local (deprecated but routable on some VPCs)
+ } {
+ if !strings.Contains(deny, mustDeny) {
+ t.Errorf("egress fence does not deny %q:\n%s", mustDeny, deny)
+ }
+ }
+ for _, mustNotDeny := range []string{"127.0.0.0/8", "127.0.0.1", "::1/128", "localhost"} {
+ if strings.Contains(deny, mustNotDeny) {
+ t.Errorf("egress fence denies loopback %q — Gotenberg talks to its own engines and its DNS stub resolver over loopback; fencing it bricks the service:\n%s", mustNotDeny, deny)
+ }
+ }
+}
+
+// TestDocEngineBandReplacesTheChildrenBand pins the scalar-render-once
+// property: a conversion orchestrator declares children AND doc_engine, and the
+// engine band replaces the children band's memory/tasks rather than stacking a
+// second assignment on top. The generated unit text must never contain two
+// MemoryMax or two TasksMax lines, so it does not lean on systemd's
+// last-assignment-wins rule — a reader sees the effective value directly.
+// KillMode, which is children's "stop kills the tree" promise and not a
+// resource scalar, still renders.
+func TestDocEngineBandReplacesTheChildrenBand(t *testing.T) {
+ got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants())
+ if err != nil {
+ t.Fatalf("RenderService: %v", err)
+ }
+ for _, scalar := range []string{"MemoryMax=", "TasksMax=", "MemoryHigh="} {
+ n := 0
+ for _, line := range strings.Split(string(got), "\n") {
+ if strings.HasPrefix(line, scalar) {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Errorf("engine drop-in renders %q %d times, want exactly once (the children band must be replaced, not stacked):\n%s", scalar, n, got)
+ }
+ }
+ if !bytes.Contains(got, []byte("KillMode=control-group")) {
+ t.Errorf("engine drop-in lost children's KillMode:\n%s", got)
+ }
+ if bytes.Contains(got, []byte("MemoryMax=1G")) {
+ t.Errorf("engine drop-in still carries the children tier's 1G — the engine band did not replace it:\n%s", got)
+ }
+}
+
+// TestDocEngineUngrantedRendersNoRelaxationAndNoFence is the asymmetric case:
+// a manifest declaring doc_engine on a box that has NOT granted it materializes
+// neither the relaxation nor the fence. The engine simply will not start under
+// the stock template (MemoryDenyWriteExecute stays on, the default tier
+// OOM-kills it) — which is the safe direction, and ungrantedAdvice names the
+// fix. It must never render the fence alone, either: a fence with no engine is
+// a lie in the unit text.
+func TestDocEngineUngrantedRendersNoRelaxationAndNoFence(t *testing.T) {
+ got, err := RenderService("gotenberg", gotenbergSet(t), Grants{})
+ if err != nil {
+ t.Fatalf("RenderService: %v", err)
+ }
+ for _, unwanted := range []string{"MemoryDenyWriteExecute=no", "IPAddressDeny=0.0.0.0/8", "MemoryMax=" + docEngineMemoryMax} {
+ if bytes.Contains(got, []byte(unwanted)) {
+ t.Errorf("ungranted doc_engine rendered %q:\n%s", unwanted, got)
+ }
+ }
+ // children is still declared and self-service, so its band renders.
+ if !bytes.Contains(got, []byte("MemoryMax=1G")) {
+ t.Errorf("ungranted doc_engine lost the children band it still declares:\n%s", got)
+ }
+}
+
+// TestDocEngineGrantAloneRendersNothing mirrors the host_role grant-lingers
+// case: a grant with no matching declaration materializes nothing. The grant is
+// not a declaration.
+func TestDocEngineGrantAloneRendersNothing(t *testing.T) {
+ s := &Set{V: 1, Command: "serve"}
+ got, err := RenderService("gotenberg", s, Grants{DocEngine: true})
+ if err != nil {
+ t.Fatalf("RenderService: %v", err)
+ }
+ if bytes.Contains(got, []byte("IPAddressDeny=0.0.0.0/8")) || bytes.Contains(got, []byte("MemoryDenyWriteExecute=no")) {
+ t.Errorf("a doc_engine grant with no declaration rendered engine directives:\n%s", got)
+ }
+}
+
+// TestBothGrantsHeldOneDeclaredRendersOnlyTheDeclaredBlock pins the case the
+// host_role+doc_engine refusal at declaration time cannot cover by itself: the
+// registry's caps column CAN hold both tokens (ValidGrantToken accepts each,
+// and nothing forbids the pair in the column — the refusal lives in
+// ValidCapabilities, which sees the manifest). So a box whose column says
+// "doc-engine,host-role" against a manifest declaring only one must render that
+// one block and NOTHING of the other — in particular, a doc_engine declaration
+// must never pick up host_role's IPAddressAllow, which in systemd's BPF
+// firewall would win over the fence's IPAddressDeny and re-open IMDS.
+func TestBothGrantsHeldOneDeclaredRendersOnlyTheDeclaredBlock(t *testing.T) {
+ both := Grants{DocEngine: true, HostRole: true}
+
+ engineOnly, err := RenderService("gotenberg", &Set{V: 1, Command: "serve", DocEngine: true}, both)
+ if err != nil {
+ t.Fatalf("RenderService(doc_engine only): %v", err)
+ }
+ if !bytes.Contains(engineOnly, []byte("IPAddressDeny=0.0.0.0/8")) {
+ t.Errorf("doc_engine declaration under both grants lost its fence:\n%s", engineOnly)
+ }
+ for _, forbidden := range []string{"IPAddressAllow", "DynamicUser=no", "User=carlos-gotenberg"} {
+ if bytes.Contains(engineOnly, []byte(forbidden)) {
+ t.Errorf("doc_engine declaration under both grants rendered host_role's %q:\n%s", forbidden, engineOnly)
+ }
+ }
+
+ hostOnly, err := RenderService("keymail", &Set{V: 1, Command: "relay", HostRole: true}, both)
+ if err != nil {
+ t.Fatalf("RenderService(host_role only): %v", err)
+ }
+ if !bytes.Contains(hostOnly, []byte("IPAddressAllow=169.254.169.254/32")) {
+ t.Errorf("host_role declaration under both grants lost its IPAddressAllow:\n%s", hostOnly)
+ }
+ if bytes.Contains(hostOnly, []byte("IPAddressDeny=0.0.0.0/8")) || bytes.Contains(hostOnly, []byte("MemoryDenyWriteExecute=no")) {
+ t.Errorf("host_role declaration under both grants rendered engine directives:\n%s", hostOnly)
+ }
+}
+
// TestRenderIsDeterministic renders the same effective set from two
// independently-decoded Sets whose "ports" arrays are given in different
// orders, and asserts byte-identical output with ports in ascending order
diff --git a/internal/caps/testdata/gotenberg.service.conf b/internal/caps/testdata/gotenberg.service.conf
new file mode 100644
index 000000000..9f3d48bc6
--- /dev/null
+++ b/internal/caps/testdata/gotenberg.service.conf
@@ -0,0 +1,11 @@
+# GENERATED by carlos adopt — do not hand-edit
+[Service]
+ExecStart=
+ExecStart=/opt/carlos/live/%i serve
+KillMode=control-group
+MemoryDenyWriteExecute=no
+MemoryMax=4G
+MemoryHigh=3680M
+TasksMax=2048
+IPAddressDeny=
+IPAddressDeny=0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10 ff00::/8
diff --git a/internal/host/caps_pass.go b/internal/host/caps_pass.go
index a643abe17..aa3a53af4 100644
--- a/internal/host/caps_pass.go
+++ b/internal/host/caps_pass.go
@@ -231,6 +231,10 @@ func ungrantedAdvice(rt registry.Route, s *caps.Set, g caps.Grants) []string {
out = append(out, fmt.Sprintf("adopt: %s declares capability %s but this box has not granted it — the unit keeps DynamicUser and cannot use the box's instance role; grant it with: carlos route --host %s --grant %s",
rt.Host, caps.HostRoleToken, rt.Host, caps.HostRoleToken))
}
+ if s.DocEngine && !g.DocEngine {
+ out = append(out, fmt.Sprintf("adopt: %s declares capability %s but this box has not granted it — the unit keeps MemoryDenyWriteExecute and the default memory tier, so a Chromium or LibreOffice engine will not start (and would be OOM-killed if it did); grant it with: carlos route --host %s --grant %s",
+ rt.Host, caps.DocEngineToken, rt.Host, caps.DocEngineToken))
+ }
return out
}
@@ -273,6 +277,10 @@ func undeclaredGrantAdvice(rt registry.Route, s *caps.Set, g caps.Grants) []stri
out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — the unit keeps DynamicUser and reaches nothing of the box's instance role; revoke it with `carlos route --host %s --revoke %s`, or ship a release that declares it",
rt.Host, caps.HostRoleToken, rt.Host, caps.HostRoleToken))
}
+ if g.DocEngine && !s.DocEngine {
+ out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — the unit keeps the default resource tier and its normal egress; revoke it with `carlos route --host %s --revoke %s`, or ship a release that declares it",
+ rt.Host, caps.DocEngineToken, rt.Host, caps.DocEngineToken))
+ }
return out
}
diff --git a/internal/host/caps_pass_test.go b/internal/host/caps_pass_test.go
index e8c579791..f530fc66a 100644
--- a/internal/host/caps_pass_test.go
+++ b/internal/host/caps_pass_test.go
@@ -439,6 +439,11 @@ func TestBuildPlansAdvisesUngrantedDeclaredCapabilities(t *testing.T) {
set: &caps.Set{V: 1, HostRole: true},
want: []string{"declares capability host-role", "carlos route --host keymail.dev --grant host-role"},
},
+ {
+ name: "ungranted doc_engine says the engine will not start, and names the fix",
+ set: &caps.Set{V: 1, DocEngine: true},
+ want: []string{"declares capability doc-engine", "MemoryDenyWriteExecute", "OOM-killed", "carlos route --host keymail.dev --grant doc-engine"},
+ },
{
name: "one line per capability, in port order",
set: &caps.Set{V: 1, HostRole: true, Ports: []caps.Port{{Port: 465}, {Port: 25}}},
@@ -502,6 +507,13 @@ func TestBuildPlansAdvisesGrantedButUndeclaredCapabilities(t *testing.T) {
want: []string{"is granted host-role", "keeps DynamicUser",
"carlos route --host keymail.dev --revoke host-role"},
},
+ {
+ name: "granted doc-engine the live release never declares",
+ set: &caps.Set{V: 1, Daemon: true},
+ grants: "doc-engine",
+ want: []string{"is granted doc-engine", "keeps the default resource tier",
+ "carlos route --host keymail.dev --revoke doc-engine"},
+ },
{
name: "declared and granted says nothing",
set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}, HostRole: true},