clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / internal / workbench / operations.go
operations.go 8.1K
1package workbench23import (4 "errors"5 "fmt"6 "sort"7 "strings"89 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"10)1112// Pipeline operations.13//14// The workbench does not link the pipeline: an operation is the `atlas` binary15// invoked exactly as a person at a terminal would invoke it, and the run's own16// event stream comes back as rows (issue #5 ยง3.1 -- the workbench shells out to17// the lane CLIs). That is what keeps a lane's work inside its lane while the18// page that starts it imports nothing but the format and the score.19//20// Everything an operation may touch is configured when the workbench is21// mounted, never taken from a request: a request chooses among the operations22// below and may name a source, a target and a volume, and each of those three23// is validated before an argv exists. Nothing here composes a shell command;24// argv is a slice, and the operating system gets it as one.2526// Targets is where operations run: the binary, the directories, and the working27// directory a run starts in. A target that is empty is a target the workbench28// was not given, and every operation that needs it refuses by saying so.29type Targets struct {30 // Atlas is the pipeline binary. `atlas workbench` fills it with its own31 // path, so the workbench runs the same build of the pipeline that is32 // serving the page.33 Atlas string34 // Dir is the working directory operations run in. Empty is the workbench's35 // own, which is what absolute paths want.36 Dir string37 // Registry is the library of .atlas files: what the measurement pages read38 // and what compose, enrich and measure are pointed at.39 Registry string40 // Cache is the shared content-addressed evidence cache used by every build.41 Cache string42 // Project is the one portable manifest this workbench is allowed to edit,43 // plan, and build. It is fixed at startup and never accepted from a request.44 Project string45 // Archive is the capture archive root -- the directory holding archive.json.46 Archive string47 // TileSet is the derived tile set directory, which `atlas tiles` writes.48 TileSet string49 // TileIndex is that tile set's register, which compose and enrich read. It50 // is stated rather than derived because the file's name is the generate51 // lane's to know, and the wiring that knows the lane fills it in.52 TileIndex string53}5455// buildOperation is the only authoring command the vNext workbench exposes.56// The manifest, cache and library are startup authority, not form values.57func buildOperation(targets Targets, offline bool) (oprunner.Operation, error) {58 var missing []string59 for name, value := range map[string]string{60 "atlas binary": targets.Atlas,61 "project": targets.Project,62 "cache": targets.Cache,63 "library": targets.Registry,64 } {65 if value == "" {66 missing = append(missing, name)67 }68 }69 if len(missing) > 0 {70 sort.Strings(missing)71 return oprunner.Operation{}, fmt.Errorf("build cannot run here: no %s configured", strings.Join(missing, ", no "))72 }73 argv := []string{targets.Atlas, "build", "--log-json", "-cache", targets.Cache, "-bundles", targets.Registry}74 if offline {75 argv = append(argv, "-offline")76 }77 argv = append(argv, targets.Project)78 return oprunner.Operation{Name: "build", Dir: targets.Dir, Argv: argv}, nil79}8081// An operation is one pipeline subcommand the page may run.82type operation struct {83 // Name is the operation as the form submits it and the CLI spells it.84 Name string85 // Summary is the one line the page prints beside the button.86 Summary string87 // Source says the operation is aimed at one capture source, so the form88 // asks for a source and a target.89 Source bool90 // Volumes says the operation accepts an optional volume slug narrowing it91 // to one volume.92 Volumes bool93 // needs are the configured targets without which it cannot run.94 needs []string95}9697// operations is the table, in the order the page lists them: the pipeline read98// left to right, capture first and measurement last.99var operations = []operation{100 {101 Name: "crawl",102 Summary: "fetch what a publisher serves into the capture archive",103 Source: true,104 needs: []string{"archive"},105 },106 {107 Name: "tiles",108 Summary: "derive raster pyramids from archived captures",109 needs: []string{"archive", "tile set"},110 },111 {112 Name: "compose",113 Summary: "build volumes from archived captures and derived pyramids",114 Volumes: true,115 needs: []string{"archive", "tile index", "registry"},116 },117 {118 Name: "enrich",119 Summary: "fold every reading of a volume together and build the richer volume",120 Volumes: true,121 needs: []string{"archive", "tile index", "registry"},122 },123 {124 Name: "measure",125 Summary: "score every build in the registry",126 Volumes: true,127 needs: []string{"registry"},128 },129}130131func operationByName(name string) (operation, bool) {132 for _, held := range operations {133 if held.Name == name {134 return held, true135 }136 }137 return operation{}, false138}139140// Ready reports whether this operation's targets are all configured, and what141// is missing when they are not. The page prints the answer beside the form142// rather than letting a person discover it by pressing the button.143func (o operation) Ready(t Targets) (bool, string) {144 var missing []string145 for _, need := range o.needs {146 if t.of(need) == "" {147 missing = append(missing, need)148 }149 }150 if t.Atlas == "" {151 missing = append(missing, "atlas binary")152 }153 if len(missing) == 0 {154 return true, ""155 }156 return false, "no " + strings.Join(missing, ", no ") + " configured"157}158159func (t Targets) of(need string) string {160 switch need {161 case "archive":162 return t.Archive163 case "tile set":164 return t.TileSet165 case "tile index":166 return t.TileIndex167 case "registry":168 return t.Registry169 }170 return ""171}172173// request is what a submission asks for, before anything is trusted.174type request struct {175 Operation string176 Source string177 Target string178 Volume string179}180181// plan turns a submission into the operation to run, or into the refusal a182// person should read. Every refusal is a 400: it is the request that is wrong,183// and none of them starts a subprocess.184func plan(targets Targets, sources []Source, asked request) (oprunner.Operation, error) {185 held, known := operationByName(asked.Operation)186 if !known {187 return oprunner.Operation{}, fmt.Errorf("unknown operation %q", asked.Operation)188 }189 if ready, why := held.Ready(targets); !ready {190 return oprunner.Operation{}, fmt.Errorf("%s cannot run here: %s", held.Name, why)191 }192193 argv := []string{targets.Atlas, held.Name, "--log-json"}194 switch held.Name {195 case "crawl":196 source, found := sourceByName(sources, asked.Source)197 if !found {198 return oprunner.Operation{}, fmt.Errorf("unknown source %q", asked.Source)199 }200 if !source.Crawlable {201 return oprunner.Operation{}, fmt.Errorf(202 "%s has no crawler registered: its captures are archived and its endpoints are not reached from here",203 source.Name)204 }205 if err := oprunner.ValidTarget(asked.Target, source.Pair); err != nil {206 return oprunner.Operation{}, fmt.Errorf("%s target %q: %w (expected %s)",207 source.Label, asked.Target, err, source.TargetHint)208 }209 argv = append(argv, "-archive", targets.Archive, "-source", source.Name, asked.Target)210 case "tiles":211 argv = append(argv, "-archive", targets.Archive, "-output", targets.TileSet)212 case "compose", "enrich":213 argv = append(argv, "-archive", targets.Archive,214 "-tiles", targets.TileIndex, "-bundles", targets.Registry)215 case "measure":216 argv = append(argv, "-bundles", targets.Registry)217 }218219 if asked.Volume != "" {220 if !held.Volumes {221 return oprunner.Operation{}, fmt.Errorf("%s does not take a volume", held.Name)222 }223 // A volume slug is a target like any other: it becomes a bare224 // argument, so it is held to the same rule that keeps an argument225 // from being read as a flag.226 if err := oprunner.ValidTarget(asked.Volume, false); err != nil {227 return oprunner.Operation{}, fmt.Errorf("volume %q: %w", asked.Volume, err)228 }229 argv = append(argv, asked.Volume)230 }231232 return oprunner.Operation{Name: held.Name, Dir: targets.Dir, Argv: argv}, nil233}234235// errNoOperation is what an empty submission gets. It is separated only so the236// handler's switch reads as a list of refusals.237var errNoOperation = errors.New("no operation was named")