The mechanism was leaking into every module. Code of one's own meant a container and therefore an image; a script meant a service and a unit somebody else had to install. One intent — run this and keep it running — expressed two unrelated ways, with the hosting chosen before anything could be declared. A daemon names a bundle and a command. The host fetches it, refuses it unless it hashes to what was declared, unpacks it where the mesh keeps such things, writes the unit and puts it in the state asked for. The unit is the mesh's, generated whole and saying so, because an edit that survives until the next declaration and then vanishes is worse than one that is refused. Its identity is the bytes AND how it is run: two daemons from one bundle differing only in their command are different daemons, and tracking the digest alone would call the second unchanged and leave the first running. The unit is rendered deterministically for the same reason — environment from a map would be written in Go's iteration order, so every apply would see a different unit and restart an unchanged daemon for ever. restart-on is honoured as a service's is: a running process does not re-read its configuration, so replacing a file and finding the daemon already up leaves the machine behaving as before while every check passes. A full-host shape, not a portable one: it needs a process supervisor to install into. It does NOT need a container runtime, which is the point. Two guards caught this properly and both were updated deliberately rather than silenced: the vocabulary count, which exists because every addition widens what a compromised control plane can express, and the shape test that catches a kind the language has and a host cannot apply — added after `network` did exactly that. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
1053 lines
45 KiB
Go
1053 lines
45 KiB
Go
// Package declaration is what the host is told a machine should be.
|
|
//
|
|
// Data, never instructions. The vocabulary is finite, versioned, and anything outside it
|
|
// refuses the whole declaration rather than being skipped — a host that applied most of what
|
|
// it was sent and reported success is a node that looks configured and is not
|
|
// (novox/hq ADR 0005).
|
|
package declaration
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"reflect"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Version is the vocabulary this host speaks. A declaration naming any other version is
|
|
// refused: an older host handed a newer vocabulary must not quietly do half of it.
|
|
const Version = 1
|
|
|
|
// Type names a kind of resource. Every addition widens what a compromised control plane can
|
|
// express, so the list is a security artefact and grows deliberately.
|
|
type Type string
|
|
|
|
const (
|
|
TypeDirectory Type = "directory"
|
|
TypeFile Type = "file"
|
|
TypeService Type = "service"
|
|
TypePackage Type = "package"
|
|
TypeContainer Type = "container"
|
|
TypeAction Type = "action"
|
|
|
|
// TypeUser is a login on the machine. Added because most of what a person actually installs
|
|
// is not a service: a shell, a terminal, a chat client, a desktop. All of those are a package
|
|
// plus configuration **in somebody's home**, and a mesh with no notion of a user can only
|
|
// manage /etc.
|
|
TypeUser Type = "user"
|
|
|
|
// TypeArchive is a set of files fetched by digest and unpacked. A desktop theme is hundreds
|
|
// of files; inlining them would make every declaration enormous and rewrite the lot whenever
|
|
// one changed.
|
|
TypeArchive Type = "archive"
|
|
|
|
// TypeNetwork is a named network on this machine, for a module whose containers must reach
|
|
// each other by name. Created if absent, removed when no longer declared — which is the whole
|
|
// reason it is a shape rather than an action, because an action leaves nothing the host can
|
|
// undo and the network would outlive the module (novox/hq ADR 0029).
|
|
TypeNetwork Type = "network"
|
|
|
|
// TypeAccess is a pre-existing, operator-owned path a module is granted use of but does not
|
|
// own (novox/hq ADR 0051). The opposite of a directory on every axis the host acts on: the
|
|
// host creates, chowns and reconciles a directory, and removes it when it is empty; it does
|
|
// none of that to an access. It confirms the path is present — refusing clearly if the
|
|
// operator has not provided it, rather than creating it as a bind mount source would
|
|
// (04-ISSUES/026) — and leaves everything about it alone. Several modules declaring one
|
|
// access is ordinary, because none of them owns it.
|
|
TypeAccess Type = "access"
|
|
|
|
// TypeDaemon is a long-running process the mesh keeps running, named by what it runs rather
|
|
// than by how it is hosted.
|
|
//
|
|
// **The intent, not the mechanism.** Until this, an author decided the hosting before they
|
|
// could declare anything: code of their own meant a `container` built from an image, a script
|
|
// meant a `service` and a unit somebody else had to install. Same intent — run this and keep
|
|
// it running — expressed two unrelated ways, and the choice baked into which kind was picked.
|
|
//
|
|
// A daemon names an artifact and what to run. The mesh unpacks the artifact where it keeps
|
|
// such things, writes the unit, and puts it in the state asked for. One module may declare
|
|
// several, in different languages, because a module is one piece of software and not one
|
|
// process (novox/hq ADR 0040).
|
|
TypeDaemon Type = "daemon"
|
|
)
|
|
|
|
// Resource is one thing that should be true of the machine.
|
|
//
|
|
// A struct per kind rather than one struct carrying every field, because the decoder is then
|
|
// what rejects a field the kind does not have: a `file` carrying an `image` is refused because
|
|
// File has no such field, not because a list somewhere remembered to say so. The one-struct
|
|
// form needs every kind revisited whenever a field is added, and the kind nobody revisits
|
|
// silently accepts a field the host will never read.
|
|
type Resource interface {
|
|
// Identity is the name the control plane keeps stable across declarations. Not a position
|
|
// and not a hash of the content: it is what lets the store say *this is the same resource
|
|
// I applied last time*, which is what makes removal possible at all.
|
|
Identity() string
|
|
// Kind is the resource's type, for the store and for reporting.
|
|
Kind() Type
|
|
// Target is what the resource acts on, for a person reading a report.
|
|
Target() string
|
|
|
|
validate(where string, allowActions bool) []string
|
|
}
|
|
|
|
// The `Type` field on each kind below exists only to absorb the JSON `"type"` key, which the
|
|
// strict decoder would otherwise refuse. `Kind()` returns the constant and is what anything
|
|
// else should read.
|
|
|
|
// Directory is a directory that should exist, with a mode.
|
|
type Directory struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Path string `json:"path"`
|
|
Mode string `json:"mode,omitempty"`
|
|
// Owner is the user this belongs to, by name. Absent means root, which is what everything
|
|
// managed was until users existed.
|
|
Owner string `json:"owner,omitempty"`
|
|
}
|
|
|
|
func (d *Directory) Identity() string { return d.ID }
|
|
func (d *Directory) Kind() Type { return TypeDirectory }
|
|
func (d *Directory) Target() string { return d.Path }
|
|
|
|
func (d *Directory) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if d.Path == "" {
|
|
problems = append(problems, where+": a directory needs a path")
|
|
}
|
|
return append(problems, checkMode(where, d.Mode)...)
|
|
}
|
|
|
|
// File is a file with literal content. The host renders nothing.
|
|
type File struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
Mode string `json:"mode,omitempty"`
|
|
|
|
// Sealed is content encrypted to this node's sealing key, for a file the mesh must deliver
|
|
// without being able to read.
|
|
//
|
|
// The one thing here the host cannot simply write. Everything else in a declaration is
|
|
// visible to whatever carried it — the broker relays the message, and the message is signed
|
|
// so it cannot be forged, but signing does not make it unreadable. A password travelling in
|
|
// `content` would be a password the broker sees, which is the transitive trust the design
|
|
// refuses everywhere else (novox/hq ADR 0004).
|
|
//
|
|
// Exclusive with Content: a file is one or the other, so that "was this secret" is answerable
|
|
// by looking rather than by knowing which field won.
|
|
Sealed string `json:"sealed,omitempty"`
|
|
|
|
// Secrets are sealed values put into Content where it says `${secret:name}`.
|
|
//
|
|
// **The one place a secret and a configuration meet, and it happens on the machine.** A
|
|
// program that wants its token inside a JSON document cannot be given a file that is entirely
|
|
// a token, and the mesh cannot compose the document itself — it discarded the value
|
|
// (novox/hq ADR 0024). So the module supplies the document with a hole in it, the mesh
|
|
// delivers the value sealed, and the host is the only thing that ever sees both.
|
|
//
|
|
// **Substitution is textual and the host learns no formats.** That is deliberate: a mechanism
|
|
// that understood JSON would be asked to understand YAML next, and then INI, which is how the
|
|
// arrangement this replaces became something nobody could hold in their head. The module knows
|
|
// its own format, because it wrote the rest of the file.
|
|
//
|
|
// The sharp edge, stated rather than discovered: a value containing a quote or a backslash
|
|
// will not be escaped for whatever syntax surrounds it.
|
|
Secrets map[string]string `json:"secrets,omitempty"`
|
|
|
|
// Bytes is content that is not text, base64-encoded — a wallpaper, a font, an icon.
|
|
//
|
|
// A third way of saying what is in a file, and the three are exclusive. It would have been
|
|
// tempting to let Content carry base64 and add a flag, and then "what is in this file" would
|
|
// depend on a field somewhere else.
|
|
Bytes string `json:"bytes,omitempty"`
|
|
|
|
// Owner is the user this belongs to, by name. Absent means root.
|
|
Owner string `json:"owner,omitempty"`
|
|
}
|
|
|
|
// Secret reports whether this file arrived sealed, which is what decides both that it must be
|
|
// opened before writing and that its contents must never appear in a report.
|
|
func (f *File) Secret() bool { return f.Sealed != "" }
|
|
|
|
func (f *File) Identity() string { return f.ID }
|
|
func (f *File) Kind() Type { return TypeFile }
|
|
func (f *File) Target() string { return f.Path }
|
|
|
|
// placeholder is what Content says where a sealed value belongs: ${secret:name}.
|
|
var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`)
|
|
|
|
// SecretsUsed are the names Content asks for, in the order they first appear.
|
|
func (f *File) SecretsUsed() []string {
|
|
var used []string
|
|
seen := map[string]bool{}
|
|
for _, m := range placeholder.FindAllStringSubmatch(f.Content, -1) {
|
|
if !seen[m[1]] {
|
|
seen[m[1]] = true
|
|
used = append(used, m[1])
|
|
}
|
|
}
|
|
return used
|
|
}
|
|
|
|
func (f *File) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if f.Path == "" {
|
|
problems = append(problems, where+": a file needs a path")
|
|
}
|
|
var said []string
|
|
for name, value := range map[string]string{
|
|
"content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes,
|
|
} {
|
|
if value != "" {
|
|
said = append(said, name)
|
|
}
|
|
}
|
|
if len(said) > 1 {
|
|
sort.Strings(said)
|
|
problems = append(problems, where+
|
|
": a file says what is in it exactly once, and this says it as "+
|
|
strings.Join(said, " and ")+
|
|
" — otherwise nobody can tell by looking which one landed on the machine")
|
|
}
|
|
// A file whose content names a secret must be given exactly the secrets it names.
|
|
//
|
|
// **Both directions, and both are refusals rather than warnings.** A placeholder with nothing
|
|
// to fill it would write `${secret:x}` into a configuration file, which the program reads as
|
|
// a value and fails on somewhere unrelated. A secret nobody uses means whoever wrote this
|
|
// believes a credential is in a file where it is not.
|
|
if len(f.Secrets) > 0 && f.Content == "" {
|
|
problems = append(problems, where+
|
|
": secrets were given and there is no content to put them in")
|
|
}
|
|
used := f.SecretsUsed()
|
|
for _, name := range used {
|
|
if f.Secrets[name] == "" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: the content asks for the secret %q and none was given", where, name))
|
|
}
|
|
}
|
|
for name := range f.Secrets {
|
|
if !slices.Contains(used, name) {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: the secret %q was given and the content never asks for it", where, name))
|
|
}
|
|
}
|
|
return append(problems, checkMode(where, f.Mode)...)
|
|
}
|
|
|
|
// User is a login on the machine.
|
|
//
|
|
// The thing that makes a shell, a chat client or a desktop expressible at all: each is a package
|
|
// plus configuration in somebody's home, and until this the mesh could only own /etc.
|
|
//
|
|
// It also makes "zsh is my login shell" **declared state** rather than an action. `chsh` is a
|
|
// command, the link may not carry one (novox/hq ADR 0005), and a shell that could only be set by
|
|
// hand would be a shell the mesh cannot manage — which is most of the reason to manage a machine
|
|
// at all.
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Name string `json:"name"`
|
|
|
|
// Shell this user logs in with. Absent means the host asserts nothing and leaves whatever is
|
|
// there — the same rule Service.Boot follows, for the same reason: a field that always
|
|
// asserts cannot express "I do not care".
|
|
Shell string `json:"shell,omitempty"`
|
|
|
|
// Groups this user must be in. Additive: the host puts the user in these and does not remove
|
|
// it from others, because a machine's own groups are not the mesh's to know about.
|
|
Groups []string `json:"groups,omitempty"`
|
|
|
|
// Home directory. Absent means the system's default for a new user, and is not changed for
|
|
// one that exists — moving somebody's home is not something a declaration should do quietly.
|
|
Home string `json:"home,omitempty"`
|
|
}
|
|
|
|
// Network is a named network on this machine.
|
|
//
|
|
// **A name and nothing else.** Not a driver, a subnet or a gateway: each of those is something a
|
|
// module would have to know about the machine it lands on, and a module naming a subnet is a
|
|
// module that collides with whatever else chose the same one. The runtime picks; the mesh names
|
|
// (novox/hq ADR 0029).
|
|
type Network struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func (n *Network) Identity() string { return n.ID }
|
|
func (n *Network) Kind() Type { return TypeNetwork }
|
|
func (n *Network) Target() string { return n.Name }
|
|
|
|
func (n *Network) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if n.Name == "" {
|
|
problems = append(problems, where+": a network needs a name")
|
|
}
|
|
// The runtimes accept more than this, and the mesh does not: a name with a slash or a colon
|
|
// in it reads as a reference to something else entirely wherever it is later printed.
|
|
for _, r := range n.Name {
|
|
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') &&
|
|
r != '-' && r != '_' && r != '.' {
|
|
problems = append(problems, where+
|
|
": a network name is letters, digits, dashes, underscores and dots, and "+
|
|
n.Name+" is not")
|
|
break
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// Modes an access may be granted at. Plain words, not the octal a directory's mode is: an access
|
|
// is not a thing the host chmods, it is a statement of how this module reaches what the operator
|
|
// owns.
|
|
const (
|
|
AccessRead = "read"
|
|
AccessReadWrite = "read-write"
|
|
)
|
|
|
|
// Access is a pre-existing, operator-owned path this module is granted use of but does not own.
|
|
//
|
|
// **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own —
|
|
// it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A
|
|
// media library, a download spool is the operator's: it existed before the mesh, several modules
|
|
// read and write it at once, and the mesh must not create, chown, reconcile or remove it. The
|
|
// host confirms it is there and mounts it; nothing else.
|
|
type Access struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Path string `json:"path"`
|
|
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
|
|
Mode string `json:"mode,omitempty"`
|
|
}
|
|
|
|
func (a *Access) Identity() string { return a.ID }
|
|
func (a *Access) Kind() Type { return TypeAccess }
|
|
func (a *Access) Target() string { return a.Path }
|
|
|
|
func (a *Access) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if !strings.HasPrefix(a.Path, "/") {
|
|
problems = append(problems, where+": an access needs an absolute path, and "+
|
|
a.Path+" is not one")
|
|
}
|
|
switch a.Mode {
|
|
case "", AccessRead, AccessReadWrite:
|
|
default:
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode))
|
|
}
|
|
return problems
|
|
}
|
|
|
|
func (u *User) Identity() string { return u.ID }
|
|
func (u *User) Kind() Type { return TypeUser }
|
|
func (u *User) Target() string { return u.Name }
|
|
|
|
func (u *User) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if u.Name == "" {
|
|
problems = append(problems, where+": a user needs a name")
|
|
}
|
|
if u.Shell != "" && !strings.HasPrefix(u.Shell, "/") {
|
|
problems = append(problems, where+
|
|
": a login shell is an absolute path, and "+u.Shell+" is not one")
|
|
}
|
|
if u.Home != "" && !strings.HasPrefix(u.Home, "/") {
|
|
problems = append(problems, where+": a home directory is an absolute path")
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// Archive is a set of files, fetched by digest and unpacked.
|
|
//
|
|
// For the case inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of
|
|
// files inlined would make every declaration enormous and rewrite all of it when one changed.
|
|
//
|
|
// **Pinned by digest, and the digest is checked before anything is unpacked.** The same discipline
|
|
// the bootstrap uses for images, and for the same reason — this is fetched over a network the
|
|
// mesh does not control, and a reference that can be made to point elsewhere is not a reference.
|
|
type Archive struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
// Source is where to fetch it from.
|
|
Source string `json:"source"`
|
|
// Digest is sha256 of the archive, as "sha256:<hex>".
|
|
Digest string `json:"digest"`
|
|
// Path is the directory it is unpacked into.
|
|
Path string `json:"path"`
|
|
// Owner is the user the unpacked files belong to. Absent means root.
|
|
Owner string `json:"owner,omitempty"`
|
|
}
|
|
|
|
func (a *Archive) Identity() string { return a.ID }
|
|
func (a *Archive) Kind() Type { return TypeArchive }
|
|
func (a *Archive) Target() string { return a.Path }
|
|
|
|
func (a *Archive) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if a.Source == "" {
|
|
problems = append(problems, where+": an archive needs somewhere to fetch it from")
|
|
}
|
|
if a.Path == "" {
|
|
problems = append(problems, where+": an archive needs somewhere to unpack into")
|
|
}
|
|
if !strings.HasPrefix(a.Digest, "sha256:") || len(a.Digest) != len("sha256:")+64 {
|
|
// Refused rather than fetched and trusted. Everything else pinned in this vocabulary is
|
|
// pinned by digest, and an archive that was not would be the one way in.
|
|
problems = append(problems, where+
|
|
": an archive is pinned by digest, as sha256:<64 hex characters>")
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// Daemon is a long-running process the mesh installs, keeps running, and owns the unit for.
|
|
//
|
|
// The difference from Service is who owns the unit: a Service puts an EXISTING unit into a state
|
|
// and deliberately does not install one, which is right for software that ships its own. A Daemon
|
|
// is the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and
|
|
// nothing else will.
|
|
//
|
|
// The difference from Container is the hosting, and a module should not have to choose: what a
|
|
// daemon says is what to run, and the machine's own process supervisor is how. A module whose code
|
|
// genuinely needs a container's isolation declares a container and says so.
|
|
type Daemon struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
// Name is what the unit is called, and what an operator will see in the process table.
|
|
Name string `json:"name"`
|
|
// Source is where to fetch the bundle from, and Digest is what it must hash to. The same
|
|
// discipline as an archive, for the same reason: this crosses a network the mesh does not
|
|
// control.
|
|
Source string `json:"source"`
|
|
Digest string `json:"digest"`
|
|
// Run is the command, relative to the unpacked bundle. The first element is the program.
|
|
//
|
|
// **Named by the module, never inferred.** Guessing an entrypoint from which files exist makes
|
|
// a daemon change what it runs when somebody adds a file.
|
|
Run []string `json:"run"`
|
|
// Env and EnvFile are what it runs with. A file rather than inline values is how a credential
|
|
// reaches a daemon without passing through the declaration.
|
|
Env map[string]string `json:"env,omitempty"`
|
|
EnvFile []string `json:"env-file,omitempty"`
|
|
// User is who it runs as. Absent means root, which is what the mesh's own modules need for
|
|
// the things they do to a machine.
|
|
User string `json:"user,omitempty"`
|
|
// RestartOn names resources whose change means this must be restarted — the same rule a
|
|
// service follows, and for the same reason: a running process does not re-read its
|
|
// configuration, so replacing a file and finding the process already up leaves the machine
|
|
// behaving the way it did before while every check passes.
|
|
RestartOn []string `json:"restart-on,omitempty"`
|
|
}
|
|
|
|
func (d *Daemon) Identity() string { return d.ID }
|
|
func (d *Daemon) Kind() Type { return TypeDaemon }
|
|
func (d *Daemon) Target() string { return d.Name }
|
|
|
|
func (d *Daemon) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if d.Name == "" {
|
|
problems = append(problems, where+": a daemon needs a name, which is what its unit is called")
|
|
}
|
|
if strings.ContainsAny(d.Name, "/ \t") {
|
|
// It becomes a unit name and a file on disk. A name with a separator in it would write
|
|
// somewhere nobody meant.
|
|
problems = append(problems, where+": a daemon's name becomes a unit name, so it cannot "+
|
|
"contain a path separator or a space")
|
|
}
|
|
if d.Source == "" {
|
|
problems = append(problems, where+": a daemon needs somewhere to fetch its bundle from")
|
|
}
|
|
if !strings.HasPrefix(d.Digest, "sha256:") || len(d.Digest) != len("sha256:")+64 {
|
|
// The same rule an archive follows, and for the same reason: this crosses a network the
|
|
// mesh does not control, and a reference that can be made to point elsewhere is not one.
|
|
problems = append(problems, where+
|
|
": a daemon's bundle is pinned by digest, as sha256:<64 hex characters>")
|
|
}
|
|
if len(d.Run) == 0 {
|
|
problems = append(problems, where+": a daemon needs to say what to run")
|
|
}
|
|
for _, part := range d.Run {
|
|
if part == "" {
|
|
problems = append(problems, where+": a daemon's command has an empty element")
|
|
break
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// Service is a unit the host puts into a state. It does not install the unit.
|
|
//
|
|
// Two states, and they are orthogonal rather than one scale. A unit can be enabled and stopped
|
|
// (it will come back at boot), or disabled and running (started by hand, gone after a reboot).
|
|
// Folding them into one field would make the second expressible only by accident.
|
|
type Service struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Unit string `json:"unit"`
|
|
State string `json:"state"`
|
|
// Boot is "enabled" or "disabled" — whether the unit starts at boot. Optional: absent means
|
|
// the host asserts nothing about it and leaves whatever is there.
|
|
//
|
|
// Without this the host could start a unit and not make it survive a reboot, which is a
|
|
// declaration that reports success and stops being true at the next power cut.
|
|
Boot string `json:"boot,omitempty"`
|
|
|
|
// RestartOn names resources whose change means this service must be restarted.
|
|
//
|
|
// Because a running service does not re-read its configuration. Replace the file, find the
|
|
// service already running, do nothing, and the machine keeps behaving the way it did before —
|
|
// while every check passes, because the file is right and the service is up. That is not
|
|
// hypothetical: it is how a third node joining a mesh left the first two carrying a network
|
|
// that no longer existed, and every part of it reported success.
|
|
//
|
|
// This is declared state rather than a command. The declaration says the running service must
|
|
// reflect these files; the host works out that it does not and acts. A *command* to restart
|
|
// would be an action, and the link may not carry one (novox/hq ADR 0005) — so this is not a
|
|
// way around that rule, it is the shape the rule leaves.
|
|
RestartOn []string `json:"restart-on,omitempty"`
|
|
}
|
|
|
|
func (s *Service) Identity() string { return s.ID }
|
|
func (s *Service) Kind() Type { return TypeService }
|
|
func (s *Service) Target() string { return s.Unit }
|
|
|
|
func (s *Service) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if s.Unit == "" {
|
|
problems = append(problems, where+": a service needs a unit")
|
|
}
|
|
if s.State != "running" && s.State != "stopped" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: state %q; a service is \"running\" or \"stopped\"", where, s.State))
|
|
}
|
|
if s.Boot != "" && s.Boot != "enabled" && s.Boot != "disabled" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: boot %q; a service is \"enabled\" or \"disabled\" at boot, or omits it to "+
|
|
"leave the machine's own setting alone", where, s.Boot))
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// Package is a package that should be present.
|
|
//
|
|
// Present is the whole of what it asserts, never a version: version is the package manager's
|
|
// business and the mesh does not hold a second opinion about it.
|
|
type Package struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Package string `json:"package"`
|
|
}
|
|
|
|
func (p *Package) Identity() string { return p.ID }
|
|
func (p *Package) Kind() Type { return TypePackage }
|
|
func (p *Package) Target() string { return p.Package }
|
|
|
|
func (p *Package) validate(where string, _ bool) []string {
|
|
if p.Package == "" {
|
|
return []string{where + ": a package needs a package name"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Container is a container that should be running, from an image pinned by digest.
|
|
type Container struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Name string `json:"name"`
|
|
// Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not.
|
|
Image string `json:"image"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
// EnvFile names files the runtime reads environment from, in order.
|
|
//
|
|
// **Because a secret may not travel in Env.** A declaration reaches a node over the broker,
|
|
// and `env` is plain text in it — so a password there is a password the broker sees, which is
|
|
// the transitive trust refused everywhere else (novox/hq ADR 0004). A sealed file reaches the
|
|
// machine unreadable, the host writes it, and the runtime reads it: the mesh never holds it
|
|
// and neither does anything between them.
|
|
//
|
|
// It is also simply how third-party software takes credentials. Nothing that ships in a
|
|
// container will read a path the mesh invented; every one of them reads its environment.
|
|
EnvFile []string `json:"env-file,omitempty"`
|
|
Ports []string `json:"ports,omitempty"`
|
|
Volumes []string `json:"volumes,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
// Names this container can reach, as `name:address`.
|
|
//
|
|
// **Because a container does not inherit the machine's names.** It gets its own `/etc/hosts`
|
|
// holding only its own hostname, so every internal name the mesh wrote for this machine is
|
|
// invisible to the thing the machine is running. That was hit for real: a database client on
|
|
// one node could not resolve another node, on a mesh where both names were correct and
|
|
// present on both machines.
|
|
//
|
|
// **A file rather than a resolver, which is the decision the mesh already made about names**
|
|
// and this extends rather than overturns: it works on every runtime, needs no package, and
|
|
// has no failure mode of its own. A resolver becomes necessary when names are wanted that are
|
|
// not one-per-node — service names, wildcards — and that is still not true.
|
|
//
|
|
// Set by the mesh, not by a module: which machines exist is a fact about the mesh, and a
|
|
// module that listed them would be a module that goes stale when one joins.
|
|
Hosts []string `json:"hosts,omitempty"`
|
|
|
|
// Network is the container's network, passed to the runtime unchanged.
|
|
//
|
|
// Needed because the control plane must reach the store and the broker on the machine it was
|
|
// raised on, before there is any mesh to arrange that. The alternative was publishing ports
|
|
// and guessing an address that works from inside a container, which is the same thing with a
|
|
// worse failure mode.
|
|
Network string `json:"network,omitempty"`
|
|
|
|
// RestartOn names resources whose change means this container must be recreated — the same
|
|
// field a service has, for the same reason (novox/hq 04-ISSUES/009). A container reads a
|
|
// mounted file once at start; a changed file leaves the running process holding the old value,
|
|
// while every check passes because the file on disk is right. The container's spec — image,
|
|
// env, volumes — does not include a mounted file's *content*, so a settings change that
|
|
// re-renders that file is invisible to the ordinary spec diff. This closes that: the host
|
|
// recreates the container when one of these resources changed this pass, even if the spec
|
|
// matches.
|
|
RestartOn []string `json:"restart-on,omitempty"`
|
|
|
|
// RunOnce marks a container the host runs to completion rather than leaves running: a step,
|
|
// not a service (novox/hq ADR 0052). The host runs it, requires it to exit 0, and records that
|
|
// it did — and because the declaration is applied in order and a failed step halts the apply,
|
|
// whatever is declared after a run-once container starts only once the step has finished. It is
|
|
// how a module runs its own code at first boot — seed a store, migrate, health-gate — under its
|
|
// own account (ADR 0047), before the container that depends on it. The record that it ran is
|
|
// the digest of this declaration, so a re-apply does not re-run it unless the declaration
|
|
// changed.
|
|
RunOnce bool `json:"run-once,omitempty"`
|
|
|
|
// Schedule marks a container the host runs on a recurring cadence — a five-field cron
|
|
// expression (novox/hq ADR 0053). It is the recurring twin of RunOnce: the same container, run
|
|
// to completion, but again and again on the clock rather than once. Installing it does not run
|
|
// it — the schedule is state that is present, like a running service, so the apply is current as
|
|
// soon as it is recorded and does NOT gate what follows. The host's scheduler fires the
|
|
// container when the cron is due, re-established from this declaration each apply because the
|
|
// declaration is the source of truth (ADR 0018). A run that exits non-zero is recorded and never
|
|
// fails the apply or flips the node's state; a run still going when the next is due is skipped
|
|
// rather than stacked. It is exclusive with RunOnce and with restart-on: a container runs once
|
|
// and gates, runs on a cadence, or stays up — never two of these.
|
|
Schedule string `json:"schedule,omitempty"`
|
|
}
|
|
|
|
func (c *Container) Identity() string { return c.ID }
|
|
func (c *Container) Kind() Type { return TypeContainer }
|
|
func (c *Container) Target() string { return c.Name }
|
|
|
|
func (c *Container) validate(where string, _ bool) []string {
|
|
var problems []string
|
|
if c.Name == "" {
|
|
problems = append(problems, where+": a container needs a name")
|
|
}
|
|
// restart-on brings a *running* container back when a file it read changed; a run-once step
|
|
// does not stay running to be brought back. Declaring both asks for two contradictory
|
|
// lifecycles at once, so it is refused rather than silently resolved to one of them.
|
|
if c.RunOnce && len(c.RestartOn) > 0 {
|
|
problems = append(problems, where+": a run-once container cannot also declare restart-on; "+
|
|
"it runs to completion rather than staying running to be restarted")
|
|
}
|
|
// A container runs once and gates, on a cadence, or stays up — never two of these
|
|
// (novox/hq ADR 0053). run-once and schedule are the two "runs to completion" lifecycles and
|
|
// contradict each other, and a scheduled step does not stay running to be brought back by
|
|
// restart-on either. Refused here on arrival, as the control plane refuses it near its author.
|
|
if c.RunOnce && c.Schedule != "" {
|
|
problems = append(problems, where+": a container is run-once or scheduled, not both; "+
|
|
"run-once runs once and gates what follows, a schedule runs it again on a cadence")
|
|
}
|
|
if c.Schedule != "" && len(c.RestartOn) > 0 {
|
|
problems = append(problems, where+": a scheduled container cannot also declare restart-on; "+
|
|
"it runs to completion on its cadence rather than staying running to be restarted")
|
|
}
|
|
if c.Schedule != "" {
|
|
if _, err := ParseCron(c.Schedule); err != nil {
|
|
problems = append(problems, where+": "+err.Error())
|
|
}
|
|
}
|
|
return append(problems, checkImage(where, c.Image)...)
|
|
}
|
|
|
|
// Action runs something the bundle declared, and the host never learns what it means.
|
|
type Action struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
Command []string `json:"command"`
|
|
// Verify is not optional and is not a courtesy. It is the read-back AND the idempotency
|
|
// check: the host does not know what a database is, so "is it already there" is a question
|
|
// only the declaration can ask (novox/hq ADR 0005).
|
|
Verify []string `json:"verify"`
|
|
// In names a container to run inside. Empty means the machine itself.
|
|
In string `json:"in,omitempty"`
|
|
}
|
|
|
|
func (a *Action) Identity() string { return a.ID }
|
|
func (a *Action) Kind() Type { return TypeAction }
|
|
|
|
func (a *Action) Target() string {
|
|
target := strings.Join(a.Command, " ")
|
|
if a.In != "" {
|
|
return "in " + a.In + ": " + target
|
|
}
|
|
return target
|
|
}
|
|
|
|
func (a *Action) validate(where string, allowActions bool) []string {
|
|
// The bound the whole security argument rests on (novox/hq ADR 0005).
|
|
if !allowActions {
|
|
return []string{where +
|
|
": an action arrived over the link, and the link may not carry one. The host " +
|
|
"applies declarations of known shape; a command to run is not one. A bundle may " +
|
|
"carry an action because it arrives with the binary — anyone able to put a " +
|
|
"hostile action there could have put it in the host itself"}
|
|
}
|
|
var problems []string
|
|
if len(a.Command) == 0 {
|
|
problems = append(problems, where+": an action needs a command")
|
|
}
|
|
if len(a.Verify) == 0 {
|
|
problems = append(problems, where+
|
|
": an action needs a verify. An action that runs and reports success without "+
|
|
"reading anything back is the fault this host exists to prevent, and verify is "+
|
|
"also how the host knows whether the action is already done")
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// newOf returns an empty resource of a kind, or nil if the kind is unknown.
|
|
//
|
|
// This is the whole vocabulary, in one place. A kind that is not here cannot be declared.
|
|
func newOf(t Type) Resource {
|
|
switch t {
|
|
case TypeDirectory:
|
|
return &Directory{}
|
|
case TypeFile:
|
|
return &File{}
|
|
case TypeService:
|
|
return &Service{}
|
|
case TypePackage:
|
|
return &Package{}
|
|
case TypeContainer:
|
|
return &Container{}
|
|
case TypeAction:
|
|
return &Action{}
|
|
case TypeNetwork:
|
|
return &Network{}
|
|
case TypeUser:
|
|
return &User{}
|
|
case TypeArchive:
|
|
return &Archive{}
|
|
case TypeAccess:
|
|
return &Access{}
|
|
case TypeDaemon:
|
|
return &Daemon{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Vocabulary is every kind this host speaks.
|
|
func Vocabulary() []Type {
|
|
return []Type{
|
|
TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDaemon, TypeDirectory, TypeFile,
|
|
TypeNetwork, TypePackage, TypeService, TypeUser,
|
|
}
|
|
}
|
|
|
|
// Declaration is what a machine should be, in the order it should be made so.
|
|
type Declaration struct {
|
|
Version int
|
|
// For names the node this is meant for. A host with an identity refuses one addressed
|
|
// elsewhere; a host without one — the first node, applying the bundle it carries — has
|
|
// nothing to check against.
|
|
For string
|
|
// Resources, in the order they are applied. The host does not sort them: ordering is a
|
|
// decision, and deciding is not what the host does (novox/hq ADR 0005).
|
|
Resources []Resource
|
|
}
|
|
|
|
// RefusalError refuses a whole declaration, naming every problem at once.
|
|
//
|
|
// Every problem rather than the first: a caller fixing one at a time learns the next only by
|
|
// running again, and a declaration is generated, so a person reading this is debugging the
|
|
// generator.
|
|
type RefusalError struct {
|
|
Problems []string
|
|
}
|
|
|
|
func (e *RefusalError) Error() string {
|
|
return fmt.Sprintf(
|
|
"this declaration is refused, and none of it was applied:\n - %s\n\n"+
|
|
"A host that applied the parts it understood would leave a machine that looks "+
|
|
"configured and is not.",
|
|
strings.Join(e.Problems, "\n - "))
|
|
}
|
|
|
|
// Parse reads a declaration that arrived over the link, and refuses anything it does not fully
|
|
// understand — including any action, which the link may not carry (novox/hq ADR 0005).
|
|
func Parse(raw []byte) (*Declaration, error) { return parse(raw, false) }
|
|
|
|
// ParseTrusted reads a declaration from a source already as privileged as the host itself: the
|
|
// bundle it carries, or a file handed to it by someone who is running it as root.
|
|
//
|
|
// Actions are permitted here and nowhere else. The asymmetry is deliberate and is the entire
|
|
// content of ADR 0005: refusing actions from the bundle buys nothing, because whoever built the
|
|
// bundle built the binary; refusing them from the link buys the bound on what a compromised
|
|
// control plane can express.
|
|
func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) }
|
|
|
|
// envelope is the declaration with its resources still unread.
|
|
//
|
|
// Two passes, because which fields are legal depends on the "type" inside each resource. The
|
|
// first pass takes the envelope and each resource's bytes; the second decodes each one into
|
|
// the struct for its kind, strictly.
|
|
type envelope struct {
|
|
Version int `json:"declaration"`
|
|
For string `json:"for,omitempty"`
|
|
Resources []json.RawMessage `json:"resources"`
|
|
}
|
|
|
|
func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
|
var env envelope
|
|
if err := strictDecode(raw, &env); err != nil {
|
|
return nil, &RefusalError{Problems: []string{"not a declaration: " + err.Error()}}
|
|
}
|
|
|
|
if env.Version != Version {
|
|
// Everything below assumes the vocabulary, so there is nothing further to say.
|
|
return nil, &RefusalError{Problems: []string{fmt.Sprintf(
|
|
"declaration version %d; this host speaks version %d. Refused whole rather than "+
|
|
"partly, so a newer vocabulary is never half-applied by an older host",
|
|
env.Version, Version)}}
|
|
}
|
|
|
|
d := &Declaration{Version: env.Version, For: env.For}
|
|
var problems []string
|
|
|
|
if len(env.Resources) == 0 {
|
|
problems = append(problems, "no resources. An empty declaration is a mistake, not a "+
|
|
"machine with nothing on it — say so with an explicit empty list if that is meant")
|
|
}
|
|
|
|
seen := map[string]int{}
|
|
for i, rawResource := range env.Resources {
|
|
// Peek, leniently. This pass only needs to know which struct to decode into; reading
|
|
// strictly here would report an unknown field before knowing which fields are known.
|
|
var head struct {
|
|
ID string `json:"id"`
|
|
Type Type `json:"type"`
|
|
}
|
|
_ = json.Unmarshal(rawResource, &head)
|
|
|
|
where := fmt.Sprintf("resource %d", i)
|
|
if head.ID != "" {
|
|
where = fmt.Sprintf("resource %q", head.ID)
|
|
}
|
|
|
|
if head.ID == "" {
|
|
problems = append(problems, where+": no id. Identity is what lets the host know "+
|
|
"this is the same resource it applied last time")
|
|
} else if first, ok := seen[head.ID]; ok {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: id already used by resource %d. Two resources with one identity cannot "+
|
|
"both be tracked", where, first))
|
|
} else {
|
|
seen[head.ID] = i
|
|
}
|
|
|
|
resource := newOf(head.Type)
|
|
if resource == nil {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: unknown type %q. This host understands %s", where, head.Type, vocabulary()))
|
|
continue
|
|
}
|
|
|
|
// A field the kind does not have is refused, and the struct is what says so — there
|
|
// is no list of exclusions for anyone to keep current.
|
|
//
|
|
// Asked separately rather than taken from the decoder's error, because the decoder
|
|
// stops at the first unknown field and this record promises every problem at once. A
|
|
// caller fixing one field at a time learns the next only by running again.
|
|
if unknown := unknownFields(rawResource, resource); len(unknown) > 0 {
|
|
for _, field := range unknown {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s: a %s does not use %q, and it is set. Refused rather than ignored",
|
|
where, head.Type, field))
|
|
}
|
|
continue
|
|
}
|
|
if err := json.Unmarshal(rawResource, resource); err != nil {
|
|
problems = append(problems, fmt.Sprintf("%s: %s", where, err))
|
|
continue
|
|
}
|
|
|
|
problems = append(problems, resource.validate(where, allowActions)...)
|
|
d.Resources = append(d.Resources, resource)
|
|
}
|
|
|
|
if len(problems) > 0 {
|
|
return nil, &RefusalError{Problems: problems}
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func strictDecode(raw []byte, into any) error {
|
|
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
|
|
// field the host does not know is a thing the control plane believes it asked for.
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(into); err != nil {
|
|
return err
|
|
}
|
|
// And **nothing after it**. A decoder reads one value and stops, so a file holding a
|
|
// declaration followed by anything at all — a truncated rewrite, two declarations
|
|
// concatenated, a stray line from whatever wrote the file — parses as the first value and the
|
|
// rest is never looked at.
|
|
//
|
|
// That is the same fault this host refuses everywhere else, in its quietest form: the machine
|
|
// applies something, reports success, and what it applied is not what the file says. Found
|
|
// when a test harness appended a line to a bundle by accident and every apply kept working.
|
|
if _, err := dec.Token(); err != io.EOF {
|
|
return fmt.Errorf(
|
|
"there is more in this file after the declaration ends. Refused whole: a file with " +
|
|
"something after it may be a truncated rewrite or two declarations run together, " +
|
|
"and applying the first would be applying something nobody wrote")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unknownFields names every JSON key the kind's struct has no field for.
|
|
//
|
|
// The struct's own tags are the list of what is legal, so adding a field to a kind is the
|
|
// whole of adding it — there is nowhere else that has to agree.
|
|
func unknownFields(raw []byte, into Resource) []string {
|
|
var got map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &got); err != nil {
|
|
return nil // not an object; the decode below will say so properly
|
|
}
|
|
|
|
known := map[string]bool{}
|
|
t := reflect.TypeOf(into).Elem()
|
|
for i := 0; i < t.NumField(); i++ {
|
|
name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
|
|
if name != "" && name != "-" {
|
|
known[name] = true
|
|
}
|
|
}
|
|
|
|
var unknown []string
|
|
for field := range got {
|
|
if !known[field] {
|
|
unknown = append(unknown, field)
|
|
}
|
|
}
|
|
sort.Strings(unknown)
|
|
return unknown
|
|
}
|
|
|
|
func checkMode(where, mode string) []string {
|
|
if mode == "" {
|
|
return nil
|
|
}
|
|
if len(mode) != 4 || mode[0] != '0' {
|
|
return []string{fmt.Sprintf(
|
|
"%s: mode %q; write it as four octal digits such as \"0644\", so it means the "+
|
|
"same thing here as it does in the manifest it came from", where, mode)}
|
|
}
|
|
for _, c := range mode[1:] {
|
|
if c < '0' || c > '7' {
|
|
return []string{fmt.Sprintf("%s: mode %q is not octal", where, mode)}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkImage insists on content, not on a name.
|
|
//
|
|
// A tag moves and a digest does not. The bundle's whole claim is that what it names is exact
|
|
// (novox/hq ADR 0006), and a bundle pinning `postgres:17` pins nothing — it names whatever
|
|
// that tag points at on the day the host happens to run.
|
|
//
|
|
// **Two forms say something exact, and only one of them needs a registry.** `name@sha256:…` is a
|
|
// manifest digest, which a registry assigns on push. A bare `sha256:…` is an image the machine
|
|
// already holds, addressed by the digest of its own configuration — equally immutable, equally
|
|
// unforgeable, and requiring nothing to have served it.
|
|
//
|
|
// That second form is what a first machine needs. The mesh's own control plane exists in no public
|
|
// registry and never will: it is built from source, and until this mesh has a registry of its own
|
|
// there is nowhere to push it to and therefore no manifest digest to name it by. Insisting on one
|
|
// would mean a registry has to exist before the thing that lets a mesh have a registry can start —
|
|
// which is not a pin, it is a dependency the rule accidentally created. A machine that built an
|
|
// image, or was handed one, can name it by what it is.
|
|
func checkImage(where, image string) []string {
|
|
if image == "" {
|
|
return []string{where + ": a container needs an image"}
|
|
}
|
|
// An image this machine holds, named by the digest of its own configuration.
|
|
if strings.HasPrefix(image, "sha256:") {
|
|
if len(image) != len("sha256:")+64 {
|
|
return []string{fmt.Sprintf(
|
|
"%s: image id %q is not a sha256 digest", where, image)}
|
|
}
|
|
return nil
|
|
}
|
|
name, digest, found := strings.Cut(image, "@")
|
|
if !found || name == "" {
|
|
return []string{fmt.Sprintf(
|
|
"%s: image %q is not pinned. Write it as name@sha256:… — or as sha256:… for an image "+
|
|
"this machine already holds. A tag moves, and a bundle that pinned a tag would "+
|
|
"not be pinned", where, image)}
|
|
}
|
|
if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 {
|
|
return []string{fmt.Sprintf(
|
|
"%s: image digest %q is not a sha256 digest", where, digest)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func vocabulary() string {
|
|
kinds := Vocabulary()
|
|
names := make([]string, 0, len(kinds))
|
|
for _, t := range kinds {
|
|
names = append(names, string(t))
|
|
}
|
|
sort.Strings(names)
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// ParseFileTrusted reads a declaration from a file somebody handed this host.
|
|
//
|
|
// The same as ParseTrusted, and it allows whole-line `//` comments first. A pinned, hand-authored
|
|
// artefact that nobody can annotate is one nobody can review — the substrate bundle is mostly
|
|
// explanation of why each digest is what it is.
|
|
//
|
|
// **Only for a file, never for the link.** Over the link the format stays exactly JSON, because
|
|
// a wire format with a second thing to strip is a wire format with a second thing to disagree
|
|
// about.
|
|
//
|
|
// It exists because there were two readers for one file: the bundle stripped comments and `apply`
|
|
// did not, so the example bundle in this repository could be built into a binary and not applied
|
|
// from disk. The failure was `invalid character '/'`, which names the symptom and not the cause.
|
|
func ParseFileTrusted(raw []byte) (*Declaration, error) {
|
|
return ParseTrusted(stripComments(raw))
|
|
}
|
|
|
|
// stripComments removes whole lines beginning with `//`.
|
|
//
|
|
// Only whole lines: anything cleverer would need to know where strings begin and end, and a
|
|
// parser that half-understands its input is worse than one that does not try. A `//` inside a
|
|
// value — every image reference has one — is untouched.
|
|
func stripComments(raw []byte) []byte {
|
|
var kept []string
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "//") {
|
|
continue
|
|
}
|
|
kept = append(kept, line)
|
|
}
|
|
return []byte(strings.Join(kept, "\n"))
|
|
}
|