package bootstrap import ( "context" "fmt" "os" "strings" "time" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/image" "github.com/novox/mesh-host/internal/profile" ) // DefaultRegistry is where an image reference that names no host comes from. const DefaultRegistry = "registry-1.docker.io:443" // Preflight refuses early and plainly, and returns the bundle template it read. // // Everything here is a thing that will otherwise be discovered half way through: a machine with // no runtime found after a bundle has been written, a template that does not parse found after an // image has been loaded, a registry that cannot be reached found inside a `docker pull` that // reports a network error and not a missing image. The order is cheapest first, so a mistake in // what the installer was pointed at costs nothing to find. func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte, error) { // 1. Does this installer carry what it claims to? // // Asked before the machine is touched, for the same reason `mesh-host bundle` exists: a host // that carries no substrate must say so when somebody asks, not on a first node // (internal/bundle). An installer built without an image would otherwise get a machine as far // as a running store and a running broker and stop. if image.IsEmpty() { return nil, image.ErrEmpty } saved, err := image.Saved() if err != nil { return nil, err } carriedID, err := image.ID(saved) if err != nil { return nil, err } say(fmt.Sprintf(" control plane %s carried (%s)", firstOr(image.Tags(saved), "untagged"), carriedID)) // 2. Is the template there, and is it a substrate? template, err := os.ReadFile(o.Template) if err != nil { return nil, fmt.Errorf( "the bundle template could not be read: %w\n"+ "It is what this machine will be asked to be, so there is nothing to do without "+ "it. Point --bundle at one; mesh-host's examples/substrate-first-node.lock is "+ "the shape", err) } // Parsed here as well as at the rewrite, because a template that is not a declaration should // cost a second rather than an image load and a written file. parsed, err := declaration.ParseFileTrusted(template) if err != nil { return nil, fmt.Errorf("the bundle template is not a declaration: %w", err) } if _, err := controlPlaneIn(parsed); err != nil { return nil, err } say(fmt.Sprintf(" bundle template %s (%d resources)", o.Template, len(parsed.Resources))) // 3. Does a container runtime ANSWER? // // Not "is it installed" — novox/hq 04-ISSUES/007 is exactly that mistake, and the detector // this uses is the one written for it: it asks the daemon for its server version, which fails // when the daemon is down however complete the installation is. // // **Yes, the bundle installs the runtime itself**, and that is not a contradiction. The // installer needs one BEFORE the apply, because the control plane's image is loaded into it // first; the bundle still declares the package and the service because the host must own them // and reassert them at every reconcile. So this is not a duplicate check — it is the one thing // the bootstrap cannot bootstrap. // // Polled rather than asked once. A socket-activated daemon queued behind // `network-online.target` is not absent, it is a few seconds away, and `docker load` against // one blocks silently rather than failing (04-ISSUES/024). Waiting is the honest reading. if err := waitForRuntime(ctx, d.Run, o.Timeout, o.Wait, say); err != nil { return nil, err } // 4. Can this machine reach what the bundle's images come from? // // Asked of the hosts the bundle actually names rather than of the internet in general. The // mesh's own image is carried and needs nothing served — it is skipped here for exactly that // reason. Everything else is somebody else's image at somebody else's registry, and a machine // that cannot reach it fails inside a pull, which reports a network error where a person // reads a missing image. for _, host := range registriesIn(parsed) { dialing, cancel := context.WithTimeout(ctx, o.Timeout) err := d.Dial(dialing, host) cancel() if err != nil { return nil, fmt.Errorf( "this machine cannot reach %s, and the bundle's images are served from there: "+ "%w\nThe apply would fail inside a pull, which says the wrong thing. Fix the "+ "machine's network, or point the bundle at a registry it can reach", host, err) } say(" reachable " + host) } return template, nil } // waitForRuntime asks the runtime, repeatedly, until it answers or the wait runs out. func waitForRuntime(ctx context.Context, run Runner, probe, wait time.Duration, say func(string)) error { detector := containerRuntimeDetector(run) deadline := time.Now().Add(wait) var last string for { probing, cancel := context.WithTimeout(ctx, probe) verdict := detector.Detect(probing) cancel() if verdict.Present { say(" container runtime " + verdict.Detail) return nil } last = verdict.Detail if time.Now().After(deadline) { break } select { case <-ctx.Done(): return ctx.Err() case <-time.After(runtimeAskEvery): } } return fmt.Errorf( "this machine has no container runtime that answers, after waiting %s: %s\n"+ "An installed package is not a capability (novox/hq 04-ISSUES/007) — the daemon was "+ "asked and did not reply. Start it, then run this again; every step is idempotent", wait, last) } // runtimeAskEvery is how often the runtime is asked again while waiting for it. var runtimeAskEvery = 2 * time.Second // containerRuntimeDetector is the host's OWN detector for a working runtime, not a second // implementation of the same question. Two answers to "is there a container runtime here" is how // the installer and the host come to disagree about a machine. func containerRuntimeDetector(run Runner) profile.Detector { for _, detector := range profile.Default(profile.Runner(run)) { if detector.Name() == profile.CapContainerRuntime { return detector } } // Unreachable unless the host's own detector set loses its container runtime, which would be // a change nobody would make on purpose — said rather than nil-dereferenced. panic("the host detects no container runtime capability, and the installer needs that answer") } // registriesIn is every host the bundle's images would be fetched from, without duplicates and in // the order they appear. func registriesIn(d *declaration.Declaration) []string { var hosts []string seen := map[string]bool{} for _, r := range d.Resources { container, ok := r.(*declaration.Container) if !ok { continue } host, served := registryOf(container.Image) if !served || seen[host] { continue } seen[host] = true hosts = append(hosts, host) } return hosts } // registryOf says where an image would be fetched from, and whether anything has to serve it. // // The second return is false for an image named by the digest of its own configuration: nothing // serves those and nothing can (see `internal/declaration`'s checkImage). That is the whole reason // the mesh's own control plane can be raised on a machine with no registry anywhere. // // The rule for the rest is the container runtime's own: the part before the first slash is a // registry host if it looks like one — it has a dot, or a port, or it is `localhost` — and // otherwise it is part of a repository name on the default registry. func registryOf(reference string) (string, bool) { if reference == "" || strings.HasPrefix(reference, "sha256:") { return "", false } name := reference if at := strings.Index(name, "@"); at >= 0 { name = name[:at] } first, _, hasPath := strings.Cut(name, "/") if !hasPath || !(strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost") { return DefaultRegistry, true } if !strings.Contains(first, ":") { // A registry with no port is reached over HTTPS, which is where a pull would go. return first + ":443", true } return first, true } func firstOr(values []string, fallback string) string { if len(values) == 0 || strings.TrimSpace(values[0]) == "" { return fallback } return values[0] }