diff --git a/cmd/mesh-bootstrap/main.go b/cmd/mesh-bootstrap/main.go index 7a1fba5..adb27ba 100644 --- a/cmd/mesh-bootstrap/main.go +++ b/cmd/mesh-bootstrap/main.go @@ -135,6 +135,11 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh --overlay-range the private network's range (default 10.42.0.0/16); refused if it overlaps an interface or route the machine already has + --adopted raise a machine in use as an adopted node: what it runs and its + firewall stay as they are, the foundation's filter is not loaded and + the mesh guards its own ports instead, and each module is taken on it + one at a time. Without it, a machine in use is refused + The installer carries a builder, not a control plane. What raises a mesh is therefore the same thing that will maintain it, and the control plane a mesh ends up running is one it built itself, from a repository and a commit it can name and build again. @@ -312,6 +317,8 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet { } { set.IntVar(p.into, p.name, *p.into, "the machine's port for "+p.what) } + set.BoolVar(&opts.Adopted, "adopted", false, + "raise this machine adopted: keep what it runs and its firewall until each module is taken") set.StringVar(&opts.OverlayRange, "overlay-range", opts.OverlayRange, "the private network's address range; must not overlap a tunnel the machine already runs") if opts.Answers == nil { diff --git a/internal/bootstrap/adopted.go b/internal/bootstrap/adopted.go new file mode 100644 index 0000000..dbbe181 --- /dev/null +++ b/internal/bootstrap/adopted.go @@ -0,0 +1,192 @@ +package bootstrap + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/novox/mesh-host/internal/declaration" +) + +// What an adopted genesis changes about the foundation (novox/hq ADR 0100). +// +// **The firewall found on the machine stays in force.** The foundation's own filter drops by +// default, and every base chain at a hook runs; an accept ends only its own chain and a drop in any +// is final — so loading it would close whatever the machine serves. On an adopted machine it is +// not loaded. Its duty, the store never reachable from outside, passes to the mesh's guard: a table +// of the mesh's own that only refuses, and only the foundation's own ports, which genesis has just +// checked free — so it cannot close anything the machine serves. + +// The guard, as the controller declares it: the same ids, paths and text, so the first push +// finds it already there and takes it over unchanged. +const ( + guardID = declaration.AdoptionPrefix + "guard" + guardUnitID = declaration.AdoptionPrefix + "guard-unit" + guardRunningID = declaration.AdoptionPrefix + "guard-running" + guardPath = "/etc/mesh/guard.nft" + guardUnit = "mesh-guard.service" + guardUnitPath = "/etc/systemd/system/" + guardUnit +) + +// AsGuard renders the mesh's refusal-only table for the given machine ports. It passes everything +// by default; it refuses the ports except from the machine itself — its loopback and the container +// runtime's own networks — and from the private network, known by the interface a packet arrives +// on and never by its source address; at prerouting, ahead of the runtime's destination +// translation, in the inet family so both address families. +// +// Character for character the controller's (mesh-controller internal/catalogue AsGuard); a test +// on each side holds its copy to the same golden text. +func AsGuard(ports []int) string { + sorted := append([]int{}, ports...) + sort.Ints(sorted) + listed := make([]string, len(sorted)) + for i, p := range sorted { + listed[i] = strconv.Itoa(p) + } + var b strings.Builder + b.WriteString("table inet mesh_guard {}\n") + b.WriteString("delete table inet mesh_guard\n") + b.WriteString("table inet mesh_guard {\n") + b.WriteString("\tchain prerouting {\n") + b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n") + fmt.Fprintf(&b, "\t\tiifname != \"lo\" iifname != \"docker0\" iifname != \"br-*\" "+ + "iifname != \"mesh0\" tcp dport { %s } drop\n", strings.Join(listed, ", ")) + b.WriteString("\t}\n") + b.WriteString("}\n") + return b.String() +} + +// guardUnitText is the unit that loads the guard. Stopping it deletes only its own table — never a +// flush, which would take the container runtime's rules and the found firewall with it. +func guardUnitText() string { + return "[Unit]\n" + + "Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" + + "After=network-pre.target\n" + + "Wants=network-pre.target\n" + + "\n" + + "[Service]\n" + + "Type=oneshot\n" + + "RemainAfterExit=yes\n" + + "ExecStart=nft -f " + guardPath + "\n" + + "ExecReload=nft -f " + guardPath + "\n" + + "ExecStop=nft delete table inet mesh_guard\n" + + "\n" + + "[Install]\n" + + "WantedBy=multi-user.target\n" +} + +// guardResources are the guard as three resources of kinds the host already has. +func guardResources(ports []int) []map[string]any { + return []map[string]any{ + {"id": guardID, "type": "file", "path": guardPath, "content": AsGuard(ports), "mode": "0644"}, + {"id": guardUnitID, "type": "file", "path": guardUnitPath, "content": guardUnitText(), "mode": "0644"}, + {"id": guardRunningID, "type": "service", "unit": guardUnit, "state": "running", + "boot": "enabled", "restart-on": []any{guardID, guardUnitID}}, + } +} + +// guardAfter is where the guard goes: once the container runtime runs, before anything publishes +// a port. +const guardAfter = "container-runtime-running" + +// AdoptedRewrite says what RewriteAdopted did. +type AdoptedRewrite struct { + Removed []string + Guarded []int +} + +// RewriteAdopted makes the produced bundle one for an adopted machine: the foundation's own filter +// taken out, and the mesh's guard put in its place, guarding the store's and the broker's +// management ports on this node. The nftables package stays: the guard is loaded with it, and +// installing a package loads no table. Openings are not the bundle's — the first push declares +// them, once there is a controller to derive them. +func RewriteAdopted(r *Rewritten, p FoundationPorts) (AdoptedRewrite, error) { + var out AdoptedRewrite + p = p.orDefaults() + bundle := r.Bundle + var err error + for _, id := range []string{"base-filter-loaded", "base-filter"} { + if !r.declares(id) { + continue + } + if bundle, err = removeResource(bundle, id); err != nil { + return out, err + } + out.Removed = append(out.Removed, id) + } + + out.Guarded = []int{p.Store, p.Management} + var text bytes.Buffer + text.WriteString(",\n // The mesh's guard (novox/hq ADR 0100): this machine is adopted, so its own firewall\n" + + " // stays in force and the foundation's filter is not loaded. The guard only refuses: the\n" + + " // store's and the broker's management ports, except from the machine and the private network.") + for _, res := range guardResources(out.Guarded) { + var one bytes.Buffer + enc := json.NewEncoder(&one) + enc.SetEscapeHTML(false) + if err := enc.Encode(res); err != nil { + return out, err + } + text.WriteString("\n ") + text.Write(bytes.TrimSpace(one.Bytes())) + text.WriteString(",") + } + insert := bytes.TrimSuffix(text.Bytes(), []byte(",")) + + _, _, to, err := resourceAt(bundle, guardAfter) + if err != nil { + return out, fmt.Errorf("the guard goes after %q, and %w", guardAfter, err) + } + rest := bundle[to:] + joined := make([]byte, 0, len(bundle)+len(insert)) + joined = append(joined, bundle[:to]...) + joined = append(joined, insert...) + // What followed the resource — its own comma, or the end of the list — now follows the guard. + if trimmed := bytes.TrimLeft(rest, " \t\r\n"); len(trimmed) > 0 && trimmed[0] != ',' && trimmed[0] != ']' { + return out, fmt.Errorf("the bundle does not separate %q from what follows it the way a list does", guardAfter) + } + joined = append(joined, rest...) + + parsed, err := declaration.ParseFileTrusted(joined) + if err != nil { + return out, fmt.Errorf("the bundle stopped being a declaration once it was made an adopted one, which is this installer's fault: %w", err) + } + r.Bundle, r.Declaration, r.Resources = joined, parsed, len(parsed.Resources) + return out, nil +} + +// declares is whether the produced bundle names a resource. +func (r Rewritten) declares(id string) bool { + for _, res := range r.Declaration.Resources { + if res.Identity() == id { + return true + } + } + return false +} + +// genesisTakes are the modules an adopted genesis takes as it installs them: the foundation's and +// the mesh's own, whose names genesis checked free, so taking them replaces nothing a predecessor +// ran. The private network is not among them — it rewrites the machine's hosts file and the +// container runtime's configuration whole — and neither is anything the operator installs later. +var genesisTakes = map[string]bool{ + RegistryModule: true, ControlPlaneModule: true, BuilderModule: true, + "postgres": true, "lavinmq": true, "mesh-vault": true, "mesh-catalog": true, +} + +// takeIfAdopted takes one of genesis's own modules on an adopted node, once it is assigned and +// before the push that raises it. +func takeIfAdopted(ctx context.Context, o Options, control controlPlane, module string, say func(string)) error { + if !o.Adopted || !genesisTakes[module] { + return nil + } + if _, err := control.tell(ctx, "take", o.Node, module); err != nil { + return err + } + say(" taken " + module + " on " + o.Node + " — the mesh's own, its name checked free") + return nil +} diff --git a/internal/bootstrap/adopted_test.go b/internal/bootstrap/adopted_test.go new file mode 100644 index 0000000..dd6e8e6 --- /dev/null +++ b/internal/bootstrap/adopted_test.go @@ -0,0 +1,192 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/novox/mesh-host/internal/declaration" +) + +// Defends novox/hq ADR 0100: an adopted genesis loads no table that drops by default or holds an +// accept; the mesh guards its own ports in a table that only refuses; and genesis takes the mesh's +// own modules as it installs them, and nothing else. + +// The same golden text the controller's test holds its AsGuard to. +const goldenGuard = `table inet mesh_guard {} +delete table inet mesh_guard +table inet mesh_guard { + chain prerouting { + type filter hook prerouting priority raw; policy accept; + iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop + } +} +` + +func TestTheGuardIsExactlyThisTable(t *testing.T) { + if got := AsGuard([]int{15672, 5432}); got != goldenGuard { + t.Fatalf("the guard changed:\n%s", got) + } +} + +func TestAnAdoptedBundleLoadsNoDroppingTableAndExactlyTheGuard(t *testing.T) { + r := producedBundle(t) + p := FoundationPorts{Store: 5433, Management: 15673} + if _, err := RewritePorts(&r, p, ""); err != nil { + t.Fatal(err) + } + got, err := RewriteAdopted(&r, p) + if err != nil { + t.Fatal(err) + } + if strings.Join(got.Removed, ",") != "base-filter-loaded,base-filter" { + t.Errorf("removed %v", got.Removed) + } + at := map[string]int{} + var guards []*declaration.File + for i, res := range r.Declaration.Resources { + at[res.Identity()] = i + if f, ok := res.(*declaration.File); ok { + if strings.Contains(f.Content, "policy drop") || strings.Contains(f.Content, " accept\n") && + !strings.Contains(f.Content, "policy accept") { + t.Errorf("%s loads a table that drops or accepts: %q", f.ID, f.Content) + } + if f.Path == guardPath { + guards = append(guards, f) + } + } + if s, ok := res.(*declaration.Service); ok && s.Unit == "nftables.service" { + t.Errorf("the foundation's filter is still loaded by %s", s.ID) + } + } + if len(guards) != 1 { + t.Fatalf("%d guard table(s)", len(guards)) + } + if !strings.Contains(guards[0].Content, "tcp dport { 5433, 15673 } drop") { + t.Errorf("the guard does not refuse this node's ports: %s", guards[0].Content) + } + if strings.Count(guards[0].Content, "accept") != 1 || !strings.Contains(guards[0].Content, "policy accept") { + t.Errorf("the guard holds an accept of its own: %s", guards[0].Content) + } + for _, id := range []string{guardID, guardUnitID, guardRunningID} { + if _, ok := at[id]; !ok { + t.Errorf("the bundle has no %s", id) + } + } + if !(at["container-runtime-running"] < at[guardID] && at[guardRunningID] < at["store"]) { + t.Errorf("the guard is not between the runtime and the store: %v", at) + } + if _, kept := at["base-filter-package"]; !kept { + t.Error("nft, which loads the guard, is no longer installed") + } + unit := r.Declaration.Resources[at[guardRunningID]].(*declaration.Service) + if unit.Unit != guardUnit || unit.State != "running" || strings.Join(unit.RestartOn, ",") != guardID+","+guardUnitID { + t.Errorf("the guard's service: %+v", unit) + } + stop := r.Declaration.Resources[at[guardUnitID]].(*declaration.File).Content + if !strings.Contains(stop, "ExecStop=nft delete table inet mesh_guard") || strings.Contains(stop, "flush") { + t.Errorf("stopping the guard does not delete only its own table: %s", stop) + } +} + +func TestAConvergedBundleIsNotMadeAnAdoptedOne(t *testing.T) { + // The converged genesis keeps the foundation's filter, byte for byte (novox/hq ADR 0088). + r := producedBundle(t) + for _, res := range r.Declaration.Resources { + if strings.HasPrefix(res.Identity(), declaration.AdoptionPrefix) { + t.Errorf("a converged bundle carries %s", res.Identity()) + } + } + if !r.declares("base-filter-loaded") { + t.Error("a converged bundle lost its filter") + } +} + +func TestAnAdoptedGenesisTakesTheMeshsOwnModulesBeforePushingThem(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + for _, c := range []struct { + module string + takes bool + }{{RegistryModule, true}, {ControlPlaneModule, true}, {BuilderModule, true}, {"gitea", false}} { + rec := &controlRecorder{settings: map[string]string{}} + control := controlPlane{container: "temp-mesh-controller", run: rec.run, timeout: time.Second} + o := Options{Node: "anchor", Adopted: true, Wait: time.Second} + if _, err := installModule(context.Background(), o, control, c.module, []byte(`{}`), quietly); err != nil { + t.Fatal(err) + } + assign, take, push := rec.index("assign anchor "+c.module), rec.index("take anchor "+c.module), rec.index("push anchor") + if !c.takes { + if take >= 0 { + t.Errorf("%s was taken at genesis", c.module) + } + continue + } + if !(assign >= 0 && assign < take && take < push) { + t.Errorf("%s: assign %d, take %d, push %d: %v", c.module, assign, take, push, rec.told) + } + } +} + +func TestAConvergedGenesisTakesNothing(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + rec := &controlRecorder{settings: map[string]string{}} + control := controlPlane{container: "temp-mesh-controller", run: rec.run, timeout: time.Second} + if _, err := installModule(context.Background(), Options{Node: "anchor", Wait: time.Second}, control, + RegistryModule, []byte(`{}`), quietly); err != nil { + t.Fatal(err) + } + if rec.index("take") >= 0 { + t.Errorf("a converged genesis took a module: %v", rec.told) + } +} + +func TestAnAdoptedGenesisOpensTheRegistryFromAnywhere(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + rec := &controlRecorder{settings: map[string]string{}} + control := controlPlane{container: "temp-mesh-controller", run: rec.run, timeout: time.Second} + o := Options{Node: "anchor", Adopted: true, Ports: FoundationPorts{Registry: 5100}, Wait: time.Second} + if _, err := installModule(context.Background(), o, control, RegistryModule, []byte(`{}`), quietly); err != nil { + t.Fatal(err) + } + if got := rec.settings["distribution-settings.json"]; got != `{"expose":{"5000":"anywhere"},"ports":{"5000":5100}}` { + t.Errorf("the registry was told %s", got) + } +} + +func TestAnAdoptedGenesisChoosesTheFilterAndLoadsNone(t *testing.T) { + rec := &controlRecorder{settings: map[string]string{}} + control := controlPlane{container: "mesh-controller", run: rec.run, timeout: time.Second} + o := Options{Node: "anchor", Adopted: true, Answers: map[string]string{"packet-filter": "nftables"}} + filter, err := ChooseAndInstallFilter(context.Background(), o, control, quietly) + if err != nil || filter != "nftables" { + t.Fatalf("%q %v", filter, err) + } + if len(rec.told) != 0 { + t.Errorf("an adopted genesis installed a filter: %v", rec.told) + } +} + +func TestAnAdoptedNodeIsRecordedAdopted(t *testing.T) { + stop := errors.New("stop here") + runtime := &asked{answer: func(name string, args []string) (string, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "node list"): + return "", nil + case strings.Contains(joined, "node add"): + return "", nil + } + return "", fmt.Errorf("%w: %s %v", stop, name, args) + }} + _, _ = Enrol(context.Background(), Options{ + Node: "anchor", Adopted: true, State: t.TempDir() + "/state.json", Timeout: time.Second, + Host: "/usr/local/bin/mesh-host", HostInBackground: true, + }, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}, + func(string) {}) + if !runtime.ran("node add anchor --adopted") { + t.Errorf("the node was not added adopted: %v", runtime.commands) + } +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 95b86b4..4349bcc 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -34,6 +34,8 @@ import ( "fmt" "strings" "time" + + "github.com/novox/mesh-host/internal/firewall" ) // Step names one stage. A failure says which one, because "the bootstrap failed" is a sentence @@ -377,6 +379,23 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro // Which half of the host applies things here. Asked of the machine and proved, because // `mesh-host` pins this at link time and an installer run by hand has no link time. + // An adopted machine keeps the firewall it was found with, so the mesh must speak it; one no + // host speaks is refused here, before anything changes (novox/hq ADR 0100). + if o.Adopted { + kind, name, err := firewall.Detect(ctx, d.Run) + if err != nil { + return result, failed(StepPreflight, err) + } + if kind == firewall.Unsupported { + return result, failed(StepPreflight, fmt.Errorf( + "this machine is filtered by %s, and no host speaks that firewall yet. An adopted "+ + "machine keeps its firewall in force, so the mesh could neither open what it needs "+ + "through it nor say what it would close. Nothing was changed", name)) + } + result.Firewall = string(kind) + say(" adopted what is on this machine is kept; its firewall (" + string(kind) + ") stays in force") + } + sys, err := WorkOutSystem(ctx, d.Run, o.System) if err != nil { return result, failed(StepPreflight, err) @@ -444,6 +463,14 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro if moved.Places > 0 { say(fmt.Sprintf(" ports %d place(s) rewritten to this node's foundation ports", moved.Places)) } + if o.Adopted { + adopted, err := RewriteAdopted(&rewritten, o.Ports) + if err != nil { + return result, failed(StepBundle, err) + } + say(fmt.Sprintf(" adopted bundle the foundation's filter is not loaded (%s); the mesh's guard refuses %v from outside", + strings.Join(adopted.Removed, ", "), adopted.Guarded)) + } for _, c := range []struct { what, path string made bool @@ -718,7 +745,9 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro // ---- 17. filter ----------------------------------------------------------------------- say("filter — required, so the question is which, not whether") - if err := ChooseAndInstallFilter(ctx, o, permanentControl, say); err != nil { + filter, err := ChooseAndInstallFilter(ctx, o, permanentControl, say) + result.Filter = filter + if err != nil { return result, failed(StepFilter, err) } @@ -736,6 +765,12 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro return result, failed(StepExport, err) } + if o.Adopted { + say("\nthis machine is a mesh of one adopted node: it builds its own software, holds its graph " + + "and sits on its private network, and what it ran before is kept as it was, behind the firewall " + + "it was found with. Take each module on it once its data has moved; converge it when done.") + return result, nil + } say("\nthis machine is a mesh of one node: it builds its own software, holds its graph, " + "sits on its private network, and filters what modules declared.") say("what remains is somebody else's: adding nodes, and assigning what they should run.") diff --git a/internal/bootstrap/enrol.go b/internal/bootstrap/enrol.go index e64b86c..7ee3b71 100644 --- a/internal/bootstrap/enrol.go +++ b/internal/bootstrap/enrol.go @@ -72,7 +72,13 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla if mentions(nodes, o.Node) { say(" already a node " + o.Node) } else { - if _, err := control.tell(ctx, "node", "add", o.Node); err != nil { + add := []string{"node", "add", o.Node} + if o.Adopted { + // The controller records the node's mode; an adopted one keeps what it was found with + // until each module is taken (novox/hq ADR 0100). + add = append(add, "--adopted") + } + if _, err := control.tell(ctx, add...); err != nil { return out, err } out.Added = true diff --git a/internal/bootstrap/module.go b/internal/bootstrap/module.go index 92c4116..6352d4d 100644 --- a/internal/bootstrap/module.go +++ b/internal/bootstrap/module.go @@ -214,8 +214,12 @@ func pinPlaceholder(manifest []byte, reference, module string) ([]byte, int, err } // prepareModule is what genesis tells the controller about a module on this node once it is -// assigned and before it is pushed: the ports this node gave it (novox/hq ADR 0100). +// assigned and before it is pushed: the ports this node gave it, and on an adopted node that the +// module is taken (novox/hq ADR 0100). func prepareModule(ctx context.Context, o Options, control controlPlane, module string, say func(string)) error { - return setFoundationSettings(ctx, o, control, module, say) + if err := setFoundationSettings(ctx, o, control, module, say); err != nil { + return err + } + return takeIfAdopted(ctx, o, control, module, say) } diff --git a/internal/bootstrap/phase2.go b/internal/bootstrap/phase2.go index 431f12c..ee2319e 100644 --- a/internal/bootstrap/phase2.go +++ b/internal/bootstrap/phase2.go @@ -150,16 +150,22 @@ func PlaceOnTheNetwork(ctx context.Context, o Options, control controlPlane, // ChooseAndInstallFilter picks the packet filter — required, so the question is which, not // whether — and installs it. -func ChooseAndInstallFilter(ctx context.Context, o Options, control controlPlane, say func(string)) error { +func ChooseAndInstallFilter(ctx context.Context, o Options, control controlPlane, say func(string)) (string, error) { filter, err := decide(Choice{ Name: "packet-filter", Question: "Which packet filter should this machine run?", Options: []string{"nftables"}, }, o.Answers["packet-filter"], o.Prompt, say) if err != nil { - return err + return "", err } - return InstallFromCatalogue(ctx, o, control, filter, say) + if o.Adopted { + // The firewall found here stays in force until the node converges; the filter is + // chosen now and assigned by the flip (novox/hq ADR 0100). + say(" not installed " + filter + " — this machine is adopted; converging it assigns " + filter) + return filter, nil + } + return filter, InstallFromCatalogue(ctx, o, control, filter, say) } // InstallExtras installs what was asked for beyond the floor. diff --git a/internal/bootstrap/retire.go b/internal/bootstrap/retire.go index 2ed13f8..af41291 100644 --- a/internal/bootstrap/retire.go +++ b/internal/bootstrap/retire.go @@ -132,18 +132,29 @@ func RetireTheTemporaryControlPlane(ctx context.Context, o Options, sys system.S // where the comment explaining it lives. A comment that outlives the thing it describes is worse // than no comment: it is the file telling somebody the machine has a control plane it does not. func removeResource(bundle []byte, id string) ([]byte, error) { + previous, from, to, err := resourceAt(bundle, id) + if err != nil { + return nil, err + } + return cut(bundle, previous, from, to), nil +} + +// resourceAt finds one resource's object in a bundle's text by its id: where the one before it +// ended, and where it starts and ends — comments and strings skipped, so an id quoted in a comment +// or a command is never mistaken for the resource. +func resourceAt(bundle []byte, id string) (previous, from, to int, err error) { array := indexOutsideStrings(bundle, `"resources"`) if array < 0 { - return nil, fmt.Errorf("this bundle has no resources array, so there is nothing to take out of it") + return 0, 0, 0, fmt.Errorf("this bundle has no resources array, so there is nothing to take out of it") } open := indexOutsideStrings(bundle[array:], "[") if open < 0 { - return nil, fmt.Errorf("this bundle's resources are not a list") + return 0, 0, 0, fmt.Errorf("this bundle's resources are not a list") } open += array - depth, from := 0, -1 - previous := open + depth := 0 + from, previous = -1, open inString, escaped, inLine, inBlock := false, false, false, false for i := open + 1; i < len(bundle); i++ { c := bundle[i] @@ -181,16 +192,16 @@ func removeResource(bundle []byte, id string) ([]byte, error) { break } if isResource(bundle[from:i+1], id) { - return cut(bundle, previous, from, i+1), nil + return previous, from, i + 1, nil } previous = i + 1 from = -1 case c == ']' && depth == 0: - return nil, fmt.Errorf( + return 0, 0, 0, fmt.Errorf( "this bundle declares no %q, so there is nothing to take out of it", id) } } - return nil, fmt.Errorf("this bundle's resources list does not end") + return 0, 0, 0, fmt.Errorf("this bundle's resources list does not end") } // isResource reports whether one resource's text is the one wanted.