A struct per resource kind, instead of one struct with every field

Jochen asked why we don't simply have dedicated structs. We should, and the
flat struct was me extending an existing pattern rather than questioning it.

Before: one Resource struct carrying path, content, mode, unit, state, package,
image, name, env, ports, volumes, args, command, verify and in. Because a file
and a container shared it, nothing stopped {"type":"file","image":"postgres"},
so a `uses` map listed which fields each kind was allowed to carry -- a second
place to keep current, and the kind nobody updates is the one that silently
accepts a field the host will never read.

Now: Directory, File, Service, Package, Container and Action are separate
structs behind a Resource interface. File has no Image field, so the mistake is
not detected -- it is unrepresentable. Adding a field to a kind is the whole of
adding it; there is nowhere else that has to agree.

Parsing is two passes: read the envelope and each resource's raw bytes, peek at
"type" to choose the struct, then decode into it. Peeking is lenient on purpose
-- reading strictly there would report an unknown field before knowing which
fields are known.

Unknown fields are found by comparing the JSON keys against the struct's own
json tags rather than by catching the decoder's error. The decoder stops at the
first unknown field, and RefusalError promises every problem at once: a caller
fixing one field at a time learns the next only by running again. Caught by
testing the refactor against a real declaration -- a container carrying both
`unit` and `mode` reported only one of them.

apply.go switches on the concrete type instead of a string, so a new kind that
has no applier is a compile error rather than a runtime default branch.

No behaviour change otherwise. All existing tests pass unmodified except two
that reached for fields the interface no longer exposes.
This commit is contained in:
2026-08-27 21:03:59 +02:00
parent 337126603e
commit 9a9937b7e6
4 changed files with 365 additions and 236 deletions
+38 -35
View File
@@ -93,7 +93,7 @@ func Apply(
declared := map[string]bool{}
for _, r := range d.Resources {
declared[r.ID] = true
declared[r.Identity()] = true
}
for _, orphan := range known.Orphans(declared) {
@@ -112,12 +112,12 @@ func Apply(
for _, resource := range d.Resources {
outcome, err := applyOne(ctx, resource, run)
if err != nil {
return report, known, &Error{Resource: resource.ID, Err: err, Done: report}
return report, known, &Error{Resource: resource.Identity(), Err: err, Done: report}
}
// Only now. The record follows the fact, never leads it.
known.Record(store.Applied{
ID: resource.ID, Type: string(resource.Type),
ID: resource.Identity(), Type: string(resource.Kind()),
Target: outcome.Target, AppliedAt: time.Now().UTC(),
})
report.Outcomes = append(report.Outcomes, outcome)
@@ -129,26 +129,32 @@ func Apply(
}
func applyOne(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
switch r.Type {
case declaration.TypeDirectory:
return applyDirectory(r)
case declaration.TypeFile:
return applyFile(r)
case declaration.TypeService:
return applyService(ctx, r, run)
case declaration.TypePackage:
return applyPackage(ctx, r, run)
case declaration.TypeContainer:
return applyContainer(ctx, r, run)
case declaration.TypeAction:
return applyAction(ctx, r, run)
switch res := r.(type) {
case *declaration.Directory:
return applyDirectory(res)
case *declaration.File:
return applyFile(res)
case *declaration.Service:
return applyService(ctx, res, run)
case *declaration.Package:
return applyPackage(ctx, res, run)
case *declaration.Container:
return applyContainer(ctx, res, run)
case *declaration.Action:
return applyAction(ctx, res, run)
default:
// Unreachable: the declaration refused this already. Present because "unreachable"
// stops being true the moment someone adds a type and forgets this switch.
return Outcome{}, fmt.Errorf("no applier for type %q", r.Type)
// stops being true the moment someone adds a kind and forgets this switch.
return Outcome{}, fmt.Errorf("no applier for type %q", r.Kind())
}
}
// begin starts an outcome from any resource, so the three facts a report needs are read from
// the resource itself rather than restated by each applier.
func begin(r declaration.Resource) Outcome {
return Outcome{ID: r.Identity(), Type: string(r.Kind()), Target: r.Target()}
}
func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
if spec == "" {
return fallback, nil
@@ -160,8 +166,8 @@ func modeOf(spec string, fallback os.FileMode) (os.FileMode, error) {
return os.FileMode(parsed), nil
}
func applyDirectory(r declaration.Resource) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Path}
func applyDirectory(r *declaration.Directory) (Outcome, error) {
out := begin(r)
mode, err := modeOf(r.Mode, 0o755)
if err != nil {
return out, err
@@ -210,8 +216,8 @@ func applyDirectory(r declaration.Resource) (Outcome, error) {
return out, nil
}
func applyFile(r declaration.Resource) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Path}
func applyFile(r *declaration.File) (Outcome, error) {
out := begin(r)
mode, err := modeOf(r.Mode, 0o644)
if err != nil {
return out, err
@@ -308,8 +314,8 @@ func writeAtomically(path string, content []byte, mode os.FileMode) error {
return os.Rename(tmp.Name(), path)
}
func applyService(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Unit}
func applyService(ctx context.Context, r *declaration.Service, run Runner) (Outcome, error) {
out := begin(r)
before, err := serviceState(ctx, r.Unit, run)
if err != nil {
@@ -495,8 +501,8 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error
// asserts, because version is the package manager's business and the mesh does not have a
// second opinion about it (novox/hq ADR 0041 — the host depends on nothing, and that includes
// not becoming a second package manager).
func applyPackage(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Package}
func applyPackage(ctx context.Context, r *declaration.Package, run Runner) (Outcome, error) {
out := begin(r)
installed, err := packageInstalled(ctx, r.Package, run)
if err != nil {
@@ -558,7 +564,7 @@ const (
// containerSpec is the identity of a declared container: everything that, if changed, means
// the running container is no longer what was asked for.
func containerSpec(r declaration.Resource) string {
func containerSpec(r *declaration.Container) string {
keys := make([]string, 0, len(r.Env))
for k := range r.Env {
keys = append(keys, k)
@@ -604,8 +610,8 @@ func containerState(ctx context.Context, name string, run Runner) (state struct
// There is no "update" for a container: a container's configuration is fixed when it is
// created, so any change is a replacement. Saying that plainly is better than a partial
// in-place update that leaves the running thing half-declared.
func applyContainer(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: r.Name}
func applyContainer(ctx context.Context, r *declaration.Container, run Runner) (Outcome, error) {
out := begin(r)
want := containerSpec(r)
if _, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err != nil {
@@ -685,11 +691,8 @@ func sortedKeys(m map[string]string) []string {
// anything to do — it does not know what a database is, so "is the database there" is a
// question only the declaration can ask. Running it again afterwards is how the host knows the
// command had the effect it claimed (novox/hq ADR 0047).
func applyAction(ctx context.Context, r declaration.Resource, run Runner) (Outcome, error) {
out := Outcome{ID: r.ID, Type: string(r.Type), Target: strings.Join(r.Command, " ")}
if r.In != "" {
out.Target = "in " + r.In + ": " + out.Target
}
func applyAction(ctx context.Context, r *declaration.Action, run Runner) (Outcome, error) {
out := begin(r)
if _, err := runAction(ctx, r, r.Verify, run); err == nil {
out.Action = "unchanged"
@@ -714,7 +717,7 @@ func applyAction(ctx context.Context, r declaration.Resource, run Runner) (Outco
}
// runAction runs one of an action's command lines, on the machine or inside a container.
func runAction(ctx context.Context, r declaration.Resource, argv []string, run Runner) (string, error) {
func runAction(ctx context.Context, r *declaration.Action, argv []string, run Runner) (string, error) {
if len(argv) == 0 {
return "", errors.New("no command")
}