Compose a module's Go service as a process the host runs (hq issue 213)

The controller is to be declared as a Go bundle run by a process instead of
an image (novox/hq issue 213, ADR 0188 §1, §3). The composer could not
express that honestly yet:

- a module declaring tools had every bundle served by the node's runtime,
  so the controller's own binary would have been launched a second time as
  an MCP child; a bundle one of the module's resources runs is now served
  only when it says `loads`
- a module's accounts went after the mesh-computed files, so secrets owned
  by the account a process runs as were refused on the first apply; a
  module's `user` resources now go first
- `prepares` derived its step only from a container; a process is now
  prepared by the same program with `prepare` as a run-once process
- a process may say what it `replaces` (a resource of its module it no
  longer declares), prefixed as the host records it, so the host keeps the
  old one running until the process is (needs mesh-host's `replaces`)

This lands before the controller's manifest uses any of it: the running
controller composes its own declaration, so the code that fills the new
shape must be live first.
This commit is contained in:
jochen
2026-10-04 01:11:26 +02:00
parent 00037608ae
commit 7bb9e55d0b
4 changed files with 286 additions and 12 deletions
+52 -4
View File
@@ -693,7 +693,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// Now, and not before: a module whose resources are computed replaces them wholesale, and
// merging earlier would throw away the files it still needs.
resources = append(append([]map[string]any{}, first...), resources...)
//
// **Except the module's own accounts, which go before even those** (novox/hq issue 213). What
// the mesh computes may belong to one: a module whose code runs as an account it declares has
// its secrets written owned by that account, and a file given to a user the machine does not
// have yet fails — so on the first apply the secrets were refused, the process started without
// them, and the second apply healed it, which is the fault the paragraph above describes.
// An account depends on nothing the mesh computes.
accounts, rest := accountsFirst(resources)
resources = append(append(accounts, first...), rest...)
// No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
// container got the whole roster as `--add-host` entries at creation, and a name that
@@ -872,6 +880,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
copied["reload-on"] = renamed
}
// And what a process replaces (novox/hq issue 213): a resource of this module's that it
// no longer declares, named as the host recorded it, or the host hands nothing over and
// removes it first.
if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil {
copied["replaces"] = renamed
}
// **What reads one of this module's own secrets is restarted when it changes** (novox/hq
// issue 203, issue 206). A credential is re-issued by the mesh, and a container that
// mounted the old file keeps the old one open: the build machine ran for an hour on a
@@ -2010,12 +2024,18 @@ func preparationTarget(m Manifest) string {
return ""
}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" || !ownArtifact(r, m.Module) {
// A container, or a process the host runs from a bundle the module built (novox/hq issue
// 213): the same program in the same context, hosted as a unit rather than a container.
kind := fmt.Sprint(r["type"])
if (kind != "container" && kind != "process") || !ownArtifact(r, m.Module) {
continue
}
if once, _ := r["run-once"].(bool); once {
continue
}
if r["schedule"] != nil {
continue
}
return fmt.Sprint(r["id"])
}
return ""
@@ -2029,7 +2049,10 @@ func ownArtifact(resource map[string]any, module string) bool {
return true
}
image, _ := resource["image"].(string)
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/")
// A process or an archive carries what was built as its source (novox/hq issue 213).
source, _ := resource["source"].(string)
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/") ||
strings.HasPrefix(source, ArtifactStoreScheme+module+"/")
}
// prepared is the module's own resource as the step that prepares its state: the same image, the same
@@ -2051,7 +2074,19 @@ func prepared(from map[string]any) map[string]any {
step["id"] = fmt.Sprint(from["id"]) + "-prepare"
step["name"] = fmt.Sprint(from["name"]) + "-prepare"
step["run-once"] = true
step["args"] = []any{PreparationArgument}
if fmt.Sprint(from["type"]) == "process" {
// A process says its whole command: the program, then its arguments. The step is the same
// program asked to prepare (novox/hq issue 213). It replaces nothing — what the process
// replaces is handed over to the process, never to the step that runs before it — and a
// step is not restarted, it runs again when what it reads changed, which `restart-on` says.
run := stringsIn(from["run"])
if len(run) > 0 {
step["run"] = []any{run[0], PreparationArgument}
}
delete(step, "replaces")
} else {
step["args"] = []any{PreparationArgument}
}
delete(step, "ports")
delete(step, "ip")
delete(step, "schedule")
@@ -2196,3 +2231,16 @@ func withRestartOn(have any, add []string) []any {
}
return out
}
// accountsFirst splits a module's resources into its accounts and everything else, each in the order
// written.
func accountsFirst(resources []map[string]any) (accounts, rest []map[string]any) {
for _, r := range resources {
if fmt.Sprint(r["type"]) == "user" {
accounts = append(accounts, r)
continue
}
rest = append(rest, r)
}
return accounts, rest
}