Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e135dfc9 | ||
|
|
f92dd3e286 | ||
|
|
cdbe3ab0a4 | ||
|
|
a8f1cdb445 | ||
|
|
ecb3003ba4 | ||
|
|
d3861f82d4 | ||
|
|
b6dbe0a7b9 | ||
|
|
07bdad9e94 |
@@ -1583,6 +1583,11 @@ func containerSpecReading(r *declaration.Container, declares, reads map[string]s
|
|||||||
for _, n := range r.Networks {
|
for _, n := range r.Networks {
|
||||||
b.WriteString("also-on " + n + "\n")
|
b.WriteString("also-on " + n + "\n")
|
||||||
}
|
}
|
||||||
|
// And the capabilities it was granted (ADR 0170): one gained or dropped is a different
|
||||||
|
// container, and the runtime cannot change a running one's.
|
||||||
|
for _, c := range r.Capabilities {
|
||||||
|
b.WriteString("cap " + c + "\n")
|
||||||
|
}
|
||||||
// The cadence is part of what was declared, so a changed schedule is a changed spec — the marker
|
// The cadence is part of what was declared, so a changed schedule is a changed spec — the marker
|
||||||
// moves and the install is reported "updated" and re-established. Added only when present, so no
|
// moves and the install is reported "updated" and re-established. Added only when present, so no
|
||||||
// ordinary container's or run-once step's digest moves for a field it does not set.
|
// ordinary container's or run-once step's digest moves for a field it does not set.
|
||||||
@@ -1777,6 +1782,9 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
|
|||||||
if r.Network != "" {
|
if r.Network != "" {
|
||||||
args = append(args, "--network", r.Network)
|
args = append(args, "--network", r.Network)
|
||||||
}
|
}
|
||||||
|
for _, c := range r.Capabilities {
|
||||||
|
args = append(args, "--cap-add", c)
|
||||||
|
}
|
||||||
for _, d := range r.Dns {
|
for _, d := range r.Dns {
|
||||||
args = append(args, "--dns", d)
|
args = append(args, "--dns", d)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,3 +134,42 @@ func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
|
|||||||
t.Fatalf("keeping the left-out module's container was not said: %+v", report.Outcomes)
|
t.Fatalf("keeping the left-out module's container was not said: %+v", report.Outcomes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A container's capabilities reach the runtime and are part of its spec (novox/hq ADR 0170).
|
||||||
|
func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
|
||||||
|
var ran []string
|
||||||
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
if name != "docker" {
|
||||||
|
return "", errors.New("not installed")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "info":
|
||||||
|
return "29.0.0\n", nil
|
||||||
|
case "container":
|
||||||
|
return "false\t\n", errors.New("no such container")
|
||||||
|
case "run":
|
||||||
|
ran = args
|
||||||
|
return "deadbeef\n", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||||
|
{"id":"fw","type":"container","name":"fw","image":"`+pinned+`","network":"host","capabilities":["NET_ADMIN"]}
|
||||||
|
]}`)
|
||||||
|
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||||
|
granted := false
|
||||||
|
for i, a := range ran {
|
||||||
|
if a == "--cap-add" && i+1 < len(ran) && ran[i+1] == "NET_ADMIN" {
|
||||||
|
granted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !granted {
|
||||||
|
t.Fatalf("the capability was not granted: %v", ran)
|
||||||
|
}
|
||||||
|
with := d.Resources[0].(*declaration.Container)
|
||||||
|
without := *with
|
||||||
|
without.Capabilities = nil
|
||||||
|
if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) {
|
||||||
|
t.Fatal("a capability is not part of the container's spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/declaration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189,
|
||||||
|
// issue 108).
|
||||||
|
//
|
||||||
|
// What it exists for: the artifact store's collector walks the storage and requires every writer
|
||||||
|
// stopped. A run-once step runs beside containers and a scheduled one is the same container again,
|
||||||
|
// so the mesh had no way to say it — which is why the store it inherited has never collected
|
||||||
|
// anything. The risk the field brings is one shape only: a window that opens and never closes.
|
||||||
|
// Every test here is about that shape.
|
||||||
|
|
||||||
|
// windowRun records the order of stop / run / start, which is the whole of what is being asserted.
|
||||||
|
type windowRun struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
order []string
|
||||||
|
failAt string // the arg[0] that should fail ("run" makes the step fail)
|
||||||
|
wontGo string // a container name that refuses to start again
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowRun) run(_ context.Context, _ string, args ...string) (string, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
switch args[0] {
|
||||||
|
case "info":
|
||||||
|
return "27.0\n", nil
|
||||||
|
case "stop", "start":
|
||||||
|
w.order = append(w.order, args[0]+" "+args[1])
|
||||||
|
if args[0] == "start" && args[1] == w.wontGo {
|
||||||
|
return "", errors.New("the runtime refused")
|
||||||
|
}
|
||||||
|
case "run":
|
||||||
|
w.order = append(w.order, "run")
|
||||||
|
if w.failAt == "run" {
|
||||||
|
return "", errors.New("the step exited non-zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowRun) seen() []string {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return append([]string{}, w.order...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aStoreWithACollector is a module in the shape distribution has: a server that must not be
|
||||||
|
// writing, and a nightly step that walks its storage with the server held still.
|
||||||
|
func aStoreWithACollector(t *testing.T) *declaration.Declaration {
|
||||||
|
t.Helper()
|
||||||
|
return parseTrusted(t, `{"declaration":1,"resources":[
|
||||||
|
{"id":"store","type":"container","name":"mesh-registry","image":"`+pinned+`"},
|
||||||
|
{"id":"collect","type":"container","name":"mesh-registry-collect","image":"`+pinned+`",
|
||||||
|
"schedule":"30 3 * * *","while-stopped":["store"]}
|
||||||
|
]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fireOnce(t *testing.T, d *declaration.Declaration, w *windowRun) {
|
||||||
|
t.Helper()
|
||||||
|
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
|
||||||
|
s := NewScheduler(clock, w.run, func(string) {})
|
||||||
|
s.Sync(d, nil)
|
||||||
|
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
|
||||||
|
s.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAScheduledStepHoldsItsModulesContainerStillAndStartsItAgain(t *testing.T) {
|
||||||
|
w := &windowRun{}
|
||||||
|
fireOnce(t, aStoreWithACollector(t), w)
|
||||||
|
|
||||||
|
got := w.seen()
|
||||||
|
want := []string{"stop mesh-registry", "run", "start mesh-registry"}
|
||||||
|
var kept []string
|
||||||
|
for _, line := range got {
|
||||||
|
if strings.HasPrefix(line, "stop mesh-registry-collect") {
|
||||||
|
// Clearing the step's own exited container by name; not part of the window.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
if len(kept) != len(want) {
|
||||||
|
t.Fatalf("the window was not stop, run, start: %v", got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if kept[i] != want[i] {
|
||||||
|
t.Fatalf("the window was %v, want %v", kept, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one that matters: a step that fails must leave the service running.
|
||||||
|
func TestAFailedStepStillClosesTheWindow(t *testing.T) {
|
||||||
|
w := &windowRun{failAt: "run"}
|
||||||
|
fireOnce(t, aStoreWithACollector(t), w)
|
||||||
|
|
||||||
|
var started bool
|
||||||
|
for _, line := range w.seen() {
|
||||||
|
if line == "start mesh-registry" {
|
||||||
|
started = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !started {
|
||||||
|
t.Fatalf("the step failed and the container it held still was never started again: %v", w.seen())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A container that will not come back is said loudly: it is down, and nothing else notices until
|
||||||
|
// the next apply compares it.
|
||||||
|
func TestAContainerThatWillNotStartAgainIsSaidLoudly(t *testing.T) {
|
||||||
|
w := &windowRun{wontGo: "mesh-registry"}
|
||||||
|
var said []string
|
||||||
|
clock := &fixedClock{now: time.Date(2026, 10, 2, 3, 29, 0, 0, time.UTC)}
|
||||||
|
s := NewScheduler(clock, w.run, func(line string) { said = append(said, line) })
|
||||||
|
s.Sync(aStoreWithACollector(t), nil)
|
||||||
|
s.Advance(context.Background(), time.Date(2026, 10, 2, 3, 30, 5, 0, time.UTC))
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
var loud bool
|
||||||
|
for _, line := range said {
|
||||||
|
if strings.Contains(line, "WILL NOT START AGAIN") && strings.Contains(line, "mesh-registry") {
|
||||||
|
loud = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !loud {
|
||||||
|
t.Fatalf("a service left stopped by a maintenance window was not said loudly: %v", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several containers come back in the reverse of the order they were stopped: a module names the
|
||||||
|
// dependant first, and starting it before what it depends on is not bringing it back.
|
||||||
|
func TestTheWindowClosesInTheReverseOfTheOrderItOpened(t *testing.T) {
|
||||||
|
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||||
|
{"id":"web","type":"container","name":"web","image":"`+pinned+`"},
|
||||||
|
{"id":"db","type":"container","name":"db","image":"`+pinned+`"},
|
||||||
|
{"id":"collect","type":"container","name":"collect","image":"`+pinned+`",
|
||||||
|
"schedule":"30 3 * * *","while-stopped":["web","db"]}
|
||||||
|
]}`)
|
||||||
|
w := &windowRun{}
|
||||||
|
fireOnce(t, d, w)
|
||||||
|
|
||||||
|
var stops, starts []string
|
||||||
|
for _, line := range w.seen() {
|
||||||
|
switch {
|
||||||
|
case line == "stop web" || line == "stop db":
|
||||||
|
stops = append(stops, line)
|
||||||
|
case strings.HasPrefix(line, "start "):
|
||||||
|
starts = append(starts, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stops) != 2 || stops[0] != "stop web" || stops[1] != "stop db" {
|
||||||
|
t.Fatalf("stopped in %v, want the order the step named them", stops)
|
||||||
|
}
|
||||||
|
if len(starts) != 2 || starts[0] != "start db" || starts[1] != "start web" {
|
||||||
|
t.Fatalf("started in %v, want the reverse", starts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the refusals, each for what it says rather than that it says something.
|
||||||
|
func TestAMaintenanceWindowIsRefusedWhereItCannotMean(t *testing.T) {
|
||||||
|
for _, c := range []struct{ name, body, says string }{
|
||||||
|
{
|
||||||
|
"a window with no schedule",
|
||||||
|
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","while-stopped":["store"]}`,
|
||||||
|
"needs a schedule",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a window naming itself",
|
||||||
|
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","schedule":"30 3 * * *","while-stopped":["collect"]}`,
|
||||||
|
"this step itself",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a window naming something that is not a container here",
|
||||||
|
`{"id":"collect","type":"container","name":"c","image":"` + pinned + `","schedule":"30 3 * * *","while-stopped":["elsewhere"]}`,
|
||||||
|
"no container by that id",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
_, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[` + c.body + `]}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s was accepted", c.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("%s: the refusal does not say %q: %v", c.name, c.says, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,29 @@ type scheduledJob struct {
|
|||||||
container *declaration.Container
|
container *declaration.Container
|
||||||
next time.Time // the next minute at which it is due
|
next time.Time // the next minute at which it is due
|
||||||
running bool // a run is in flight — the next due run is skipped rather than stacked
|
running bool // a run is in flight — the next due run is skipped rather than stacked
|
||||||
|
// hold is the runtime names of the containers held still for the duration of a run, in the
|
||||||
|
// order the step named them (novox/hq ADR 0189).
|
||||||
|
hold []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldStillFor is the runtime names of the containers a step holds still, resolved from ids.
|
||||||
|
func heldStillFor(step *declaration.Container, d *declaration.Declaration) []string {
|
||||||
|
if len(step.WhileStopped) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
byID := map[string]string{}
|
||||||
|
for _, r := range d.Resources {
|
||||||
|
if c, ok := r.(*declaration.Container); ok {
|
||||||
|
byID[c.Identity()] = c.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(step.WhileStopped))
|
||||||
|
for _, id := range step.WhileStopped {
|
||||||
|
if name := byID[id]; name != "" {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScheduler builds a scheduler. A nil clock is the system clock; a nil log says nothing.
|
// NewScheduler builds a scheduler. A nil clock is the system clock; a nil log says nothing.
|
||||||
@@ -123,15 +146,20 @@ func (s *Scheduler) Sync(d *declaration.Declaration, held map[string]bool) {
|
|||||||
// on its cadence rather than staying running to be restarted — so its identity cannot
|
// on its cadence rather than staying running to be restarted — so its identity cannot
|
||||||
// depend on another resource's content and there is nothing to pass.
|
// depend on another resource's content and there is nothing to pass.
|
||||||
spec := containerSpec(c, inputs{})
|
spec := containerSpec(c, inputs{})
|
||||||
|
// The runtime stops containers by name; the declaration names them by id. Resolved here,
|
||||||
|
// against the declaration this job was armed from, so a fire never has to look anything up
|
||||||
|
// (novox/hq ADR 0189). The parser has already refused an id that is not a container here.
|
||||||
|
hold := heldStillFor(c, d)
|
||||||
if existing := s.jobs[c.Identity()]; existing != nil && existing.spec == spec {
|
if existing := s.jobs[c.Identity()]; existing != nil && existing.spec == spec {
|
||||||
// Unchanged: keep where it is in its cadence, refresh the declaration pointer only.
|
// Unchanged: keep where it is in its cadence, refresh the declaration pointer only.
|
||||||
existing.container = c
|
existing.container = c
|
||||||
|
existing.hold = hold
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// New or changed: arm it for the next due minute after now.
|
// New or changed: arm it for the next due minute after now.
|
||||||
next, _ := cron.Next(s.clock.Now())
|
next, _ := cron.Next(s.clock.Now())
|
||||||
s.jobs[c.Identity()] = &scheduledJob{
|
s.jobs[c.Identity()] = &scheduledJob{
|
||||||
id: c.Identity(), spec: spec, cron: cron, container: c, next: next,
|
id: c.Identity(), spec: spec, cron: cron, container: c, next: next, hold: hold,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +230,15 @@ func (s *Scheduler) fire(ctx context.Context, j *scheduledJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The window opens here and closes in the defer, whatever happens** (novox/hq ADR 0189).
|
||||||
|
// Deferred before the first stop so a panic, a failing step or a step that runs long all end
|
||||||
|
// the same way: the service running. The one real risk of this field is a window that never
|
||||||
|
// closes, and the only defence against it is that closing is not conditional on anything.
|
||||||
|
if len(j.hold) > 0 {
|
||||||
|
defer s.letRun(ctx, cri, j)
|
||||||
|
s.holdStill(ctx, cri, j)
|
||||||
|
}
|
||||||
|
|
||||||
// A container by this name left exited by the previous run would collide with --name. Removing
|
// A container by this name left exited by the previous run would collide with --name. Removing
|
||||||
// one that is not there is the state we want, so its error is ignored — the same as run-once.
|
// one that is not there is the state we want, so its error is ignored — the same as run-once.
|
||||||
_, _ = s.run(ctx, cri, "rm", "-f", j.container.Name)
|
_, _ = s.run(ctx, cri, "rm", "-f", j.container.Name)
|
||||||
@@ -260,3 +297,51 @@ func (s *Scheduler) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// holdStill stops the containers this step runs instead of, in the order it named them.
|
||||||
|
//
|
||||||
|
// A stop that fails is said and not fatal. The step runs anyway: for the case this exists for —
|
||||||
|
// a collector walking storage nothing must be writing to — a writer that would not stop is worth
|
||||||
|
// knowing about, and refusing to run would mean the work never happens and the log says nothing
|
||||||
|
// new each night. What must not be skipped is the restart, and it is not: it is deferred.
|
||||||
|
func (s *Scheduler) holdStill(ctx context.Context, cri string, j *scheduledJob) {
|
||||||
|
for _, name := range j.hold {
|
||||||
|
if _, err := s.run(ctx, cri, "stop", name); err != nil {
|
||||||
|
s.log(fmt.Sprintf("scheduled step %s: could not stop %s for the run: %v", j.id, name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.log(fmt.Sprintf("scheduled step %s: %s held still for the run", j.id, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// letRun starts them again, in the reverse of the order they were stopped, and says so loudly if
|
||||||
|
// one does not come back.
|
||||||
|
//
|
||||||
|
// **Reverse order**, because stopping walks a dependency the other way: a module that holds two
|
||||||
|
// containers still names the one that depends on the other first, and bringing them back the same
|
||||||
|
// way would start a dependant before what it depends on.
|
||||||
|
//
|
||||||
|
// Given its own context, because this runs in a defer and the one the run used may already be
|
||||||
|
// cancelled — a host shutting down mid-window would otherwise leave the service stopped, which is
|
||||||
|
// precisely the outcome this field must never have.
|
||||||
|
func (s *Scheduler) letRun(_ context.Context, cri string, j *scheduledJob) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), closingWindow)
|
||||||
|
defer cancel()
|
||||||
|
for i := len(j.hold) - 1; i >= 0; i-- {
|
||||||
|
name := j.hold[i]
|
||||||
|
if _, err := s.run(ctx, cri, "start", name); err != nil {
|
||||||
|
// Said as loudly as this host says anything: a service the mesh stopped for a
|
||||||
|
// maintenance window and could not start again is down, and nothing else will notice
|
||||||
|
// until the next apply compares it.
|
||||||
|
s.log(fmt.Sprintf(
|
||||||
|
"scheduled step %s: %s was held still for the run and WILL NOT START AGAIN: %v",
|
||||||
|
j.id, name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.log(fmt.Sprintf("scheduled step %s: %s running again", j.id, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closingWindow is how long the host will spend putting back what it stopped. Generous: this is
|
||||||
|
// the half that must not be given up on.
|
||||||
|
const closingWindow = 5 * time.Minute
|
||||||
|
|||||||
@@ -940,6 +940,12 @@ type Container struct {
|
|||||||
// its siblings can name before any of them can resolve anything.
|
// its siblings can name before any of them can resolve anything.
|
||||||
Dns []string `json:"dns,omitempty"`
|
Dns []string `json:"dns,omitempty"`
|
||||||
|
|
||||||
|
// Capabilities are the Linux capabilities this container is granted beyond the runtime's
|
||||||
|
// default set, by name (novox/hq ADR 0170): a holder's runtime that changes the machine's packet
|
||||||
|
// filter asks for NET_ADMIN. Exactly these, named in the spec so a change recreates the
|
||||||
|
// container; a privileged container stays undeclarable.
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
|
||||||
// Networks are networks this container also joins once created, by name — a found network a
|
// Networks are networks this container also joins once created, by name — a found network a
|
||||||
// per-machine setting keeps for a taken container (novox/hq ADR 0163, rule 4), so a
|
// per-machine setting keeps for a taken container (novox/hq ADR 0163, rule 4), so a
|
||||||
// neighbour that resolves it there keeps resolving it until the neighbour is taken too.
|
// neighbour that resolves it there keeps resolving it until the neighbour is taken too.
|
||||||
@@ -991,6 +997,24 @@ type Container struct {
|
|||||||
// rather than stacked. It is exclusive with RunOnce and with restart-on: a container runs once
|
// 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.
|
// and gates, runs on a cadence, or stays up — never two of these.
|
||||||
Schedule string `json:"schedule,omitempty"`
|
Schedule string `json:"schedule,omitempty"`
|
||||||
|
|
||||||
|
// WhileStopped names resources of the same module — containers — that must be held still for
|
||||||
|
// the duration of this step's run (novox/hq ADR 0189). The host stops each before the run and
|
||||||
|
// starts each again after it, **whatever the step did**: a step that failed must leave the
|
||||||
|
// service running, because the one real risk of this field is a window that never closes.
|
||||||
|
//
|
||||||
|
// **For the work a service cannot have done underneath it.** The artifact store's collector
|
||||||
|
// walks the storage and requires every writer stopped; a run-once step runs beside containers
|
||||||
|
// and a scheduled one is the same container again, so until this there was no way for a module
|
||||||
|
// to say it. The predecessor said it with a shell script, which is how the mesh inherited a
|
||||||
|
// store that has never collected anything.
|
||||||
|
//
|
||||||
|
// **Its own module's containers, and only on a schedule.** A module that could quiesce a
|
||||||
|
// neighbour could stop the mesh. And at apply time the host already has a window — the
|
||||||
|
// declaration is applied in order and a run-once step gates what follows — so a one-time
|
||||||
|
// offline job says *before*, not *instead of*; a recurring window is the case order cannot
|
||||||
|
// express, and the only one this serves.
|
||||||
|
WhileStopped []string `json:"while-stopped,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) Identity() string { return c.ID }
|
func (c *Container) Identity() string { return c.ID }
|
||||||
@@ -1022,6 +1046,20 @@ func (c *Container) validate(where string, _ bool) []string {
|
|||||||
problems = append(problems, where+": "+err.Error())
|
problems = append(problems, where+": "+err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A maintenance window belongs to a recurring step (novox/hq ADR 0189). Refused on anything
|
||||||
|
// else here, where the field is; that it names containers of the same module, and not itself,
|
||||||
|
// is judged against the whole declaration (see whileStoppedNames).
|
||||||
|
if len(c.WhileStopped) > 0 && c.Schedule == "" {
|
||||||
|
problems = append(problems, where+": while-stopped needs a schedule; at apply the host "+
|
||||||
|
"already has a window — the declaration is applied in order and a run-once step gates "+
|
||||||
|
"what follows — so a one-time offline job is declared before what it works on")
|
||||||
|
}
|
||||||
|
for _, id := range c.WhileStopped {
|
||||||
|
if id == c.ID {
|
||||||
|
problems = append(problems, where+": while-stopped names "+strconv.Quote(id)+
|
||||||
|
", which is this step itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
// The runtime's flags take addresses, and a name here would be handed to it verbatim and
|
// The runtime's flags take addresses, and a name here would be handed to it verbatim and
|
||||||
// refused at create — after the old container was already removed. Refused on arrival instead.
|
// refused at create — after the old container was already removed. Refused on arrival instead.
|
||||||
for _, d := range c.Dns {
|
for _, d := range c.Dns {
|
||||||
@@ -1039,6 +1077,12 @@ func (c *Container) validate(where string, _ bool) []string {
|
|||||||
"static address anywhere but a user-defined one")
|
"static address anywhere but a user-defined one")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, cap := range c.Capabilities {
|
||||||
|
if !capabilityName.MatchString(cap) {
|
||||||
|
problems = append(problems, where+": capabilities names "+strconv.Quote(cap)+", which is not a "+
|
||||||
|
"capability's name (CAP_NET_ADMIN or NET_ADMIN)")
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, n := range c.Networks {
|
for _, n := range c.Networks {
|
||||||
problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...)
|
problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...)
|
||||||
if n == c.Network {
|
if n == c.Network {
|
||||||
@@ -1209,6 +1253,10 @@ type Adoption struct {
|
|||||||
Untaken map[string][]string `json:"untaken,omitempty"`
|
Untaken map[string][]string `json:"untaken,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// capabilityName is what a Linux capability is called: upper case, underscores, an optional CAP_
|
||||||
|
// prefix. The runtime accepts either spelling.
|
||||||
|
var capabilityName = regexp.MustCompile(`^(CAP_)?[A-Z][A-Z0-9_]*$`)
|
||||||
|
|
||||||
// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted —
|
// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted —
|
||||||
// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held.
|
// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held.
|
||||||
const AdoptionPrefix = "adoption."
|
const AdoptionPrefix = "adoption."
|
||||||
@@ -1422,6 +1470,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
|||||||
problems = append(problems, resource.validate(where, allowActions)...)
|
problems = append(problems, resource.validate(where, allowActions)...)
|
||||||
d.Resources = append(d.Resources, resource)
|
d.Resources = append(d.Resources, resource)
|
||||||
}
|
}
|
||||||
|
problems = append(problems, checkWhileStopped(d.Resources)...)
|
||||||
problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...)
|
problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...)
|
||||||
if env.Adoption == nil {
|
if env.Adoption == nil {
|
||||||
for _, r := range d.Resources {
|
for _, r := range d.Resources {
|
||||||
@@ -1450,6 +1499,41 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
|||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkWhileStopped judges a maintenance window against the whole declaration (novox/hq ADR 0189).
|
||||||
|
//
|
||||||
|
// A step may hold still only a container that is **here** — in this same declaration, which is to
|
||||||
|
// say on this machine and placed by the mesh. That is what makes it the module's own: a node's
|
||||||
|
// declaration carries one module's resources beside another's, so the id must also be a container
|
||||||
|
// and not a file or a directory, which there would be nothing to stop.
|
||||||
|
//
|
||||||
|
// Refused on arrival rather than discovered at the first fire. A window that names something the
|
||||||
|
// host cannot stop is a window that opens at 03:00 and reports nothing until somebody reads a log.
|
||||||
|
func checkWhileStopped(resources []Resource) []string {
|
||||||
|
containers := map[string]bool{}
|
||||||
|
for _, r := range resources {
|
||||||
|
if r.Kind() == TypeContainer {
|
||||||
|
containers[r.Identity()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, r := range resources {
|
||||||
|
c, ok := r.(*Container)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, id := range c.WhileStopped {
|
||||||
|
if containers[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"resource %q: while-stopped names %q, and this declaration has no container by "+
|
||||||
|
"that id. A step may hold still only a container placed on this machine "+
|
||||||
|
"beside it", c.ID, id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
func strictDecode(raw []byte, into any) error {
|
func strictDecode(raw []byte, into any) error {
|
||||||
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
|
// 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.
|
// field the host does not know is a thing the control plane believes it asked for.
|
||||||
|
|||||||
@@ -504,3 +504,23 @@ func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
|
|||||||
t.Fatalf("a carried bundle leaving modules out was accepted: %v", err)
|
t.Fatalf("a carried bundle leaving modules out was accepted: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170).
|
||||||
|
func TestACapabilityIsNamedOrRefused(t *testing.T) {
|
||||||
|
image := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||||
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
|
{"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := d.Resources[0].(*Container).Capabilities; len(got) != 2 || got[0] != "NET_ADMIN" {
|
||||||
|
t.Fatalf("capabilities read as %v", got)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{`"net_admin"`, `"ALL;rm -rf /"`, `"privileged"`} {
|
||||||
|
if _, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
|
{"id":"fw","type":"container","name":"fw","image":"` + image + `","capabilities":[` + bad + `]}]}`)); err == nil {
|
||||||
|
t.Errorf("%s was accepted as a capability", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const (
|
|||||||
CapServiceManager = "service-manager"
|
CapServiceManager = "service-manager"
|
||||||
CapFirewall = "firewall"
|
CapFirewall = "firewall"
|
||||||
CapOverlay = "overlay"
|
CapOverlay = "overlay"
|
||||||
|
// CapVirtualisation is a running virtualisation daemon: what the lab raises its machines on
|
||||||
|
// (novox/hq ADR 0172), and what grants a module the daemon's socket.
|
||||||
|
CapVirtualisation = "virtualisation"
|
||||||
CapGraphicalSession = "graphical-session"
|
CapGraphicalSession = "graphical-session"
|
||||||
// CapSeat is hardware: somewhere a display server COULD run. CapGraphicalSession above is
|
// CapSeat is hardware: somewhere a display server COULD run. CapGraphicalSession above is
|
||||||
// state: whether one IS running. Assignment needs the first.
|
// state: whether one IS running. Assignment needs the first.
|
||||||
@@ -205,6 +208,11 @@ func Default(runner Runner) []Detector {
|
|||||||
why: "lists the ruleset — needs the tool AND the privilege to use it",
|
why: "lists the ruleset — needs the tool AND the privilege to use it",
|
||||||
runner: runner,
|
runner: runner,
|
||||||
},
|
},
|
||||||
|
commandCapability{
|
||||||
|
name: CapVirtualisation, command: "incus", args: []string{"info"},
|
||||||
|
why: "asks the virtualisation daemon about itself — a running daemon, not an installed client",
|
||||||
|
runner: runner,
|
||||||
|
},
|
||||||
commandCapability{
|
commandCapability{
|
||||||
name: CapOverlay, command: "wg", args: []string{"show", "interfaces"},
|
name: CapOverlay, command: "wg", args: []string{"show", "interfaces"},
|
||||||
why: "asks the kernel for interfaces — needs the module, not just the tool",
|
why: "asks the kernel for interfaces — needs the module, not just the tool",
|
||||||
|
|||||||
Reference in New Issue
Block a user