1package workbench23import (4 "errors"5 "fmt"6 "strings"78 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"9)1011// Pipeline operations.12//13// The workbench does not link the pipeline: an operation is the `atlas` binary14// invoked exactly as a person at a terminal would invoke it, and the run's own15// event stream comes back as rows (issue #5 ยง3.1 -- the workbench shells out to16// the lane CLIs). That is what keeps a lane's work inside its lane while the17// page that starts it imports nothing but the format and the score.18//19// Everything an operation may touch is configured when the workbench is20// mounted, never taken from a request: a request chooses among the operations21// below and may name a source, a target and a volume, and each of those three22// is validated before an argv exists. Nothing here composes a shell command;23// argv is a slice, and the operating system gets it as one.2425// Targets is where operations run: the binary, the directories, and the working26// directory a run starts in. A target that is empty is a target the workbench27// was not given, and every operation that needs it refuses by saying so.28type Targets struct {29 // Atlas is the pipeline binary. `atlas workbench` fills it with its own30 // path, so the workbench runs the same build of the pipeline that is31 // serving the page.32 Atlas string33 // Dir is the working directory operations run in. Empty is the workbench's34 // own, which is what absolute paths want.35 Dir string36 // Registry is the library of .atlas files: what the measurement pages read37 // and what compose, enrich and measure are pointed at.38 Registry string39 // Archive is the capture archive root -- the directory holding archive.json.40 Archive string41 // TileSet is the derived tile set directory, which `atlas tiles` writes.42 TileSet string43 // TileIndex is that tile set's register, which compose and enrich read. It44 // is stated rather than derived because the file's name is the generate45 // lane's to know, and the wiring that knows the lane fills it in.46 TileIndex string47}4849// An operation is one pipeline subcommand the page may run.50type operation struct {51 // Name is the operation as the form submits it and the CLI spells it.52 Name string53 // Summary is the one line the page prints beside the button.54 Summary string55 // Source says the operation is aimed at one capture source, so the form56 // asks for a source and a target.57 Source bool58 // Volumes says the operation accepts an optional volume slug narrowing it59 // to one volume.60 Volumes bool61 // needs are the configured targets without which it cannot run.62 needs []string63}6465// operations is the table, in the order the page lists them: the pipeline read66// left to right, capture first and measurement last.67var operations = []operation{68 {69 Name: "crawl",70 Summary: "fetch what a publisher serves into the capture archive",71 Source: true,72 needs: []string{"archive"},73 },74 {75 Name: "tiles",76 Summary: "derive raster pyramids from archived captures",77 needs: []string{"archive", "tile set"},78 },79 {80 Name: "compose",81 Summary: "build volumes from archived captures and derived pyramids",82 Volumes: true,83 needs: []string{"archive", "tile index", "registry"},84 },85 {86 Name: "enrich",87 Summary: "fold every reading of a volume together and build the richer volume",88 Volumes: true,89 needs: []string{"archive", "tile index", "registry"},90 },91 {92 Name: "measure",93 Summary: "score every build in the registry",94 Volumes: true,95 needs: []string{"registry"},96 },97}9899func operationByName(name string) (operation, bool) {100 for _, held := range operations {101 if held.Name == name {102 return held, true103 }104 }105 return operation{}, false106}107108// Ready reports whether this operation's targets are all configured, and what109// is missing when they are not. The page prints the answer beside the form110// rather than letting a person discover it by pressing the button.111func (o operation) Ready(t Targets) (bool, string) {112 var missing []string113 for _, need := range o.needs {114 if t.of(need) == "" {115 missing = append(missing, need)116 }117 }118 if t.Atlas == "" {119 missing = append(missing, "atlas binary")120 }121 if len(missing) == 0 {122 return true, ""123 }124 return false, "no " + strings.Join(missing, ", no ") + " configured"125}126127func (t Targets) of(need string) string {128 switch need {129 case "archive":130 return t.Archive131 case "tile set":132 return t.TileSet133 case "tile index":134 return t.TileIndex135 case "registry":136 return t.Registry137 }138 return ""139}140141// request is what a submission asks for, before anything is trusted.142type request struct {143 Operation string144 Source string145 Target string146 Volume string147}148149// plan turns a submission into the operation to run, or into the refusal a150// person should read. Every refusal is a 400: it is the request that is wrong,151// and none of them starts a subprocess.152func plan(targets Targets, sources []Source, asked request) (oprunner.Operation, error) {153 held, known := operationByName(asked.Operation)154 if !known {155 return oprunner.Operation{}, fmt.Errorf("unknown operation %q", asked.Operation)156 }157 if ready, why := held.Ready(targets); !ready {158 return oprunner.Operation{}, fmt.Errorf("%s cannot run here: %s", held.Name, why)159 }160161 argv := []string{targets.Atlas, held.Name, "--log-json"}162 switch held.Name {163 case "crawl":164 source, found := sourceByName(sources, asked.Source)165 if !found {166 return oprunner.Operation{}, fmt.Errorf("unknown source %q", asked.Source)167 }168 if !source.Crawlable {169 return oprunner.Operation{}, fmt.Errorf(170 "%s has no crawler registered: its captures are archived and its endpoints are not reached from here",171 source.Name)172 }173 if err := oprunner.ValidTarget(asked.Target, source.Pair); err != nil {174 return oprunner.Operation{}, fmt.Errorf("%s target %q: %w (expected %s)",175 source.Label, asked.Target, err, source.TargetHint)176 }177 argv = append(argv, "-archive", targets.Archive, "-source", source.Name, asked.Target)178 case "tiles":179 argv = append(argv, "-archive", targets.Archive, "-output", targets.TileSet)180 case "compose", "enrich":181 argv = append(argv, "-archive", targets.Archive,182 "-tiles", targets.TileIndex, "-bundles", targets.Registry)183 case "measure":184 argv = append(argv, "-bundles", targets.Registry)185 }186187 if asked.Volume != "" {188 if !held.Volumes {189 return oprunner.Operation{}, fmt.Errorf("%s does not take a volume", held.Name)190 }191 // A volume slug is a target like any other: it becomes a bare192 // argument, so it is held to the same rule that keeps an argument193 // from being read as a flag.194 if err := oprunner.ValidTarget(asked.Volume, false); err != nil {195 return oprunner.Operation{}, fmt.Errorf("volume %q: %w", asked.Volume, err)196 }197 argv = append(argv, asked.Volume)198 }199200 return oprunner.Operation{Name: held.Name, Dir: targets.Dir, Argv: argv}, nil201}202203// errNoOperation is what an empty submission gets. It is separated only so the204// handler's switch reads as a list of refusals.205var errNoOperation = errors.New("no operation was named")