diff --git a/.github/workflows/docker_build_mothd.yml b/.github/workflows/docker_build_mothd.yml deleted file mode 100644 index 63b8aaa..0000000 --- a/.github/workflows/docker_build_mothd.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Mothd Docker build -on: [push] - -jobs: - build-mothd: - name: Build mothd - runs-on: ubuntu-latest - steps: - - name: Retrieve code - uses: actions/checkout@v1 - - name: Build mothd - run: docker build . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1293514..c6c03aa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish Container image +name: Publish on: release: types: [published] @@ -10,18 +10,45 @@ jobs: steps: - name: Retrieve code uses: actions/checkout@v1 - - name: Push to GitHub Packages + + - name: Push moth to GitHub Packages uses: docker/build-push-action@v2 with: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + target: moth + file: build/package/Containerfile registry: docker.pkg.github.com repository: dirtbags/moth/moth tag_with_ref: true - - name: Push to Docker Hub + + - name: Push moth-devel to GitHub Packages + uses: docker/build-push-action@v2 + with: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + target: moth-devel + file: build/package/Containerfile + registry: docker.pkg.github.com + repository: dirtbags/moth/moth-devel + tag_with_ref: true + + - name: Push moth to Docker Hub uses: docker/build-push-action@v2 with: username: neale password: ${{ secrets.DOCKER_TOKEN }} - registry: dirtbags/moth + target: moth + file: build/packages/Containerfile + repository: dirtbags/moth + tag_with_ref: true + + - name: Push moth-devel to Docker Hub + uses: docker/build-push-action@v2 + with: + username: neale + password: ${{ secrets.DOCKER_TOKEN }} + target: moth-devel + file: build/packages/Containerfile + repository: dirtbags/moth-devel tag_with_ref: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f704c0e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: Tests +on: [push] + +jobs: + test-mothd: + name: Test mothd + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: 1.13 + - name: Retrieve code + uses: actions/checkout@v1 + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore index 4e8c7d1..ade229e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,5 @@ *.o .idea ./bin/ -build/ -cache/ -target/ puzzles __debug_bin diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b1183..2942bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,20 +4,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [4.0.0] - Unreleased -### Added -- New `transpile` command to replace some functionality of devel server - +## [v4.0.0] - Unreleased ### Changed - Major rewrite/refactor of `mothd` - - There are now providers for State, Puzzles, and Theme. Sqlite, Redis, or S3 should fit in easily. + - Clear separation of roles: State, Puzzles, and Theme + - Sqlite, Redis, or S3 should fit in easily now + - Will allow "dynamic" puzzles now, we just need a flag to enable it - Server no longer provides unlocked content - - Puzzle URLs are now just `/content/${cat}/${points}/` -- `state/until` is now `state/hours` and can specify multiple begin/end hours -- `state/disabled` is now `state/enabled` -- Mothball structure has changed substantially + - Puzzle URLs are now just `/content/${cat}/${points}/` + - Changes to `state` directory + - Most files now have a bit of (English) documentation at the beginning + - `state/until` is now `state/hours` and can specify multiple begin/end hours + - `state/disabled` is now `state/enabled` +- Mothball structure has changed - Mothballs no longer contain `map.txt` + - Mothballs no longer obfuscate content paths - Clients now expect unlocked puzzles to just be `map[string][]int` +- New `/state` API endpoint + - Provides *all* server state: event log, team mapping, messages, configuration + +### Added +- New `transpile` CLI command + - Provides `mothball` action to create mothballs + - Lets you test a few development server things, if you want ### Deprecated diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 3def45a..0000000 --- a/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM golang:1.13 AS builder -COPY go.* /src/ -COPY pkg /src/pkg/ -COPY cmd /src/cmd/ -COPY theme /target/theme/ -COPY example-puzzles /target/puzzles/ -COPY LICENSE.md /target/ -WORKDIR /src/ -RUN CGO_ENABLED=0 GOOS=linux go install -i -a -ldflags '-extldflags "-static"' ./... -RUN mkdir -p /target/bin -RUN cp /go/bin/* /target/bin/ - -FROM builder AS tester -RUN go test ./... - -FROM scratch -COPY --from=builder /target / - -ENTRYPOINT [ "/bin/mothd" ] diff --git a/README.md b/README.md index f46329b..47dfc91 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ Dirtbags Monarch Of The Hill Server ===================== -![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master) -![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=master) +![Build badge](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master) +![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth) Monarch Of The Hill (MOTH) is a puzzle server. We (the authors) have used it for instructional and contest events called diff --git a/VERSION b/VERSION deleted file mode 100644 index d5c0c99..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -3.5.1 diff --git a/build.sh b/build.sh deleted file mode 100755 index ac8d438..0000000 --- a/build.sh +++ /dev/null @@ -1,14 +0,0 @@ -#! /bin/sh - -set -e - -read version < VERSION - -cd $(dirname $0) -for img in moth moth-devel; do - echo "==== $img" - sudo docker build --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy --tag dirtbags/$img --tag dirtbags/$img:$version -f Dockerfile.$img . - [ "$1" = "-push" ] && docker push dirtbags/$img:$version && docker push dirtbags/$img:latest -done - -exit 0 diff --git a/build/package/Containerfile b/build/package/Containerfile new file mode 100644 index 0000000..8b4b94c --- /dev/null +++ b/build/package/Containerfile @@ -0,0 +1,37 @@ +FROM golang:1.13 AS builder +COPY go.* /src/ +COPY pkg /src/pkg/ +COPY cmd /src/cmd/ +COPY theme /target/theme/ +COPY example-puzzles /target/puzzles/ +COPY LICENSE.md /target/ +WORKDIR /src/ +RUN CGO_ENABLED=0 GOOS=linux go install -i -a -ldflags '-extldflags "-static"' ./... + +# I can't put these in /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin +RUN mkdir -p /target/bin/ +RUN cp /go/bin/* /target/ + +########## + +FROM builder AS tester +RUN go test ./... + +########## + +FROM scratch AS moth +COPY --from=builder /target / +ENTRYPOINT [ "/mothd" ] + +########## + +FROM ubuntu AS moth-devel +RUN apt-get -y update && apt-get -y install \ + build-essential \ + bsdgames \ + figlet toilet \ + python3 \ + python3-pil \ + lua5.3 +COPY --from=builder /bin/* / +CMD [ "/mothd", "-puzzles", "/puzzles" ] diff --git a/build/package/build.sh b/build/package/build.sh new file mode 100755 index 0000000..c9913a0 --- /dev/null +++ b/build/package/build.sh @@ -0,0 +1,22 @@ +#! /bin/sh + +set -e + +cd $(dirname $0)/../.. + +PODMAN=$(command -v podman || echo docker) +VERSION=$(cat CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}') + +for target in moth moth-devel; do + tag=dirtbags/$target:$VERSION + echo "==== Building $tag" + $PODMAN build \ + --build-arg http_proxy --build-arg https_proxy --build-arg no_proxy \ + --tag dirtbags/$target \ + --tag dirtbags/$target:$VERSION \ + --target $target \ + -f build/package/Containerfile . + [ "$1" = "-push" ] && docker push dirtbags/$target:$VERSION && docker push dirtbags/$img:latest +done + +exit 0 diff --git a/cmd/mothd/httpd.go b/cmd/mothd/httpd.go index a31c2da..66eb6f4 100644 --- a/cmd/mothd/httpd.go +++ b/cmd/mothd/httpd.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/dirtbags/moth/pkg/jsend" ) @@ -29,6 +30,10 @@ func NewHTTPServer(base string, server *MothServer) *HTTPServer { h.HandleMothFunc("/register", h.RegisterHandler) h.HandleMothFunc("/answer", h.AnswerHandler) h.HandleMothFunc("/content/", h.ContentHandler) + + if server.Config.Devel { + h.HandleMothFunc("/mothballer/", h.MothballerHandler) + } return h } @@ -128,16 +133,16 @@ func (h *HTTPServer) AnswerHandler(mh MothRequestHandler, w http.ResponseWriter, // ContentHandler returns static content from a given puzzle func (h *HTTPServer) ContentHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { - trimLen := len(h.base) + len("/content/") - parts := strings.SplitN(req.URL.Path[trimLen:], "/", 3) - if len(parts) < 3 { - http.Error(w, "Not Found", http.StatusNotFound) + parts := strings.SplitN(req.URL.Path[len(h.base)+1:], "/", 4) + if len(parts) < 4 { + http.NotFound(w, req) return } - cat := parts[0] - pointsStr := parts[1] - filename := parts[2] + // parts[0] == "content" + cat := parts[1] + pointsStr := parts[2] + filename := parts[3] if filename == "" { filename = "puzzle.json" @@ -154,3 +159,23 @@ func (h *HTTPServer) ContentHandler(mh MothRequestHandler, w http.ResponseWriter http.ServeContent(w, req, filename, mtime, mf) } + +// MothballerHandler returns a mothball +func (h *HTTPServer) MothballerHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { + parts := strings.SplitN(req.URL.Path[len(h.base)+1:], "/", 2) + if len(parts) < 2 { + http.NotFound(w, req) + return + } + + // parts[0] == "mothballer" + filename := parts[1] + cat := strings.TrimSuffix(filename, ".mb") + mothball, err := mh.Mothball(cat) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.ServeContent(w, req, filename, time.Now(), mothball) +} diff --git a/cmd/mothd/httpd_test.go b/cmd/mothd/httpd_test.go index 70be3c7..6bd66dc 100644 --- a/cmd/mothd/httpd_test.go +++ b/cmd/mothd/httpd_test.go @@ -8,6 +8,8 @@ import ( "net/url" "testing" "time" + + "github.com/spf13/afero" ) const TestParticipantID = "shipox" @@ -123,3 +125,39 @@ func TestHttpd(t *testing.T) { t.Error("Unexpected body", r.Body.String()) } } + +func TestDevelMemHttpd(t *testing.T) { + srv := NewTestServer() + + { + hs := NewHTTPServer("/", srv) + + if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 { + t.Error("Should have gotten a 404 for mothballer in prod mode") + } + } + + { + srv.Config.Devel = true + hs := NewHTTPServer("/", srv) + + if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 { + t.Log(r.Body.String()) + t.Log(r.Result()) + t.Error("Should have given us an internal server error, since category is a mothball") + } + } +} + +func TestDevelFsHttps(t *testing.T) { + fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata") + transpilerProvider := NewTranspilerProvider(fs) + srv := NewMothServer(Configuration{Devel: true}, NewTestTheme(), NewTestState(), transpilerProvider) + hs := NewHTTPServer("/", srv) + + if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 { + t.Log(r.Body.String()) + t.Log(r.Result()) + t.Error("Didn't get a Mothball") + } +} diff --git a/cmd/mothd/main.go b/cmd/mothd/main.go index 718348b..61bb823 100644 --- a/cmd/mothd/main.go +++ b/cmd/mothd/main.go @@ -2,7 +2,9 @@ package main import ( "flag" + "fmt" "mime" + "os" "time" "github.com/spf13/afero" @@ -44,8 +46,22 @@ func main() { "/", "Base URL of this instance", ) + seed := flag.String( + "seed", + "", + "Random seed to use, overrides $SEED", + ) flag.Parse() + // Set random seed + if *seed == "" { + *seed = os.Getenv("SEED") + } + if *seed == "" { + *seed = fmt.Sprintf("%d%d", os.Getpid(), time.Now().Unix()) + } + os.Setenv("SEED", *seed) + osfs := afero.NewOsFs() theme := NewTheme(afero.NewBasePathFs(osfs, *themePath)) state := NewState(afero.NewBasePathFs(osfs, *statePath)) diff --git a/cmd/mothd/mothballs.go b/cmd/mothd/mothballs.go index e78e3c0..deab589 100644 --- a/cmd/mothd/mothballs.go +++ b/cmd/mothd/mothballs.go @@ -3,6 +3,7 @@ package main import ( "archive/zip" "bufio" + "bytes" "fmt" "io" "log" @@ -172,6 +173,11 @@ func (m *Mothballs) refresh() { } } +// Mothball just returns an error +func (m *Mothballs) Mothball(cat string) (*bytes.Reader, error) { + return nil, fmt.Errorf("Can't repackage a compiled mothball") +} + // Maintain performs housekeeping for Mothballs. func (m *Mothballs) Maintain(updateInterval time.Duration) { m.refresh() diff --git a/cmd/mothd/providercommand.go b/cmd/mothd/providercommand.go index 1af9766..75e46b8 100644 --- a/cmd/mothd/providercommand.go +++ b/cmd/mothd/providercommand.go @@ -4,6 +4,7 @@ package main import ( "bytes" "context" + "fmt" "io" "log" "os" @@ -122,6 +123,11 @@ func (pc ProviderCommand) CheckAnswer(cat string, points int, answer string) (bo return true, nil } +// Mothball just returns an error +func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) { + return nil, fmt.Errorf("Can't package a command-generated category") +} + // Maintain does nothing: a command puzzle ProviderCommand has no housekeeping func (pc ProviderCommand) Maintain(updateInterval time.Duration) { } diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index 781f7c1..a355cf7 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "io" "strconv" @@ -41,6 +42,7 @@ type PuzzleProvider interface { Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Inventory() []Category CheckAnswer(cat string, points int, answer string) (bool, error) + Mothball(cat string) (*bytes.Reader, error) Maintainer } @@ -229,3 +231,16 @@ func (mh *MothRequestHandler) ExportState() *StateExport { return &export } + +// Mothball generates a mothball for the given category. +func (mh *MothRequestHandler) Mothball(cat string) (r *bytes.Reader, err error) { + if !mh.Config.Devel { + return nil, fmt.Errorf("Cannot mothball in production mode") + } + for _, provider := range mh.PuzzleProviders { + if r, err = provider.Mothball(cat); err == nil { + return r, nil + } + } + return nil, err +} diff --git a/cmd/mothd/transpiler.go b/cmd/mothd/transpiler.go index 042e213..ddda86b 100644 --- a/cmd/mothd/transpiler.go +++ b/cmd/mothd/transpiler.go @@ -69,6 +69,12 @@ func (p TranspilerProvider) CheckAnswer(cat string, points int, answer string) ( return c.Answer(points, answer), nil } +// Mothball packages up a category into a mothball. +func (p TranspilerProvider) Mothball(cat string) (*bytes.Reader, error) { + c := transpile.NewFsCategory(p.fs, cat) + return transpile.Mothball(c) +} + // Maintain performs housekeeping. func (p TranspilerProvider) Maintain(updateInterval time.Duration) { // Nothing to do here. diff --git a/cmd/transpile/main.go b/cmd/transpile/main.go index 00c9dee..efa0007 100644 --- a/cmd/transpile/main.go +++ b/cmd/transpile/main.go @@ -32,17 +32,21 @@ func nothing() error { return nil } +func usage(w io.Writer) { + fmt.Fprintln(w, "Usage: transpile COMMAND [flags]") + fmt.Fprintln(w, "") + fmt.Fprintln(w, " mothball: Compile a mothball") + fmt.Fprintln(w, " inventory: Show category inventory") + fmt.Fprintln(w, " open: Open a file for a puzzle") + fmt.Fprintln(w, " answer: Check correctness of an answer") +} + // ParseArgs parses arguments and runs the appropriate action. func (t *T) ParseArgs() (Command, error) { var cmd Command if len(t.Args) == 1 { - fmt.Fprintln(t.Stderr, "Usage: transpile COMMAND [flags]") - fmt.Fprintln(t.Stderr, "") - fmt.Fprintln(t.Stderr, " mothball: Compile a mothball") - fmt.Fprintln(t.Stderr, " inventory: Show category inventory") - fmt.Fprintln(t.Stderr, " open: Open a file for a puzzle") - fmt.Fprintln(t.Stderr, " answer: Check correctness of an answer") + usage(t.Stderr) return nothing, nil } @@ -60,7 +64,11 @@ func (t *T) ParseArgs() (Command, error) { case "answer": flags.StringVar(&t.answer, "answer", "", "Answer to check") cmd = t.CheckAnswer + case "help": + usage(t.Stderr) + return nothing, nil default: + usage(t.Stderr) return nothing, fmt.Errorf("%s is not a valid command", t.Args[1]) } @@ -69,6 +77,7 @@ func (t *T) ParseArgs() (Command, error) { return nothing, err } if *directory != "" { + log.Println(*directory) t.fs = afero.NewBasePathFs(t.BaseFs, *directory) } else { t.fs = t.BaseFs @@ -101,6 +110,7 @@ func (t *T) PrintInventory() error { } // DumpFile writes a file to the writer. +// BUG(neale): The "open" and "answer" actions don't work on categories with an "mkcategory" executable. func (t *T) DumpFile() error { puzzle := transpile.NewFsPuzzle(t.fs) @@ -160,6 +170,7 @@ func main() { Stdout: os.Stdout, Stderr: os.Stderr, Args: os.Args, + BaseFs: afero.NewOsFs(), } cmd, err := t.ParseArgs() if err != nil { diff --git a/example-puzzles/example/3/mkpuzzle b/example-puzzles/example/3/mkpuzzle index 92df1da..5863e13 100755 --- a/example-puzzles/example/3/mkpuzzle +++ b/example-puzzles/example/3/mkpuzzle @@ -7,26 +7,12 @@ import random import shutil import sys -parser = argparse.ArgumentParser("Generate a puzzle") -parser.add_argument("--file", dest="file", help="File to provide, instead of puzzle") -parser.add_argument("--answer", dest="answer", help="Answer to check, instead of providing puzzle") -args = parser.parse_args() - -seed = hash(os.getenv("SEED")) -random.seed(seed) +random.seed(os.getenv("SEED", "")) words = ["apple", "pear", "peach", "tangerine", "orange", "potato", "carrot", "pea"] answer = ' '.join(random.sample(words, 4)) -if args.file: - f = open(args.file, "rb") - shutil.copyfileobj(f, sys.stdout.buffer) -elif args.answer: - if args.answer == answer: - print("correct") - else: - print("incorrect") -else: +def puzzle(): number = random.randint(20, 500) obj = { "Pre": { @@ -53,3 +39,22 @@ else: } } json.dump(obj, sys.stdout) + +def open_file(filename): + f = open(filename, "rb") + shutil.copyfileobj(f, sys.stdout.buffer) + +def check_answer(check): + if answer == check: + print("correct") + else: + print("incorrect") + +if len(sys.argv) == 1: + puzzle() +elif sys.argv[1] == "file": + open_file(sys.argv[2]) +elif sys.argv[1] == "answer": + check_answer(sys.argv[2]) +else: + raise RuntimeError("Unknown command: %s" % sys.argv[1]) diff --git a/pkg/transpile/category.go b/pkg/transpile/category.go index fb67339..887ae61 100644 --- a/pkg/transpile/category.go +++ b/pkg/transpile/category.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "fmt" "log" "os/exec" + "path" "strconv" "strings" "time" @@ -120,14 +122,22 @@ type FsCommandCategory struct { timeout time.Duration } -// Inventory returns a list of point values for this category. -func (c FsCommandCategory) Inventory() ([]int, error) { +func (c FsCommandCategory) run(command string, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), c.timeout) defer cancel() - cmd := exec.CommandContext(ctx, c.command, "inventory") - stdout, err := cmd.Output() - if err != nil { + cmdargs := append([]string{command}, args...) + cmd := exec.CommandContext(ctx, "./"+path.Base(c.command), cmdargs...) + cmd.Dir = path.Dir(c.command) + return cmd.Output() +} + +// Inventory returns a list of point values for this category. +func (c FsCommandCategory) Inventory() ([]int, error) { + stdout, err := c.run("inventory") + if exerr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("inventory: %s: %s", err, string(exerr.Stderr)) + } else if err != nil { return nil, err } @@ -143,11 +153,7 @@ func (c FsCommandCategory) Inventory() ([]int, error) { func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) { var p Puzzle - ctx, cancel := context.WithTimeout(context.Background(), c.timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, c.command, "puzzle", strconv.Itoa(points)) - stdout, err := cmd.Output() + stdout, err := c.run("puzzle", strconv.Itoa(points)) if err != nil { return p, err } @@ -163,21 +169,13 @@ func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) { // Open returns an io.ReadCloser for the given filename. func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) { - ctx, cancel := context.WithTimeout(context.Background(), c.timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, c.command, "file", strconv.Itoa(points), filename) - stdout, err := cmd.Output() + stdout, err := c.run("file", strconv.Itoa(points), filename) return nopCloser{bytes.NewReader(stdout)}, err } // Answer checks whether an answer is correct. func (c FsCommandCategory) Answer(points int, answer string) bool { - ctx, cancel := context.WithTimeout(context.Background(), c.timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, c.command, "answer", strconv.Itoa(points), answer) - stdout, err := cmd.Output() + stdout, err := c.run("answer", strconv.Itoa(points), answer) if err != nil { log.Printf("ERROR: Answering %d points: %s", points, err) return false diff --git a/pkg/transpile/common_test.go b/pkg/transpile/common_test.go index c838fc1..c2c9d94 100644 --- a/pkg/transpile/common_test.go +++ b/pkg/transpile/common_test.go @@ -13,7 +13,7 @@ pre: - Buster - DW attachments: - - filename: moo.txt + - moo.txt --- YAML body `) diff --git a/pkg/transpile/inventory.go b/pkg/transpile/inventory.go index 8c6f750..48abe3f 100644 --- a/pkg/transpile/inventory.go +++ b/pkg/transpile/inventory.go @@ -3,6 +3,7 @@ package transpile import ( "log" "sort" + "strings" "github.com/spf13/afero" ) @@ -20,12 +21,16 @@ func FsInventory(fs afero.Fs) (Inventory, error) { inv := make(Inventory) for _, ent := range dirEnts { + if strings.HasPrefix(ent.Name(), ".") { + continue + } if ent.IsDir() { name := ent.Name() c := NewFsCategory(fs, name) puzzles, err := c.Inventory() if err != nil { - return nil, err + log.Printf("Inventory: %s: %s", name, err) + continue } sort.Ints(puzzles) inv[name] = puzzles diff --git a/pkg/transpile/mothball.go b/pkg/transpile/mothball.go index 1ef87f2..b393802 100644 --- a/pkg/transpile/mothball.go +++ b/pkg/transpile/mothball.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "os/exec" ) // Mothball packages a Category up for a production server run. @@ -37,7 +38,7 @@ func Mothball(c Category) (*bytes.Reader, error) { } puzzle, err := c.Puzzle(points) if err != nil { - return nil, err + return nil, fmt.Errorf("Puzzle %d: %s", points, err) } // Record answers in answers.txt @@ -45,13 +46,16 @@ func Mothball(c Category) (*bytes.Reader, error) { fmt.Fprintln(answersTxt, points, answer) } - // Remove all answers from puzzle object + // Remove answers and debugging from puzzle object puzzle.Answers = []string{} + puzzle.Debug.Errors = []string{} + puzzle.Debug.Hints = []string{} + puzzle.Debug.Log = []string{} // Write out Puzzle object penc := json.NewEncoder(pw) if err := penc.Encode(puzzle); err != nil { - return nil, err + return nil, fmt.Errorf("Puzzle %d: %s", points, err) } // Write out all attachments and scripts @@ -63,11 +67,13 @@ func Mothball(c Category) (*bytes.Reader, error) { return nil, err } ar, err := c.Open(points, att) - if err != nil { - return nil, err + if exerr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("Puzzle %d: %s: %s: %s", points, att, err, string(exerr.Stderr)) + } else if err != nil { + return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err) } if _, err := io.Copy(aw, ar); err != nil { - return nil, err + return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err) } } } diff --git a/pkg/transpile/puzzle.go b/pkg/transpile/puzzle.go index 11f6b9c..ff9c2e2 100644 --- a/pkg/transpile/puzzle.go +++ b/pkg/transpile/puzzle.go @@ -92,6 +92,26 @@ type StaticAttachment struct { FilesystemPath string // Filename in backing FS (URL, mothball, or local FS) } +// UnmarshalYAML allows a StaticAttachment to be specified as a single string. +// The way the yaml library works is weird. +func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error { + if err := unmarshal(&sa.Filename); err == nil { + sa.FilesystemPath = sa.Filename + return nil + } + + parts := new(struct { + Filename string + FilesystemPath string + }) + if err := unmarshal(parts); err != nil { + return err + } + sa.Filename = parts.Filename + sa.FilesystemPath = parts.FilesystemPath + return nil +} + // ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer. type ReadSeekCloser interface { io.Reader @@ -307,8 +327,16 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) { p.Debug.Summary = val[0] case "hint": p.Debug.Hints = val + case "solution": + p.Debug.Hints = val case "ksa": p.Post.KSAs = val + case "objective": + p.Post.Objective = val[0] + case "success.acceptable": + p.Post.Success.Acceptable = val[0] + case "success.mastery": + p.Post.Success.Mastery = val[0] default: return p, fmt.Errorf("Unknown header field: %s", key) } @@ -338,18 +366,19 @@ type FsCommandPuzzle struct { timeout time.Duration } -func (fp FsCommandPuzzle) run(args ...string) ([]byte, error) { +func (fp FsCommandPuzzle) run(command string, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), fp.timeout) defer cancel() - cmd := exec.CommandContext(ctx, "./"+path.Base(fp.command), args...) + cmdargs := append([]string{command}, args...) + cmd := exec.CommandContext(ctx, "./"+path.Base(fp.command), cmdargs...) cmd.Dir = path.Dir(fp.command) return cmd.Output() } // Puzzle returns a Puzzle struct for the current puzzle. func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) { - stdout, err := fp.run() + stdout, err := fp.run("puzzle") if exiterr, ok := err.(*exec.ExitError); ok { return Puzzle{}, errors.New(string(exiterr.Stderr)) } else if err != nil { @@ -379,7 +408,7 @@ func (c nopCloser) Close() error { // Open returns a newly-opened file. // BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files. func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) { - stdout, err := fp.run("--file", filename) + stdout, err := fp.run("file", filename) buf := nopCloser{bytes.NewReader(stdout)} if err != nil { return buf, err @@ -390,7 +419,7 @@ func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) { // Answer checks whether the given answer is correct. func (fp FsCommandPuzzle) Answer(answer string) bool { - stdout, err := fp.run("--answer", answer) + stdout, err := fp.run("answer", answer) if err != nil { log.Printf("ERROR: checking answer: %s", err) return false diff --git a/pkg/transpile/puzzle_test.go b/pkg/transpile/puzzle_test.go index 288cde5..a6fc813 100644 --- a/pkg/transpile/puzzle_test.go +++ b/pkg/transpile/puzzle_test.go @@ -136,3 +136,35 @@ func TestFsPuzzle(t *testing.T) { t.Error("Error answer marked correct") } } + +func TestAttachment(t *testing.T) { + buf := bytes.NewBufferString(` +pre: + attachments: + - simple + - filename: complex + filesystempath: backingfile +`) + p, err := yamlHeaderParser(buf) + if err != nil { + t.Error(err) + return + } + + att := p.Pre.Attachments + if len(att) != 2 { + t.Error("Wrong number of attachments", att) + } + if att[0].Filename != "simple" { + t.Error("Attachment 0 wrong") + } + if att[0].Filename != att[0].FilesystemPath { + t.Error("Attachment 0 wrong") + } + if att[1].Filename != "complex" { + t.Error("Attachment 1 wrong") + } + if att[1].FilesystemPath != "backingfile" { + t.Error("Attachment 2 wrong") + } +} diff --git a/pkg/transpile/testdata/generated/mkcategory b/pkg/transpile/testdata/generated/mkcategory index cfa4892..67d96f9 100755 --- a/pkg/transpile/testdata/generated/mkcategory +++ b/pkg/transpile/testdata/generated/mkcategory @@ -20,9 +20,9 @@ case $1:$2:$3 in } } EOT - ;; - puzzle:*:) - fail "No such puzzle" + ;; + puzzle:*) + fail "No such puzzle: $2" ;; file:1:moo.txt) echo "Moo." diff --git a/pkg/transpile/testdata/static/3/mkpuzzle b/pkg/transpile/testdata/static/3/mkpuzzle index df19db0..8c28a89 100755 --- a/pkg/transpile/testdata/static/3/mkpuzzle +++ b/pkg/transpile/testdata/static/3/mkpuzzle @@ -1,7 +1,12 @@ #! /bin/sh -case $1 in - "") +fail () { + echo "ERROR: $*" 1>&2 + exit 1 +} + +case $1:$2 in + puzzle:) cat <<'EOT' { "Answers": ["answer"], @@ -12,34 +17,23 @@ case $1 in } EOT ;; - -file|--file) - case $2 in - moo.txt) - echo "Moo." - ;; - *) - echo "ERROR: no such file: $1" 1>&2 - exit 1 - ;; - esac + file:moo.txt) + echo "Moo." ;; - -answer|--answer) - case $2 in - moo) - echo "correct" - ;; - error) - echo "error" 1>&2 - exit 1 - ;; - *) - echo "incorrect" - ;; - esac + file:*) + fail "no such file: $1" + ;; + answer:moo) + echo "correct" + ;; + answer:error) + fail "you requested an error" + ;; + answer:*) + echo "incorrect" ;; *) - echo "ERROR: don't know what to do with $1" 1>&2 - exit 1 + fail "What is $1" ;; esac \ No newline at end of file diff --git a/theme/basic.css b/theme/basic.css index f00195e..14a5a1e 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -93,6 +93,7 @@ input:invalid { #devel { background-color: #eee; color: black; + overflow: scroll; } #devel .string { color: #9c27b0; diff --git a/theme/puzzle.js b/theme/puzzle.js index 49f03ce..9c8e42a 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -88,11 +88,6 @@ function submit(e) { e.preventDefault() let data = new FormData(e.target) - // Kludge for patterned answers - let xAnswer = data.get("xAnswer") - if (xAnswer) { - data.set("answer", xAnswer) - } window.data = data fetch("answer", { method: "POST", @@ -193,7 +188,6 @@ function answerCheck(e) { checkAnswer(answer) .then (correct => { - document.querySelector("[name=xAnswer").value = correct || answer if (correct) { ok.textContent = "⭕" ok.title = "Possibly correct"