Compare commits
4 Commits
fix-pack-s
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| eb76daaaba | |||
| 1fbc37d669 | |||
| 100b04b876 | |||
| 62b1538b6a |
|
|
@ -19,8 +19,8 @@ jobs:
|
||||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519.pub
|
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519.pub
|
||||||
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub
|
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub
|
||||||
ssh -o StrictHostKeyChecking=accept-new -T git@focs.gc.sjtu.edu.cn -p 2222
|
ssh -o StrictHostKeyChecking=accept-new -T git@focs.gc.sjtu.edu.cn -p 2222
|
||||||
# - name: Lint
|
- name: Lint
|
||||||
# run: make lint
|
run: make lint
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make build
|
run: make build
|
||||||
- name: Version
|
- name: Version
|
||||||
|
|
|
||||||
14
Makefile
14
Makefile
|
|
@ -8,6 +8,7 @@ DATE := $(shell date +"%Y%m%d-%H%M%S")
|
||||||
VERSION := $(COMMIT_HASH)-$(DATE)
|
VERSION := $(COMMIT_HASH)-$(DATE)
|
||||||
LDFLAGS := -s -w -X main.Version=$(VERSION)
|
LDFLAGS := -s -w -X main.Version=$(VERSION)
|
||||||
GOFLAGS := -trimpath -mod=readonly -buildvcs=false
|
GOFLAGS := -trimpath -mod=readonly -buildvcs=false
|
||||||
|
COVERAGE_FILE ?= coverage.out
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
|
|
@ -27,18 +28,15 @@ lint:
|
||||||
prepare-test:
|
prepare-test:
|
||||||
git submodule update --init --remote
|
git submodule update --init --remote
|
||||||
|
|
||||||
test:
|
test: build
|
||||||
./scripts/prepare_test_repos.sh $(TMP_DIR)
|
./scripts/prepare_test_repos.sh $(TMP_DIR)
|
||||||
# no clang-tidy-18 locally
|
# no clang-tidy-18 locally
|
||||||
rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/keyword/clangtidy
|
rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/keyword/clangtidy
|
||||||
go test -count=1 -v ./...
|
go test -count=1 -v -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./...
|
||||||
|
go tool cover -func=$(COVERAGE_FILE) | tail -n 1
|
||||||
local-test:
|
|
||||||
rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/
|
|
||||||
mkdir -p $(TMP_DIR)/submodules/JOJ3-examples/examples/
|
|
||||||
go test -count=1 -v ./...
|
|
||||||
|
|
||||||
ci-test:
|
ci-test:
|
||||||
./scripts/prepare_test_repos.sh $(TMP_DIR)
|
./scripts/prepare_test_repos.sh $(TMP_DIR)
|
||||||
./scripts/run_foreach_test_repos.sh $(TMP_DIR) "sed -i '2i \ \ \"sandboxExecServer\": \"172.17.0.1:5051\",' conf.json"
|
./scripts/run_foreach_test_repos.sh $(TMP_DIR) "sed -i '2i \ \ \"sandboxExecServer\": \"172.17.0.1:5051\",' conf.json"
|
||||||
GITHUB_ACTOR="" go test -count=1 -v ./...
|
GITHUB_ACTOR="" go test -count=1 -v -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./...
|
||||||
|
go tool cover -func=$(COVERAGE_FILE) | tail -n 1
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# JOJ3
|
# JOJ3
|
||||||
|
|
||||||
[](https://goreportcard.com/report/github.com/joint-online-judge/JOJ3)
|
[](https://focs.gc.sjtu.edu.cn/git/JOJ/JOJ3/actions?workflow=build.yaml)
|
||||||
[](https://pkg.go.dev/github.com/joint-online-judge/JOJ3)
|
[](https://pkg.go.dev/github.com/joint-online-judge/JOJ3)
|
||||||
[](https://deepwiki.com/joint-online-judge/JOJ3)
|
[](https://deepwiki.com/joint-online-judge/JOJ3)
|
||||||
|
|
||||||
|
|
@ -100,6 +100,7 @@ Here are the steps `joj3` will run.
|
||||||
3. Generate stages.
|
3. Generate stages.
|
||||||
- We have an empty list of stages at the beginning.
|
- We have an empty list of stages at the beginning.
|
||||||
- We check all the stages from the configuration file. Stages with empty `group` field will always be added. Stages with non-empty `group` field requires that value (case insensitive) appears in the commit group. e.g. with commit msg `feat(h5/e3): joj msan [joj]`, stages with the following `group` field will run: `""`, `"joj"`. Currently, it does not support multiple groups within one commit. If the group specified in the commit message is `[all]`, then all groups will run.
|
- We check all the stages from the configuration file. Stages with empty `group` field will always be added. Stages with non-empty `group` field requires that value (case insensitive) appears in the commit group. e.g. with commit msg `feat(h5/e3): joj msan [joj]`, stages with the following `group` field will run: `""`, `"joj"`. Currently, it does not support multiple groups within one commit. If the group specified in the commit message is `[all]`, then all groups will run.
|
||||||
|
- Groups are matched as case-insensitive, comma/space/semicolon/pipe-separated tokens. For example, `[joj, lint]` selects the `joj` and `lint` groups without substring matching.
|
||||||
- Every stage needs to have an unique `name`, which means if two stages have the same name, only the first one will be added.
|
- Every stage needs to have an unique `name`, which means if two stages have the same name, only the first one will be added.
|
||||||
4. Run stages.
|
4. Run stages.
|
||||||
- By default, all the stages will run sequentially.
|
- By default, all the stages will run sequentially.
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,9 @@ func GetConfPath(confRoot, confName, fallbackConfName, msg, tag string) (
|
||||||
hintValidScopes(confRoot, confName)
|
hintValidScopes(confRoot, confName)
|
||||||
}
|
}
|
||||||
slog.Error("stat conf", "error", err)
|
slog.Error("stat conf", "error", err)
|
||||||
|
if tag != "" {
|
||||||
|
return confPath, confStat, conventionalCommit, err
|
||||||
|
}
|
||||||
// fallback to conf file in conf root on conf not exist
|
// fallback to conf file in conf root on conf not exist
|
||||||
confPath = filepath.Join(confRoot, fallbackConfName)
|
confPath = filepath.Join(confRoot, fallbackConfName)
|
||||||
slog.Info("fallback to conf", "path", confPath)
|
slog.Info("fallback to conf", "path", confPath)
|
||||||
|
|
@ -201,8 +204,13 @@ func GetConfPath(confRoot, confName, fallbackConfName, msg, tag string) (
|
||||||
func MatchGroups(conf *Conf, conventionalCommit *ConventionalCommit) []string {
|
func MatchGroups(conf *Conf, conventionalCommit *ConventionalCommit) []string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
keywords := []string{}
|
keywords := []string{}
|
||||||
loweredCommitGroup := strings.ToLower(conventionalCommit.Group)
|
requestedGroups := make(map[string]bool)
|
||||||
matchAllGroups := loweredCommitGroup == "all"
|
for _, group := range strings.FieldsFunc(conventionalCommit.Group, func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == '|' || r == ' ' || r == '\t'
|
||||||
|
}) {
|
||||||
|
requestedGroups[strings.ToLower(group)] = true
|
||||||
|
}
|
||||||
|
matchAllGroups := requestedGroups["all"]
|
||||||
confStages := []ConfStage{}
|
confStages := []ConfStage{}
|
||||||
confStages = append(confStages, conf.PreStages...)
|
confStages = append(confStages, conf.PreStages...)
|
||||||
confStages = append(confStages, conf.Stages...)
|
confStages = append(confStages, conf.Stages...)
|
||||||
|
|
@ -221,7 +229,7 @@ func MatchGroups(conf *Conf, conventionalCommit *ConventionalCommit) []string {
|
||||||
slog.Info("group keywords from stages", "keywords", keywords)
|
slog.Info("group keywords from stages", "keywords", keywords)
|
||||||
groups := []string{}
|
groups := []string{}
|
||||||
for _, keyword := range keywords {
|
for _, keyword := range keywords {
|
||||||
if matchAllGroups || strings.Contains(loweredCommitGroup, keyword) {
|
if matchAllGroups || requestedGroups[keyword] {
|
||||||
groups = append(groups, keyword)
|
groups = append(groups, keyword)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,89 @@
|
||||||
package conf
|
package conf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestConfLogValueRedactsSandboxToken(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||||
|
conf := &Conf{
|
||||||
|
Name: "test",
|
||||||
|
SandboxToken: "top-secret-token",
|
||||||
|
}
|
||||||
|
logger.Info("config", "conf", conf)
|
||||||
|
got := output.String()
|
||||||
|
if strings.Contains(got, "top-secret-token") {
|
||||||
|
t.Fatalf("configuration log exposed sandbox token: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "[REDACTED]") || !strings.Contains(got, `"Name":"test"`) {
|
||||||
|
t.Fatalf("configuration log lost expected diagnostic fields: %s", got)
|
||||||
|
}
|
||||||
|
if conf.SandboxToken != "top-secret-token" {
|
||||||
|
t.Fatalf("logging mutated the runtime configuration: %q", conf.SandboxToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSHA256(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "input")
|
||||||
|
content := []byte("joj3")
|
||||||
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantSum := sha256.Sum256(content)
|
||||||
|
got, err := GetSHA256(path)
|
||||||
|
if err != nil || got != hex.EncodeToString(wantSum[:]) {
|
||||||
|
t.Fatalf("GetSHA256() = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := GetSHA256(path + ".missing"); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("missing GetSHA256() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfPath(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
scopedDir := filepath.Join(root, "course")
|
||||||
|
if err := os.Mkdir(scopedDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{filepath.Join(scopedDir, "conf.json"), filepath.Join(root, "fallback.json")} {
|
||||||
|
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _, commit, err := GetConfPath(root, "conf.json", "fallback.json", "test(course): run", "")
|
||||||
|
if err != nil || got != filepath.Join(scopedDir, "conf.json") || commit.Scope != "course" {
|
||||||
|
t.Fatalf("GetConfPath(scoped) = %q, %+v, %v", got, commit, err)
|
||||||
|
}
|
||||||
|
got, _, _, err = GetConfPath(root, "conf.json", "fallback.json", "not conventional", "")
|
||||||
|
if err != nil || got != filepath.Join(root, "fallback.json") {
|
||||||
|
t.Fatalf("GetConfPath(fallback) = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, _, err = parseMsg(root, "conf.json", "test(../escape): run", ""); err == nil || !strings.Contains(err.Error(), "invalid scope") {
|
||||||
|
t.Fatalf("parseMsg(traversal) error = %v", err)
|
||||||
|
}
|
||||||
|
got, _, commit, err = GetConfPath(root, "conf.json", "fallback.json", "ignored", "missing-tag")
|
||||||
|
if !os.IsNotExist(err) || got != filepath.Join(root, "missing-tag", "conf.json") || commit.Scope != "missing-tag" {
|
||||||
|
t.Fatalf("GetConfPath(tag) = %q, %+v, %v", got, commit, err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(root, "fallback.json")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _, _, err = GetConfPath(root, "conf.json", "fallback.json", "invalid", "")
|
||||||
|
if !os.IsNotExist(err) || got != filepath.Join(root, "fallback.json") {
|
||||||
|
t.Fatalf("GetConfPath(missing fallback) = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseConventionalCommit(t *testing.T) {
|
func TestParseConventionalCommit(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -130,3 +209,34 @@ func TestParseConventionalCommit(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchGroupsUsesExactTokens(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
group string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "exact not substring", group: "cpp", want: []string{"cpp"}},
|
||||||
|
{name: "case insensitive", group: "CPP", want: []string{"cpp"}},
|
||||||
|
{name: "comma and space", group: "cpp, lint", want: []string{"cpp", "lint"}},
|
||||||
|
{name: "semicolon", group: "cpp;lint", want: []string{"cpp", "lint"}},
|
||||||
|
{name: "pipe", group: "cpp|lint", want: []string{"cpp", "lint"}},
|
||||||
|
{name: "tab", group: "cpp\tlint", want: []string{"cpp", "lint"}},
|
||||||
|
{name: "duplicate token", group: "cpp,cpp", want: []string{"cpp"}},
|
||||||
|
{name: "all", group: "ALL", want: []string{"c", "cpp", "lint"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
conf := &Conf{Stages: []ConfStage{
|
||||||
|
{Name: "short", Groups: []string{"c"}},
|
||||||
|
{Name: "cpp", Groups: []string{"cpp"}},
|
||||||
|
{Name: "lint", Groups: []string{"lint"}},
|
||||||
|
}}
|
||||||
|
got := MatchGroups(conf, &ConventionalCommit{Group: tt.group})
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("MatchGroups() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package conf
|
package conf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
"github.com/joint-online-judge/JOJ3/internal/stage"
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -32,6 +34,16 @@ type Conf struct {
|
||||||
PostStages []ConfStage
|
PostStages []ConfStage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogValue preserves the configuration's diagnostic value without writing the
|
||||||
|
// sandbox credential to text or structured logs.
|
||||||
|
func (c Conf) LogValue() slog.Value {
|
||||||
|
type logConf Conf
|
||||||
|
if c.SandboxToken != "" {
|
||||||
|
c.SandboxToken = "[REDACTED]"
|
||||||
|
}
|
||||||
|
return slog.AnyValue(logConf(c))
|
||||||
|
}
|
||||||
|
|
||||||
type OptionalCmd struct {
|
type OptionalCmd struct {
|
||||||
Args *[]string
|
Args *[]string
|
||||||
Env *[]string
|
Env *[]string
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ func run(conf *joj3Conf.Conf, conventionalCommit *joj3Conf.ConventionalCommit) e
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("stage run", "error", err)
|
slog.Error("stage run", "error", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if forceQuitStageName != "" {
|
if forceQuitStageName != "" {
|
||||||
slog.Info("stage force quit", "name", forceQuitStageName)
|
slog.Info("stage force quit", "name", forceQuitStageName)
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,16 @@ func TestRun(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
// The repo-health-checker package runs all healthcheck fixtures directly so
|
||||||
|
// their execution contributes to Go coverage. Keep one sandbox case here as
|
||||||
|
// an end-to-end binary/executor/parser smoke test.
|
||||||
|
if strings.HasPrefix(tt, "healthcheck/") && tt != "healthcheck/release" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
t.Run(tt, func(t *testing.T) {
|
t.Run(tt, func(t *testing.T) {
|
||||||
|
if tt == "healthcheck/release" {
|
||||||
|
prepareLargeCopyInFixture(t, filepath.Join(root, tt))
|
||||||
|
}
|
||||||
t.Chdir(fmt.Sprintf("%s%s", root, tt))
|
t.Chdir(fmt.Sprintf("%s%s", root, tt))
|
||||||
os.Args = []string{"./joj3"}
|
os.Args = []string{"./joj3"}
|
||||||
outputFile := "joj3_result.json"
|
outputFile := "joj3_result.json"
|
||||||
|
|
@ -106,3 +115,54 @@ func TestRun(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareLargeCopyInFixture(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
const fileCount = 1001
|
||||||
|
filesDir := filepath.Join(dir, "many-files")
|
||||||
|
if err := os.Mkdir(filesDir, 0o700); err != nil && !os.IsExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := range fileCount {
|
||||||
|
path := filepath.Join(filesDir, fmt.Sprintf("%04d", i))
|
||||||
|
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(filesDir) })
|
||||||
|
|
||||||
|
confPath := filepath.Join(dir, "conf.json")
|
||||||
|
original, err := os.ReadFile(confPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var conf map[string]any
|
||||||
|
if err := json.Unmarshal(original, &conf); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stages := conf["stages"].([]any)
|
||||||
|
executor := stages[0].(map[string]any)["executor"].(map[string]any)
|
||||||
|
with := executor["with"].(map[string]any)
|
||||||
|
command := with["default"].(map[string]any)
|
||||||
|
args := command["args"].([]any)
|
||||||
|
checkerArgs := make([]string, 0, len(args))
|
||||||
|
for _, arg := range args {
|
||||||
|
checkerArgs = append(checkerArgs, fmt.Sprintf("%q", arg))
|
||||||
|
}
|
||||||
|
command["args"] = []string{
|
||||||
|
"/bin/sh", "-c",
|
||||||
|
fmt.Sprintf("test \"$(find many-files -type f | wc -l)\" -eq %d && exec %s", fileCount, strings.Join(checkerArgs, " ")),
|
||||||
|
}
|
||||||
|
patched, err := json.Marshal(conf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(confPath, patched, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.WriteFile(confPath, original, 0o600); err != nil {
|
||||||
|
t.Errorf("restore conf: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -124,6 +126,7 @@ func newErrorStageResults(err error) ([]stage.StageResult, string) {
|
||||||
}, "Internal Error"
|
}, "Internal Error"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:unparam // named err lets deferred cleanup errors reach the caller
|
||||||
func runStages(
|
func runStages(
|
||||||
conf *conf.Conf,
|
conf *conf.Conf,
|
||||||
groups []string,
|
groups []string,
|
||||||
|
|
@ -153,36 +156,42 @@ func runStages(
|
||||||
stageResults, forceQuitStageName = newErrorStageResults(err)
|
stageResults, forceQuitStageName = newErrorStageResults(err)
|
||||||
return stageResults, forceQuitStageName, err
|
return stageResults, forceQuitStageName, err
|
||||||
}
|
}
|
||||||
defer stage.Cleanup()
|
ctx := context.Background()
|
||||||
|
defer func() {
|
||||||
|
err = errors.Join(err, stage.Cleanup(ctx))
|
||||||
|
}()
|
||||||
// ignore force quit in preStages & postStages
|
// ignore force quit in preStages & postStages
|
||||||
slog.Info("run preStages")
|
slog.Info("run preStages")
|
||||||
_, _, err = stage.Run(preStages)
|
_, _, preErr := stage.Run(ctx, preStages)
|
||||||
if err != nil {
|
if preErr != nil {
|
||||||
slog.Error("run preStages", "error", err)
|
slog.Error("run preStages", "error", preErr)
|
||||||
}
|
}
|
||||||
slog.Info("run stages")
|
slog.Info("run stages")
|
||||||
stageResults, forceQuitStageName, err = stage.Run(stages)
|
stageResults, forceQuitStageName, mainErr := stage.Run(ctx, stages)
|
||||||
if err != nil {
|
if mainErr != nil {
|
||||||
slog.Error("run stages", "error", err)
|
slog.Error("run stages", "error", mainErr)
|
||||||
stageResults, forceQuitStageName = newErrorStageResults(err)
|
stageResults, forceQuitStageName = newErrorStageResults(mainErr)
|
||||||
}
|
}
|
||||||
onStagesComplete(stageResults, forceQuitStageName)
|
onStagesComplete(stageResults, forceQuitStageName)
|
||||||
slog.Info("output result start", "path", conf.OutputPath)
|
slog.Info("output result start", "path", conf.OutputPath)
|
||||||
slog.Debug("output result start",
|
slog.Debug("output result start",
|
||||||
"path", conf.OutputPath, "results", stageResults)
|
"path", conf.OutputPath, "results", stageResults)
|
||||||
content, err := json.Marshal(stageResults)
|
content, marshalErr := json.Marshal(stageResults)
|
||||||
if err != nil {
|
if marshalErr != nil {
|
||||||
slog.Error("marshal stageResults", "error", err)
|
slog.Error("marshal stageResults", "error", marshalErr)
|
||||||
|
}
|
||||||
|
var outputErr error
|
||||||
|
if marshalErr == nil {
|
||||||
|
outputErr = os.WriteFile(conf.OutputPath,
|
||||||
|
append(content, '\n'), 0o600)
|
||||||
|
if outputErr != nil {
|
||||||
|
slog.Error("write stageResults", "error", outputErr)
|
||||||
}
|
}
|
||||||
err = os.WriteFile(conf.OutputPath,
|
|
||||||
append(content, []byte("\n")...), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("write stageResults", "error", err)
|
|
||||||
}
|
}
|
||||||
slog.Info("run postStages")
|
slog.Info("run postStages")
|
||||||
_, _, err = stage.Run(postStages)
|
_, _, postErr := stage.Run(ctx, postStages)
|
||||||
if err != nil {
|
if postErr != nil {
|
||||||
slog.Error("run postStages", "error", err)
|
slog.Error("run postStages", "error", postErr)
|
||||||
}
|
}
|
||||||
return stageResults, forceQuitStageName, err
|
return stageResults, forceQuitStageName, errors.Join(preErr, mainErr, marshalErr, outputErr, postErr)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
cmd/joj3/stage_test.go
Normal file
71
cmd/joj3/stage_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/joint-online-judge/JOJ3/cmd/joj3/conf"
|
||||||
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunStagesPreservesMainAndOutputErrors(t *testing.T) {
|
||||||
|
brokenStage := conf.ConfStage{Name: "broken"}
|
||||||
|
brokenStage.Executor.Name = "missing-executor"
|
||||||
|
c := &conf.Conf{
|
||||||
|
SandboxExecServer: "localhost:5051",
|
||||||
|
OutputPath: t.TempDir(),
|
||||||
|
Stages: []conf.ConfStage{brokenStage},
|
||||||
|
}
|
||||||
|
_, _, err := runStages(c, nil, func([]stage.StageResult, string) {})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("runStages() unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "executor not found") ||
|
||||||
|
!strings.Contains(err.Error(), "is a directory") {
|
||||||
|
t.Fatalf("runStages() error = %v, want main and output errors", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStagesPreservesPhaseErrorsAndWritesMainFailure(t *testing.T) {
|
||||||
|
broken := func(name, executor string) conf.ConfStage {
|
||||||
|
s := conf.ConfStage{Name: name}
|
||||||
|
s.Executor.Name = executor
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "result.json")
|
||||||
|
c := &conf.Conf{
|
||||||
|
SandboxExecServer: "localhost:5051",
|
||||||
|
OutputPath: outputPath,
|
||||||
|
PreStages: []conf.ConfStage{broken("pre", "missing-pre")},
|
||||||
|
Stages: []conf.ConfStage{broken("main", "missing-main")},
|
||||||
|
PostStages: []conf.ConfStage{broken("post", "missing-post")},
|
||||||
|
}
|
||||||
|
callbackCalled := false
|
||||||
|
_, forceQuit, err := runStages(c, nil, func(results []stage.StageResult, forceQuit string) {
|
||||||
|
callbackCalled = true
|
||||||
|
if len(results) != 1 || results[0].Name != "Internal Error" || forceQuit != "Internal Error" {
|
||||||
|
t.Fatalf("callback results = %v, forceQuit = %q", results, forceQuit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("runStages() unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"missing-pre", "missing-main", "missing-post"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("runStages() error %q does not contain %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if forceQuit != "Internal Error" || !callbackCalled {
|
||||||
|
t.Fatalf("forceQuit = %q, callbackCalled = %v", forceQuit, callbackCalled)
|
||||||
|
}
|
||||||
|
content, readErr := os.ReadFile(outputPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatal(readErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), "Internal Error") ||
|
||||||
|
!strings.Contains(string(content), "missing-main") {
|
||||||
|
t.Fatalf("output = %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
|
@ -15,8 +16,8 @@ import (
|
||||||
|
|
||||||
// parseMultiValueFlag parses a multi-value command-line flag and appends its values to the provided slice.
|
// parseMultiValueFlag parses a multi-value command-line flag and appends its values to the provided slice.
|
||||||
// It registers a flag with the specified name and description, associating it with a multiStringValue receiver.
|
// It registers a flag with the specified name and description, associating it with a multiStringValue receiver.
|
||||||
func parseMultiValueFlag(values *[]string, flagName, description string) {
|
func parseMultiValueFlag(flags *flag.FlagSet, values *[]string, flagName, description string) {
|
||||||
flag.Var((*multiStringValue)(values), flagName, description)
|
flags.Var((*multiStringValue)(values), flagName, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
type multiStringValue []string
|
type multiStringValue []string
|
||||||
|
|
@ -39,7 +40,12 @@ func setupSlog() {
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var Version string
|
||||||
|
|
||||||
|
func run(args []string, stdout io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("repo-health-checker", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
var (
|
||||||
rootDir string
|
rootDir string
|
||||||
repoSize float64
|
repoSize float64
|
||||||
checkFileNameList string
|
checkFileNameList string
|
||||||
|
|
@ -48,27 +54,23 @@ var (
|
||||||
whitelistedChars string
|
whitelistedChars string
|
||||||
allowedDomainList string
|
allowedDomainList string
|
||||||
actorCsvPath string
|
actorCsvPath string
|
||||||
showVersion *bool
|
showVersion bool
|
||||||
Version string
|
)
|
||||||
)
|
flags.BoolVar(&showVersion, "version", false, "print current version")
|
||||||
|
flags.StringVar(&rootDir, "root", ".", "root dir for forbidden files check")
|
||||||
func init() {
|
flags.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB")
|
||||||
showVersion = flag.Bool("version", false, "print current version")
|
flags.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check")
|
||||||
flag.StringVar(&rootDir, "root", ".", "root dir for forbidden files check")
|
flags.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums")
|
||||||
flag.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB")
|
flags.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files")
|
||||||
flag.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check")
|
flags.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email")
|
||||||
flag.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums")
|
flags.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file")
|
||||||
flag.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files")
|
parseMultiValueFlag(flags, &metaFile, "meta", "meta files to check")
|
||||||
flag.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email")
|
if err := flags.Parse(args); err != nil {
|
||||||
flag.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file")
|
return err
|
||||||
parseMultiValueFlag(&metaFile, "meta", "meta files to check")
|
}
|
||||||
}
|
if showVersion {
|
||||||
|
_, err := fmt.Fprintln(stdout, Version)
|
||||||
func main() {
|
return err
|
||||||
flag.Parse()
|
|
||||||
if *showVersion {
|
|
||||||
fmt.Println(Version)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
setupSlog()
|
setupSlog()
|
||||||
slog.Info("start repo-health-checker", "version", Version)
|
slog.Info("start repo-health-checker", "version", Version)
|
||||||
|
|
@ -92,7 +94,15 @@ func main() {
|
||||||
jsonRes, err := json.Marshal(res)
|
jsonRes, err := json.Marshal(res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("marshal result", "error", err)
|
slog.Error("marshal result", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintln(stdout, string(jsonRes))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||||
|
slog.Error("repo-health-checker", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Println(string(jsonRes))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
cmd/repo-health-checker/main_test.go
Normal file
101
cmd/repo-health-checker/main_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
|
"github.com/joint-online-judge/JOJ3/pkg/healthcheck"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exampleConf struct {
|
||||||
|
Stages []struct {
|
||||||
|
Executor struct {
|
||||||
|
With struct {
|
||||||
|
Default struct {
|
||||||
|
Args []string `json:"args"`
|
||||||
|
} `json:"default"`
|
||||||
|
} `json:"with"`
|
||||||
|
} `json:"executor"`
|
||||||
|
} `json:"stages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSON[T any](t *testing.T, path string) T {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var value T
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheckExamples(t *testing.T) {
|
||||||
|
root := "../../tmp/submodules/JOJ3-examples/examples/healthcheck"
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// This fixture is retained in cmd/joj3 as the sandbox binary/parser smoke
|
||||||
|
// test, so do not execute it a second time here.
|
||||||
|
if entry.Name() == "release" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Run(entry.Name(), func(t *testing.T) {
|
||||||
|
dir := filepath.Join(root, entry.Name())
|
||||||
|
conf := readJSON[exampleConf](t, filepath.Join(dir, "conf.json"))
|
||||||
|
expected := readJSON[[]stage.StageResult](t, filepath.Join(dir, "expected.json"))
|
||||||
|
if len(conf.Stages) != 1 || len(expected) != 1 || len(expected[0].Results) != 1 {
|
||||||
|
t.Fatal("healthcheck fixture must contain one stage and one result")
|
||||||
|
}
|
||||||
|
args := conf.Stages[0].Executor.With.Default.Args
|
||||||
|
if len(args) == 0 {
|
||||||
|
t.Fatal("healthcheck fixture has no command")
|
||||||
|
}
|
||||||
|
t.Chdir(dir)
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
if err := run(args[1:], &stdout); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got healthcheck.Result
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode output %q: %v", stdout.String(), err)
|
||||||
|
}
|
||||||
|
want := healthcheck.Result{
|
||||||
|
Msg: expected[0].Results[0].Comment,
|
||||||
|
Failed: expected[0].ForceQuit,
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("run() = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunVersionAndInvalidFlag(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
Version = "test-version"
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
if err := run([]string{"-version"}, &stdout); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(stdout.String()) != Version {
|
||||||
|
t.Fatalf("version output = %q", stdout.String())
|
||||||
|
}
|
||||||
|
if err := run([]string{"-unknown"}, &stdout); err == nil {
|
||||||
|
t.Fatal("run() accepted an unknown flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
package dummy
|
package dummy
|
||||||
|
|
||||||
import "github.com/joint-online-judge/JOJ3/internal/stage"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
func (e *Dummy) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Dummy) Run(_ context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
res := make([]stage.ExecutorResult, 0, len(cmds))
|
res := make([]stage.ExecutorResult, 0, len(cmds))
|
||||||
for range cmds {
|
for range cmds {
|
||||||
res = append(res, stage.ExecutorResult{
|
res = append(res, stage.ExecutorResult{
|
||||||
|
|
@ -19,6 +23,6 @@ func (e *Dummy) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Dummy) Cleanup() error {
|
func (e *Dummy) Cleanup(_ context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package local
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
|
@ -70,14 +71,12 @@ func (e *Local) generateResult(
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
result.Status = stage.StatusNonzeroExitStatus
|
||||||
status := exitErr.Sys().(syscall.WaitStatus)
|
status := exitErr.Sys().(syscall.WaitStatus)
|
||||||
if status.Signaled() {
|
if status.Signaled() {
|
||||||
signal := status.Signal()
|
signal := status.Signal()
|
||||||
switch signal {
|
if signal == syscall.SIGXCPU {
|
||||||
case syscall.SIGXCPU:
|
|
||||||
result.Status = stage.StatusTimeLimitExceeded
|
result.Status = stage.StatusTimeLimitExceeded
|
||||||
default:
|
|
||||||
result.Status = stage.StatusNonzeroExitStatus
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.Error = exitErr.Error()
|
result.Error = exitErr.Error()
|
||||||
|
|
@ -105,10 +104,16 @@ func (e *Local) generateResult(
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Local) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
func (e *Local) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
var results []stage.ExecutorResult
|
var results []stage.ExecutorResult
|
||||||
|
|
||||||
for _, cmd := range cmds {
|
for _, cmd := range cmds {
|
||||||
|
if len(cmd.Args) == 0 {
|
||||||
|
return nil, fmt.Errorf("command args must not be empty")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
execCmd := exec.Command(cmd.Args[0], cmd.Args[1:]...) // #nosec G204
|
execCmd := exec.Command(cmd.Args[0], cmd.Args[1:]...) // #nosec G204
|
||||||
execCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
execCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
if cmd.CPULimit > 0 && cmd.ClockLimit <= 0 {
|
if cmd.CPULimit > 0 && cmd.ClockLimit <= 0 {
|
||||||
|
|
@ -167,6 +172,10 @@ func (e *Local) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
results = append(results, result)
|
results = append(results, result)
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
<-done
|
||||||
|
return nil, ctx.Err()
|
||||||
case <-time.After(duration):
|
case <-time.After(duration):
|
||||||
_ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL)
|
_ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL)
|
||||||
err := <-done
|
err := <-done
|
||||||
|
|
@ -216,6 +225,6 @@ func handleCopyOut(result *stage.ExecutorResult, cmd stage.Cmd) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Local) Cleanup() error {
|
func (e *Local) Cleanup(_ context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
internal/executor/local/executor_test.go
Normal file
156
internal/executor/local/executor_test.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stringPtr(s string) *string { return &s }
|
||||||
|
|
||||||
|
func TestRunCapturesIOAndCopyOut(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
output := filepath.Join(dir, "artifact")
|
||||||
|
results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{
|
||||||
|
Args: []string{"/bin/sh", "-c", "read value; printf '%s:%s' \"$MARK\" \"$value\"; printf artifact > \"$1\"", "sh", output},
|
||||||
|
Env: []string{"MARK=env"},
|
||||||
|
Stdin: &stage.CmdFile{Content: stringPtr("input\n")},
|
||||||
|
Stdout: &stage.CmdFile{Name: stringPtr("stdout")},
|
||||||
|
CopyOut: []string{output, filepath.Join(dir, "optional?")},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Status != stage.StatusAccepted {
|
||||||
|
t.Fatalf("Run() results = %+v", results)
|
||||||
|
}
|
||||||
|
if got := results[0].Files["stdout"]; got != "env:input" {
|
||||||
|
t.Fatalf("stdout = %q", got)
|
||||||
|
}
|
||||||
|
if got := results[0].Files[output]; got != "artifact" {
|
||||||
|
t.Fatalf("copy-out = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateResultClassifiesErrors(t *testing.T) {
|
||||||
|
result := (&Local{}).generateResult(errors.New("start failed"), nil, -time.Second,
|
||||||
|
stage.Cmd{}, bytes.Buffer{}, bytes.Buffer{}, false)
|
||||||
|
if result.Status != stage.StatusInternalError || result.ExitStatus != -1 || result.RunTime != 0 {
|
||||||
|
t.Fatalf("generateResult() = %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("/bin/sh", "-c", "exit 7")
|
||||||
|
err := cmd.Run()
|
||||||
|
result = (&Local{}).generateResult(err, cmd.ProcessState, time.Millisecond,
|
||||||
|
stage.Cmd{}, bytes.Buffer{}, bytes.Buffer{}, false)
|
||||||
|
if result.Status != stage.StatusNonzeroExitStatus || result.ExitStatus != 7 {
|
||||||
|
t.Fatalf("exit result = %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReportsRequiredCopyOutError(t *testing.T) {
|
||||||
|
missing := filepath.Join(t.TempDir(), "missing")
|
||||||
|
results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{
|
||||||
|
Args: []string{"/bin/true"}, CopyOut: []string{missing},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Status != stage.StatusFileError || results[0].Error == "" {
|
||||||
|
t.Fatalf("Run() results = %+v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReadsStdinFileAndRunsMultipleCommands(t *testing.T) {
|
||||||
|
input := filepath.Join(t.TempDir(), "stdin")
|
||||||
|
if err := os.WriteFile(input, []byte("from-file"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
results, err := (&Local{}).Run(context.Background(), []stage.Cmd{
|
||||||
|
{Args: []string{"/bin/true"}},
|
||||||
|
{
|
||||||
|
Args: []string{"/bin/cat"},
|
||||||
|
Stdin: &stage.CmdFile{Src: &input},
|
||||||
|
Stdout: &stage.CmdFile{Name: stringPtr("stdout")},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 || results[1].Files["stdout"] != "from-file" {
|
||||||
|
t.Fatalf("Run() results = %+v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSetupErrors(t *testing.T) {
|
||||||
|
_, err := (&Local{}).Run(context.Background(), []stage.Cmd{{
|
||||||
|
Args: []string{"/bin/cat"}, Stdin: &stage.CmdFile{Src: stringPtr(filepath.Join(t.TempDir(), "missing"))},
|
||||||
|
}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "failed to open stdin file") {
|
||||||
|
t.Fatalf("stdin error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = (&Local{}).Run(context.Background(), []stage.Cmd{{Args: []string{filepath.Join(t.TempDir(), "missing")}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "failed to start command") {
|
||||||
|
t.Fatalf("start error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunClockTimeout(t *testing.T) {
|
||||||
|
results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{
|
||||||
|
Args: []string{"/bin/sleep", "1"}, ClockLimit: uint64(20 * time.Millisecond),
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Status != stage.StatusTimeLimitExceeded {
|
||||||
|
t.Fatalf("Run() results = %+v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsEmptyArgs(t *testing.T) {
|
||||||
|
_, err := (&Local{}).Run(context.Background(), []stage.Cmd{{}})
|
||||||
|
if err == nil || err.Error() != "command args must not be empty" {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCancellationKillsProcessGroup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ready := filepath.Join(dir, "ready")
|
||||||
|
marker := filepath.Join(dir, "marker")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := (&Local{}).Run(ctx, []stage.Cmd{{
|
||||||
|
Args: []string{"/bin/sh", "-c", "touch \"$1\"; (sleep 0.3; touch \"$2\") & wait", "sh", ready, marker},
|
||||||
|
}})
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(ready); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("command did not start")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Run() error = %v, want context canceled", err)
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("child process survived cancellation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package sandbox
|
package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -12,14 +12,22 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// copied from https://github.com/criyle/go-judge/blob/master/cmd/go-judge-shell/grpc.go
|
// copied from https://github.com/criyle/go-judge/blob/master/cmd/go-judge-shell/grpc.go
|
||||||
func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType {
|
func convertPBCmd(cmd []stage.Cmd) ([]*pb.Request_CmdType, error) {
|
||||||
ret := make([]*pb.Request_CmdType, 0, len(cmd))
|
ret := make([]*pb.Request_CmdType, 0, len(cmd))
|
||||||
for _, c := range cmd {
|
for index, c := range cmd {
|
||||||
|
files, err := convertPBFiles([]*stage.CmdFile{c.Stdin, c.Stdout, c.Stderr})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("command %d standard file: %w", index, err)
|
||||||
|
}
|
||||||
|
copyIn, err := convertPBCopyIn(c.CopyIn, c.CopyInDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("command %d copy-in: %w", index, err)
|
||||||
|
}
|
||||||
req := &pb.Request_CmdType{}
|
req := &pb.Request_CmdType{}
|
||||||
req.SetArgs(c.Args)
|
req.SetArgs(c.Args)
|
||||||
req.SetEnv(c.Env)
|
req.SetEnv(c.Env)
|
||||||
req.SetTty(c.TTY)
|
req.SetTty(c.TTY)
|
||||||
req.SetFiles(convertPBFiles([]*stage.CmdFile{c.Stdin, c.Stdout, c.Stderr}))
|
req.SetFiles(files)
|
||||||
req.SetCpuTimeLimit(c.CPULimit)
|
req.SetCpuTimeLimit(c.CPULimit)
|
||||||
req.SetClockTimeLimit(c.ClockLimit)
|
req.SetClockTimeLimit(c.ClockLimit)
|
||||||
req.SetMemoryLimit(c.MemoryLimit)
|
req.SetMemoryLimit(c.MemoryLimit)
|
||||||
|
|
@ -29,7 +37,7 @@ func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType {
|
||||||
req.SetCpuSetLimit(c.CPUSetLimit)
|
req.SetCpuSetLimit(c.CPUSetLimit)
|
||||||
req.SetDataSegmentLimit(c.DataSegmentLimit)
|
req.SetDataSegmentLimit(c.DataSegmentLimit)
|
||||||
req.SetAddressSpaceLimit(c.AddressSpaceLimit)
|
req.SetAddressSpaceLimit(c.AddressSpaceLimit)
|
||||||
req.SetCopyIn(convertPBCopyIn(c.CopyIn, c.CopyInDir))
|
req.SetCopyIn(copyIn)
|
||||||
req.SetCopyOut(convertPBCopyOut(c.CopyOut))
|
req.SetCopyOut(convertPBCopyOut(c.CopyOut))
|
||||||
req.SetCopyOutCached(convertPBCopyOut(c.CopyOutCached))
|
req.SetCopyOutCached(convertPBCopyOut(c.CopyOutCached))
|
||||||
req.SetCopyOutMax(c.CopyOutMax)
|
req.SetCopyOutMax(c.CopyOutMax)
|
||||||
|
|
@ -37,25 +45,25 @@ func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType {
|
||||||
req.SetSymlinks(convertSymlink(c.CopyIn))
|
req.SetSymlinks(convertSymlink(c.CopyIn))
|
||||||
ret = append(ret, req)
|
ret = append(ret, req)
|
||||||
}
|
}
|
||||||
return ret
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBCopyIn(
|
func convertPBCopyIn(
|
||||||
copyIn map[string]stage.CmdFile, copyInDir string,
|
copyIn map[string]stage.CmdFile, copyInDir string,
|
||||||
) map[string]*pb.Request_File {
|
) (map[string]*pb.Request_File, error) {
|
||||||
if copyInDir != "" {
|
if copyInDir != "" {
|
||||||
_ = filepath.Walk(copyInDir,
|
err := filepath.Walk(copyInDir,
|
||||||
func(path string, info os.FileInfo, err error) error {
|
func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
relPath, err := filepath.Rel(copyInDir, path)
|
relPath, err := filepath.Rel(copyInDir, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
_, exists := copyIn[relPath]
|
_, exists := copyIn[relPath]
|
||||||
if !info.IsDir() && !exists {
|
if !info.IsDir() && !exists {
|
||||||
|
|
@ -63,15 +71,22 @@ func convertPBCopyIn(
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("walk %q: %w", copyInDir, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rt := make(map[string]*pb.Request_File, len(copyIn))
|
rt := make(map[string]*pb.Request_File, len(copyIn))
|
||||||
for k, i := range copyIn {
|
for k, i := range copyIn {
|
||||||
if i.Symlink != nil {
|
if i.Symlink != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rt[k] = convertPBFile(i)
|
file, err := convertPBFile(i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("file %q: %w", k, err)
|
||||||
}
|
}
|
||||||
return rt
|
rt[k] = file
|
||||||
|
}
|
||||||
|
return rt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile {
|
func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile {
|
||||||
|
|
@ -101,65 +116,67 @@ func convertSymlink(copyIn map[string]stage.CmdFile) map[string]string {
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBFiles(files []*stage.CmdFile) []*pb.Request_File {
|
func convertPBFiles(files []*stage.CmdFile) ([]*pb.Request_File, error) {
|
||||||
var ret []*pb.Request_File
|
var ret []*pb.Request_File
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
if f == nil {
|
if f == nil {
|
||||||
ret = append(ret, nil)
|
ret = append(ret, nil)
|
||||||
} else {
|
} else {
|
||||||
ret = append(ret, convertPBFile(*f))
|
file, err := convertPBFile(*f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ret = append(ret, file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ret
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBFile(i stage.CmdFile) *pb.Request_File {
|
func convertPBFile(i stage.CmdFile) (*pb.Request_File, error) {
|
||||||
req := &pb.Request_File{}
|
req := &pb.Request_File{}
|
||||||
switch {
|
switch {
|
||||||
case i.Src != nil:
|
case i.Src != nil:
|
||||||
if !filepath.IsAbs(*i.Src) {
|
if !filepath.IsAbs(*i.Src) {
|
||||||
absPath, err := filepath.Abs(*i.Src)
|
absPath, err := filepath.Abs(*i.Src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("convert pb file get abs path", "path", *i.Src, "error", err)
|
return nil, fmt.Errorf("resolve source path %q: %w", *i.Src, err)
|
||||||
absPath = "/"
|
|
||||||
}
|
}
|
||||||
i.Src = &absPath
|
i.Src = &absPath
|
||||||
}
|
}
|
||||||
s, err := os.ReadFile(*i.Src)
|
s, err := os.ReadFile(*i.Src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s = []byte{}
|
return nil, fmt.Errorf("read source file %q: %w", *i.Src, err)
|
||||||
slog.Error("convert pb file read file", "path", *i.Src, "error", err)
|
|
||||||
}
|
}
|
||||||
m := &pb.Request_MemoryFile{}
|
m := &pb.Request_MemoryFile{}
|
||||||
m.SetContent(s)
|
m.SetContent(s)
|
||||||
req.SetMemory(m)
|
req.SetMemory(m)
|
||||||
return req
|
return req, nil
|
||||||
case i.Content != nil:
|
case i.Content != nil:
|
||||||
s := strToBytes(*i.Content)
|
s := strToBytes(*i.Content)
|
||||||
m := &pb.Request_MemoryFile{}
|
m := &pb.Request_MemoryFile{}
|
||||||
m.SetContent(s)
|
m.SetContent(s)
|
||||||
req.SetMemory(m)
|
req.SetMemory(m)
|
||||||
return req
|
return req, nil
|
||||||
case i.FileID != nil:
|
case i.FileID != nil:
|
||||||
c := &pb.Request_CachedFile{}
|
c := &pb.Request_CachedFile{}
|
||||||
c.SetFileID(*i.FileID)
|
c.SetFileID(*i.FileID)
|
||||||
req.SetCached(c)
|
req.SetCached(c)
|
||||||
return req
|
return req, nil
|
||||||
case i.Name != nil && i.Max != nil:
|
case i.Name != nil && i.Max != nil:
|
||||||
p := &pb.Request_PipeCollector{}
|
p := &pb.Request_PipeCollector{}
|
||||||
p.SetName(*i.Name)
|
p.SetName(*i.Name)
|
||||||
p.SetMax(*i.Max)
|
p.SetMax(*i.Max)
|
||||||
p.SetPipe(i.Pipe)
|
p.SetPipe(i.Pipe)
|
||||||
req.SetPipe(p)
|
req.SetPipe(p)
|
||||||
return req
|
return req, nil
|
||||||
case i.StreamIn:
|
case i.StreamIn:
|
||||||
req.SetStreamIn(&emptypb.Empty{})
|
req.SetStreamIn(&emptypb.Empty{})
|
||||||
return req
|
return req, nil
|
||||||
case i.StreamOut:
|
case i.StreamOut:
|
||||||
req.SetStreamOut(&emptypb.Empty{})
|
req.SetStreamOut(&emptypb.Empty{})
|
||||||
return req
|
return req, nil
|
||||||
}
|
}
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult {
|
func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult {
|
||||||
|
|
|
||||||
121
internal/executor/sandbox/convert_test.go
Normal file
121
internal/executor/sandbox/convert_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/criyle/go-judge/pb"
|
||||||
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertPBFileVariants(t *testing.T) {
|
||||||
|
content, fileID, name := "content", "cached", "output"
|
||||||
|
max := int64(123)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file stage.CmdFile
|
||||||
|
check func(*pb.Request_File) bool
|
||||||
|
}{
|
||||||
|
{"content", stage.CmdFile{Content: &content}, func(f *pb.Request_File) bool { return string(f.GetMemory().GetContent()) == content }},
|
||||||
|
{"cached", stage.CmdFile{FileID: &fileID}, func(f *pb.Request_File) bool { return f.GetCached().GetFileID() == fileID }},
|
||||||
|
{"pipe", stage.CmdFile{Name: &name, Max: &max, Pipe: true}, func(f *pb.Request_File) bool {
|
||||||
|
return f.GetPipe().GetName() == name && f.GetPipe().GetMax() == max && f.GetPipe().GetPipe()
|
||||||
|
}},
|
||||||
|
{"stream-in", stage.CmdFile{StreamIn: true}, func(f *pb.Request_File) bool { return f.GetStreamIn() != nil }},
|
||||||
|
{"stream-out", stage.CmdFile{StreamOut: true}, func(f *pb.Request_File) bool { return f.GetStreamOut() != nil }},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := convertPBFile(tt.file)
|
||||||
|
if err != nil || got == nil || !tt.check(got) {
|
||||||
|
t.Fatalf("convertPBFile() = %v, %v", got, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertCopyOutAndResult(t *testing.T) {
|
||||||
|
copyOut := convertPBCopyOut([]string{"required", "optional?"})
|
||||||
|
if len(copyOut) != 2 || copyOut[0].GetOptional() || !copyOut[1].GetOptional() || copyOut[1].GetName() != "optional" {
|
||||||
|
t.Fatalf("convertPBCopyOut() = %+v", copyOut)
|
||||||
|
}
|
||||||
|
fileError := &pb.Response_FileError{}
|
||||||
|
fileError.SetName("input")
|
||||||
|
fileError.SetType(pb.Response_FileError_ErrorType(1))
|
||||||
|
fileError.SetMessage("bad file")
|
||||||
|
response := &pb.Response_Result{}
|
||||||
|
response.SetStatus(pb.Response_Result_StatusType(stage.StatusAccepted))
|
||||||
|
response.SetFiles(map[string][]byte{"stdout": []byte("ok")})
|
||||||
|
response.SetFileIDs(map[string]string{"bin": "id"})
|
||||||
|
response.SetFileError([]*pb.Response_FileError{fileError})
|
||||||
|
got := convertPBResult([]*pb.Response_Result{response})
|
||||||
|
if len(got) != 1 || got[0].Files["stdout"] != "ok" || got[0].FileIDs["bin"] != "id" ||
|
||||||
|
len(got[0].FileError) != 1 || got[0].FileError[0].Message != "bad file" {
|
||||||
|
t.Fatalf("convertPBResult() = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPBCmdReturnsSourceReadError(t *testing.T) {
|
||||||
|
missing := t.TempDir() + "/missing"
|
||||||
|
_, err := convertPBCmd([]stage.Cmd{{
|
||||||
|
Args: []string{"true"},
|
||||||
|
CopyIn: map[string]stage.CmdFile{
|
||||||
|
"input": {Src: &missing},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "read source file") {
|
||||||
|
t.Fatalf("convertPBCmd() error = %v, want source read error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPBCmdReturnsStandardFileReadError(t *testing.T) {
|
||||||
|
missing := t.TempDir() + "/missing"
|
||||||
|
_, err := convertPBCmd([]stage.Cmd{{
|
||||||
|
Args: []string{"true"},
|
||||||
|
Stdin: &stage.CmdFile{Src: &missing},
|
||||||
|
}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "standard file") ||
|
||||||
|
!strings.Contains(err.Error(), "read source file") {
|
||||||
|
t.Fatalf("convertPBCmd() error = %v, want standard source read error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPBCmdReturnsCopyInDirectoryWalkError(t *testing.T) {
|
||||||
|
missing := t.TempDir() + "/missing"
|
||||||
|
_, err := convertPBCmd([]stage.Cmd{{
|
||||||
|
Args: []string{"true"},
|
||||||
|
CopyInDir: missing,
|
||||||
|
}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "walk") {
|
||||||
|
t.Fatalf("convertPBCmd() error = %v, want directory walk error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPBCmdPreservesFilesAcrossMultipleSCMMaxFDBatches(t *testing.T) {
|
||||||
|
// 1001 is large enough to require several descriptor batches while keeping
|
||||||
|
// the fixture cheap to create and the protobuf request small.
|
||||||
|
const fileCount = 1001
|
||||||
|
dir := t.TempDir()
|
||||||
|
for i := range fileCount {
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("file-%03d", i))
|
||||||
|
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmds, err := convertPBCmd([]stage.Cmd{{
|
||||||
|
Args: []string{"true"},
|
||||||
|
CopyIn: make(map[string]stage.CmdFile),
|
||||||
|
CopyInDir: dir,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cmds) != 1 || len(cmds[0].GetCopyIn()) != fileCount {
|
||||||
|
t.Fatalf("converted %d commands with %d files, want 1 command with %d files",
|
||||||
|
len(cmds), len(cmds[0].GetCopyIn()), fileCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,20 +2,23 @@ package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/criyle/go-judge/pb"
|
"github.com/criyle/go-judge/pb"
|
||||||
"github.com/joint-online-judge/JOJ3/internal/stage"
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
func (e *Sandbox) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
var err error
|
var err error
|
||||||
if e.execClient == nil {
|
if e.execClient == nil {
|
||||||
slog.Debug("create exec client", "server", e.execServer)
|
slog.Debug("create exec client", "server", e.execServer)
|
||||||
e.execClient, err = createExecClient(e.execServer, e.token)
|
e.execClient, e.conn, err = createExecClient(e.execServer, e.token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -32,14 +35,19 @@ func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pbCmds := convertPBCmd(cmds)
|
pbCmds, err := convertPBCmd(cmds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
for i, pbCmd := range pbCmds {
|
for i, pbCmd := range pbCmds {
|
||||||
slog.Debug("sandbox execute", "i", i, "pbCmd size", proto.Size(pbCmd))
|
slog.Debug("sandbox execute", "i", i, "pbCmd size", proto.Size(pbCmd))
|
||||||
}
|
}
|
||||||
pbReq := &pb.Request{}
|
pbReq := &pb.Request{}
|
||||||
pbReq.SetCmd(pbCmds)
|
pbReq.SetCmd(pbCmds)
|
||||||
slog.Debug("sandbox execute", "pbReq size", proto.Size(pbReq))
|
slog.Debug("sandbox execute", "pbReq size", proto.Size(pbReq))
|
||||||
pbRet, err := e.execClient.Exec(context.TODO(), pbReq)
|
callCtx, cancel := context.WithTimeout(ctx, execRPCTimeout(cmds))
|
||||||
|
defer cancel()
|
||||||
|
pbRet, err := e.execClient.Exec(callCtx, pbReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -53,15 +61,50 @@ func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Sandbox) Cleanup() error {
|
func (e *Sandbox) Cleanup(ctx context.Context) error {
|
||||||
|
var cleanupErr error
|
||||||
for k, fileID := range e.cachedMap {
|
for k, fileID := range e.cachedMap {
|
||||||
req := &pb.FileID{}
|
req := &pb.FileID{}
|
||||||
req.SetFileID(fileID)
|
req.SetFileID(fileID)
|
||||||
_, err := e.execClient.FileDelete(context.TODO(), req)
|
callCtx, cancel := context.WithTimeout(ctx, rpcTimeoutMargin)
|
||||||
|
_, err := e.execClient.FileDelete(callCtx, req)
|
||||||
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("sandbox cleanup", "error", err)
|
slog.Error("sandbox cleanup", "error", err)
|
||||||
|
cleanupErr = errors.Join(cleanupErr, err)
|
||||||
}
|
}
|
||||||
delete(e.cachedMap, k)
|
delete(e.cachedMap, k)
|
||||||
}
|
}
|
||||||
return nil
|
if e.conn != nil {
|
||||||
|
cleanupErr = errors.Join(cleanupErr, e.conn.Close())
|
||||||
|
e.conn = nil
|
||||||
|
e.execClient = nil
|
||||||
|
}
|
||||||
|
return cleanupErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRPCTimeout(cmds []stage.Cmd) time.Duration {
|
||||||
|
var maxLimit uint64
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
limit := cmd.ClockLimit
|
||||||
|
if limit == 0 {
|
||||||
|
// Match the local executor's default wall-clock allowance when only a
|
||||||
|
// CPU limit is specified.
|
||||||
|
if cmd.CPULimit > math.MaxUint64/2 {
|
||||||
|
limit = math.MaxUint64
|
||||||
|
} else {
|
||||||
|
limit = cmd.CPULimit * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit > maxLimit {
|
||||||
|
maxLimit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
margin := uint64(rpcTimeoutMargin)
|
||||||
|
if maxLimit > uint64(math.MaxInt64)-margin {
|
||||||
|
return time.Duration(math.MaxInt64)
|
||||||
|
}
|
||||||
|
// The bound above guarantees the conversion fits in time.Duration.
|
||||||
|
return time.Duration(maxLimit + margin) // #nosec G115
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
internal/executor/sandbox/executor_test.go
Normal file
129
internal/executor/sandbox/executor_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/criyle/go-judge/pb"
|
||||||
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/protobuf/types/known/emptypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeExecutorClient struct {
|
||||||
|
pb.ExecutorClient
|
||||||
|
exec func(context.Context, *pb.Request) (*pb.Response, error)
|
||||||
|
fileDelete func(context.Context, *pb.FileID) (*emptypb.Empty, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeExecutorClient) Exec(
|
||||||
|
ctx context.Context, req *pb.Request, _ ...grpc.CallOption,
|
||||||
|
) (*pb.Response, error) {
|
||||||
|
return f.exec(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeExecutorClient) FileDelete(
|
||||||
|
ctx context.Context, id *pb.FileID, _ ...grpc.CallOption,
|
||||||
|
) (*emptypb.Empty, error) {
|
||||||
|
return f.fileDelete(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecRPCTimeout(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cmds []stage.Cmd
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{name: "margin only", want: rpcTimeoutMargin},
|
||||||
|
{
|
||||||
|
name: "largest clock limit",
|
||||||
|
cmds: []stage.Cmd{{ClockLimit: uint64(time.Minute)}, {ClockLimit: uint64(2 * time.Minute)}},
|
||||||
|
want: 2*time.Minute + rpcTimeoutMargin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cpu limit fallback",
|
||||||
|
cmds: []stage.Cmd{{CPULimit: uint64(time.Minute)}},
|
||||||
|
want: 2*time.Minute + rpcTimeoutMargin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clock limit takes precedence over cpu limit",
|
||||||
|
cmds: []stage.Cmd{{ClockLimit: uint64(time.Minute), CPULimit: uint64(10 * time.Minute)}},
|
||||||
|
want: time.Minute + rpcTimeoutMargin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cpu multiplication overflow",
|
||||||
|
cmds: []stage.Cmd{{CPULimit: math.MaxUint64}},
|
||||||
|
want: time.Duration(math.MaxInt64),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duration overflow",
|
||||||
|
cmds: []stage.Cmd{{ClockLimit: math.MaxUint64}},
|
||||||
|
want: time.Duration(math.MaxInt64),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := execRPCTimeout(tt.cmds); got != tt.want {
|
||||||
|
t.Fatalf("execRPCTimeout() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAppliesComputedRPCDeadline(t *testing.T) {
|
||||||
|
var remaining time.Duration
|
||||||
|
client := fakeExecutorClient{
|
||||||
|
exec: func(ctx context.Context, _ *pb.Request) (*pb.Response, error) {
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Exec context has no deadline")
|
||||||
|
}
|
||||||
|
remaining = time.Until(deadline)
|
||||||
|
return &pb.Response{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
executor := &Sandbox{
|
||||||
|
execClient: client,
|
||||||
|
cachedMap: make(map[string]string),
|
||||||
|
}
|
||||||
|
want := 2*time.Minute + rpcTimeoutMargin
|
||||||
|
_, err := executor.Run(context.Background(), []stage.Cmd{{
|
||||||
|
Args: []string{"true"},
|
||||||
|
CPULimit: uint64(time.Minute),
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if remaining > want || remaining < want-time.Second {
|
||||||
|
t.Fatalf("RPC deadline remaining = %v, want approximately %v", remaining, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupJoinsDeleteErrorsAndClearsCache(t *testing.T) {
|
||||||
|
deleteErr := errors.New("delete failed")
|
||||||
|
deleted := 0
|
||||||
|
client := fakeExecutorClient{
|
||||||
|
fileDelete: func(context.Context, *pb.FileID) (*emptypb.Empty, error) {
|
||||||
|
deleted++
|
||||||
|
return nil, deleteErr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
executor := &Sandbox{
|
||||||
|
execClient: client,
|
||||||
|
cachedMap: map[string]string{"one": "1", "two": "2"},
|
||||||
|
}
|
||||||
|
err := executor.Cleanup(context.Background())
|
||||||
|
if !errors.Is(err, deleteErr) {
|
||||||
|
t.Fatalf("Cleanup() error = %v, want delete error", err)
|
||||||
|
}
|
||||||
|
if deleted != 2 {
|
||||||
|
t.Fatalf("FileDelete called %d times, want 2", deleted)
|
||||||
|
}
|
||||||
|
if len(executor.cachedMap) != 0 {
|
||||||
|
t.Fatalf("cached files not cleared: %v", executor.cachedMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,13 +12,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// copied from https://github.com/criyle/go-judger-demo/blob/master/apigateway/main.go
|
// copied from https://github.com/criyle/go-judger-demo/blob/master/apigateway/main.go
|
||||||
func createExecClient(execServer, token string) (pb.ExecutorClient, error) {
|
func createExecClient(execServer, token string) (pb.ExecutorClient, *grpc.ClientConn, error) {
|
||||||
conn, err := createGRPCConnection(execServer, token)
|
conn, err := createGRPCConnection(execServer, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("gRPC connection", "error", err)
|
slog.Error("gRPC connection", "error", err)
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return pb.NewExecutorClient(conn), nil
|
return pb.NewExecutorClient(conn), conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createGRPCConnection(addr, token string) (*grpc.ClientConn, error) {
|
func createGRPCConnection(addr, token string) (*grpc.ClientConn, error) {
|
||||||
|
|
|
||||||
27
internal/executor/sandbox/grpc_test.go
Normal file
27
internal/executor/sandbox/grpc_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenAuth(t *testing.T) {
|
||||||
|
auth := newTokenAuth("secret")
|
||||||
|
metadata, err := auth.GetRequestMetadata(context.Background())
|
||||||
|
if err != nil || metadata["authorization"] != "Bearer secret" {
|
||||||
|
t.Fatalf("GetRequestMetadata() = %v, %v", metadata, err)
|
||||||
|
}
|
||||||
|
if auth.RequireTransportSecurity() {
|
||||||
|
t.Fatal("RequireTransportSecurity() = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGRPCConnection(t *testing.T) {
|
||||||
|
conn, err := createGRPCConnection("passthrough:///unused", "secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,11 @@
|
||||||
package sandbox
|
package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/criyle/go-judge/pb"
|
"github.com/criyle/go-judge/pb"
|
||||||
"github.com/joint-online-judge/JOJ3/internal/stage"
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
var name = "sandbox"
|
var name = "sandbox"
|
||||||
|
|
@ -15,8 +18,11 @@ type Sandbox struct {
|
||||||
execServer, token string
|
execServer, token string
|
||||||
cachedMap map[string]string
|
cachedMap map[string]string
|
||||||
execClient pb.ExecutorClient
|
execClient pb.ExecutorClient
|
||||||
|
conn *grpc.ClientConn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rpcTimeoutMargin = 30 * time.Second
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
stage.RegisterExecutor(name, &Sandbox{
|
stage.RegisterExecutor(name, &Sandbox{
|
||||||
execServer: "localhost:5051",
|
execServer: "localhost:5051",
|
||||||
|
|
|
||||||
38
internal/parser/keyword/parser_test.go
Normal file
38
internal/parser/keyword/parser_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package keyword
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunCountsCapsAndOrdersKeywords(t *testing.T) {
|
||||||
|
results, forceQuit, err := (&Keyword{}).Run([]stage.ExecutorResult{{
|
||||||
|
Files: map[string]string{"log": "error error warning"},
|
||||||
|
}}, map[string]any{
|
||||||
|
"score": 10,
|
||||||
|
"files": []any{"log"},
|
||||||
|
"forceQuitOnDeduct": true,
|
||||||
|
"matches": []any{
|
||||||
|
map[string]any{"keywords": []any{"error"}, "score": 3, "maxMatchCount": 1},
|
||||||
|
map[string]any{"keywords": []any{"warning"}, "score": 2},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Score != 5 || !forceQuit {
|
||||||
|
t.Fatalf("Run() = %+v, %v", results, forceQuit)
|
||||||
|
}
|
||||||
|
if !strings.Contains(results[0].Comment, "`error`: 1") || !strings.Contains(results[0].Comment, "`warning`: 1") {
|
||||||
|
t.Fatalf("comment = %q", results[0].Comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsInvalidConfiguration(t *testing.T) {
|
||||||
|
_, forceQuit, err := (&Keyword{}).Run(nil, "invalid")
|
||||||
|
if err == nil || !forceQuit {
|
||||||
|
t.Fatalf("Run() = forceQuit %v, error %v", forceQuit, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package stage
|
package stage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -9,8 +10,8 @@ import (
|
||||||
var executorMap = map[string]Executor{}
|
var executorMap = map[string]Executor{}
|
||||||
|
|
||||||
type Executor interface {
|
type Executor interface {
|
||||||
Run([]Cmd) ([]ExecutorResult, error)
|
Run(context.Context, []Cmd) ([]ExecutorResult, error)
|
||||||
Cleanup() error
|
Cleanup(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterExecutor(name string, executor Executor) {
|
func RegisterExecutor(name string, executor Executor) {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ func (t FileErrorType) MarshalJSON() ([]byte, error) {
|
||||||
func (t *FileErrorType) UnmarshalJSON(b []byte) error {
|
func (t *FileErrorType) UnmarshalJSON(b []byte) error {
|
||||||
str := string(b)
|
str := string(b)
|
||||||
v, ok := fileErrorStringReverse[str]
|
v, ok := fileErrorStringReverse[str]
|
||||||
if ok {
|
if !ok {
|
||||||
return fmt.Errorf("%s is not file error type", str)
|
return fmt.Errorf("%s is not file error type", str)
|
||||||
}
|
}
|
||||||
*t = v
|
*t = v
|
||||||
|
|
|
||||||
67
internal/stage/json_test.go
Normal file
67
internal/stage/json_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatusJSONRoundTrip(t *testing.T) {
|
||||||
|
for status := StatusInvalid; status <= StatusInternalError; status++ {
|
||||||
|
data, err := json.Marshal(status)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got Status
|
||||||
|
if err := json.Unmarshal(data, &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal %s: %v", data, err)
|
||||||
|
}
|
||||||
|
if got != status {
|
||||||
|
t.Fatalf("round trip = %v, want %v", got, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var status Status
|
||||||
|
if err := json.Unmarshal([]byte(`"unknown"`), &status); err == nil {
|
||||||
|
t.Fatal("unknown status was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileErrorTypeJSONRoundTrip(t *testing.T) {
|
||||||
|
for fileError := ErrCopyInOpenFile; fileError <= ErrCollectSizeExceeded; fileError++ {
|
||||||
|
data, err := json.Marshal(fileError)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got FileErrorType
|
||||||
|
if err := json.Unmarshal(data, &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal %s: %v", data, err)
|
||||||
|
}
|
||||||
|
if got != fileError {
|
||||||
|
t.Fatalf("round trip = %v, want %v", got, fileError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var fileError FileErrorType
|
||||||
|
if err := json.Unmarshal([]byte(`"unknown"`), &fileError); err == nil {
|
||||||
|
t.Fatal("unknown file error type was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorResultJSONSummarizesFileContents(t *testing.T) {
|
||||||
|
data, err := json.Marshal(ExecutorResult{
|
||||||
|
Status: StatusAccepted,
|
||||||
|
Files: map[string]string{"stdout": "secret output"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "secret output") || !strings.Contains(string(data), `"stdout":"len:13"`) {
|
||||||
|
t.Fatalf("MarshalJSON() = %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonNullSliceMarshalsEmptyAsArray(t *testing.T) {
|
||||||
|
data, err := json.Marshal(NonNullSlice[int](nil))
|
||||||
|
if err != nil || string(data) != "[]" {
|
||||||
|
t.Fatalf("MarshalJSON() = %s, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
package stage
|
package stage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(stages []Stage) (
|
func Run(ctx context.Context, stages []Stage) (
|
||||||
stageResults []StageResult, forceQuitStageName string, err error,
|
stageResults []StageResult, forceQuitStageName string, err error,
|
||||||
) {
|
) {
|
||||||
var executorResults []ExecutorResult
|
var executorResults []ExecutorResult
|
||||||
|
|
@ -59,9 +61,10 @@ func Run(stages []Stage) (
|
||||||
"name", stage.Executor.Name,
|
"name", stage.Executor.Name,
|
||||||
)
|
)
|
||||||
err = fmt.Errorf("executor not found: %s", stage.Executor.Name)
|
err = fmt.Errorf("executor not found: %s", stage.Executor.Name)
|
||||||
|
forceQuitStageName = stage.Name
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
executorResults, err = executor.Run(stage.Executor.Cmds)
|
executorResults, err = executor.Run(ctx, stage.Executor.Cmds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error(
|
slog.Error(
|
||||||
"executor run error",
|
"executor run error",
|
||||||
|
|
@ -69,6 +72,7 @@ func Run(stages []Stage) (
|
||||||
"name", stage.Executor.Name,
|
"name", stage.Executor.Name,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
forceQuitStageName = stage.Name
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i, executorResult := range executorResults {
|
for i, executorResult := range executorResults {
|
||||||
|
|
@ -115,6 +119,7 @@ func Run(stages []Stage) (
|
||||||
"name", stageParser.Name,
|
"name", stageParser.Name,
|
||||||
)
|
)
|
||||||
err = fmt.Errorf("parser not found: %s", stageParser.Name)
|
err = fmt.Errorf("parser not found: %s", stageParser.Name)
|
||||||
|
forceQuitStageName = stage.Name
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var parserForceQuit bool
|
var parserForceQuit bool
|
||||||
|
|
@ -130,6 +135,14 @@ func Run(stages []Stage) (
|
||||||
forceQuitStageName = stage.Name
|
forceQuitStageName = stage.Name
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if len(tmpParserResults) != len(executorResults) {
|
||||||
|
err = fmt.Errorf(
|
||||||
|
"parser %q returned %d results for %d executor results",
|
||||||
|
stageParser.Name, len(tmpParserResults), len(executorResults),
|
||||||
|
)
|
||||||
|
forceQuitStageName = stage.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
for i, parserResult := range tmpParserResults {
|
for i, parserResult := range tmpParserResults {
|
||||||
parserScoresMap[stageParser.Name][i] += parserResult.Score
|
parserScoresMap[stageParser.Name][i] += parserResult.Score
|
||||||
}
|
}
|
||||||
|
|
@ -185,12 +198,15 @@ func Run(stages []Stage) (
|
||||||
return stageResults, forceQuitStageName, err
|
return stageResults, forceQuitStageName, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func Cleanup() {
|
func Cleanup(ctx context.Context) error {
|
||||||
slog.Info("stage cleanup start")
|
slog.Info("stage cleanup start")
|
||||||
|
var cleanupErr error
|
||||||
for name, executor := range executorMap {
|
for name, executor := range executorMap {
|
||||||
err := executor.Cleanup()
|
err := executor.Cleanup(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("executor cleanup error", "name", name, "error", err)
|
slog.Error("executor cleanup error", "name", name, "error", err)
|
||||||
|
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("executor %q cleanup: %w", name, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return cleanupErr
|
||||||
}
|
}
|
||||||
|
|
|
||||||
149
internal/stage/run_test.go
Normal file
149
internal/stage/run_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contractTestExecutor struct{}
|
||||||
|
|
||||||
|
func (contractTestExecutor) Run(context.Context, []Cmd) ([]ExecutorResult, error) {
|
||||||
|
return []ExecutorResult{{Status: StatusAccepted}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (contractTestExecutor) Cleanup(context.Context) error { return nil }
|
||||||
|
|
||||||
|
type contractTestParser struct{}
|
||||||
|
|
||||||
|
func (contractTestParser) Run([]ExecutorResult, any) ([]ParserResult, bool, error) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsParserResultCountMismatch(t *testing.T) {
|
||||||
|
originalExecutors, originalParsers := executorMap, parserMap
|
||||||
|
executorMap, parserMap = map[string]Executor{}, map[string]Parser{}
|
||||||
|
t.Cleanup(func() { executorMap, parserMap = originalExecutors, originalParsers })
|
||||||
|
|
||||||
|
const executorName = "contract-test-executor"
|
||||||
|
const parserName = "contract-test-parser"
|
||||||
|
RegisterExecutor(executorName, contractTestExecutor{})
|
||||||
|
RegisterParser(parserName, contractTestParser{})
|
||||||
|
|
||||||
|
_, forceQuit, err := Run(context.Background(), []Stage{{
|
||||||
|
Name: "test",
|
||||||
|
Executor: StageExecutor{Name: executorName, Cmds: []Cmd{{}}},
|
||||||
|
Parsers: []StageParser{{Name: parserName}},
|
||||||
|
}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "returned 0 results for 1") {
|
||||||
|
t.Fatalf("Run() error = %v, want result count error", err)
|
||||||
|
}
|
||||||
|
if forceQuit != "test" {
|
||||||
|
t.Fatalf("Run() force quit = %q, want test", forceQuit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type executorFunc struct {
|
||||||
|
run func(context.Context, []Cmd) ([]ExecutorResult, error)
|
||||||
|
cleanup func(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e executorFunc) Run(ctx context.Context, cmds []Cmd) ([]ExecutorResult, error) {
|
||||||
|
return e.run(ctx, cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e executorFunc) Cleanup(ctx context.Context) error {
|
||||||
|
if e.cleanup == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.cleanup(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type parserFunc func([]ExecutorResult, any) ([]ParserResult, bool, error)
|
||||||
|
|
||||||
|
func (p parserFunc) Run(results []ExecutorResult, conf any) ([]ParserResult, bool, error) {
|
||||||
|
return p(results, conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isolateRegistries(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
originalExecutors, originalParsers := executorMap, parserMap
|
||||||
|
executorMap, parserMap = map[string]Executor{}, map[string]Parser{}
|
||||||
|
t.Cleanup(func() { executorMap, parserMap = originalExecutors, originalParsers })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupJoinsExecutorErrors(t *testing.T) {
|
||||||
|
isolateRegistries(t)
|
||||||
|
errOne := errors.New("cleanup one")
|
||||||
|
errTwo := errors.New("cleanup two")
|
||||||
|
RegisterExecutor("one", executorFunc{
|
||||||
|
run: func(context.Context, []Cmd) ([]ExecutorResult, error) { return nil, nil },
|
||||||
|
cleanup: func(context.Context) error { return errOne },
|
||||||
|
})
|
||||||
|
RegisterExecutor("two", executorFunc{
|
||||||
|
run: func(context.Context, []Cmd) ([]ExecutorResult, error) { return nil, nil },
|
||||||
|
cleanup: func(context.Context) error { return errTwo },
|
||||||
|
})
|
||||||
|
|
||||||
|
err := Cleanup(context.Background())
|
||||||
|
if !errors.Is(err, errOne) || !errors.Is(err, errTwo) {
|
||||||
|
t.Fatalf("Cleanup() error = %v, want both cleanup errors", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStopsAfterParserContractViolation(t *testing.T) {
|
||||||
|
isolateRegistries(t)
|
||||||
|
RegisterExecutor("executor", executorFunc{
|
||||||
|
run: func(context.Context, []Cmd) ([]ExecutorResult, error) {
|
||||||
|
return []ExecutorResult{{Status: StatusAccepted}}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
RegisterParser("bad", parserFunc(func([]ExecutorResult, any) ([]ParserResult, bool, error) {
|
||||||
|
return nil, false, nil
|
||||||
|
}))
|
||||||
|
secondCalled := false
|
||||||
|
RegisterParser("second", parserFunc(func([]ExecutorResult, any) ([]ParserResult, bool, error) {
|
||||||
|
secondCalled = true
|
||||||
|
return []ParserResult{{}}, false, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
_, forceQuit, err := Run(context.Background(), []Stage{{
|
||||||
|
Name: "contract",
|
||||||
|
Executor: StageExecutor{Name: "executor", Cmds: []Cmd{{}}},
|
||||||
|
Parsers: []StageParser{{Name: "bad"}, {Name: "second"}},
|
||||||
|
}})
|
||||||
|
if err == nil || forceQuit != "contract" {
|
||||||
|
t.Fatalf("Run() = forceQuit %q, error %v", forceQuit, err)
|
||||||
|
}
|
||||||
|
if secondCalled {
|
||||||
|
t.Fatal("parser after contract violation was called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSetsForceQuitForMissingComponents(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
stage Stage
|
||||||
|
}{
|
||||||
|
{name: "executor", stage: Stage{Name: "missing-executor", Executor: StageExecutor{Name: "unknown"}}},
|
||||||
|
{
|
||||||
|
name: "parser",
|
||||||
|
stage: Stage{
|
||||||
|
Name: "missing-parser",
|
||||||
|
Executor: StageExecutor{Name: "executor", Cmds: []Cmd{{}}},
|
||||||
|
Parsers: []StageParser{{Name: "unknown"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
isolateRegistries(t)
|
||||||
|
RegisterExecutor("executor", contractTestExecutor{})
|
||||||
|
_, forceQuit, err := Run(context.Background(), []Stage{tt.stage})
|
||||||
|
if err == nil || forceQuit != tt.stage.Name {
|
||||||
|
t.Fatalf("Run() = forceQuit %q, error %v", forceQuit, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
80
pkg/healthcheck/author_test.go
Normal file
80
pkg/healthcheck/author_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package healthcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newCommitRepo(t *testing.T, message, email string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo, err := git.PlainInit(dir, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
worktree, err := repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := worktree.Add("README.md"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
signature := &object.Signature{Name: "Student", Email: email, When: time.Unix(1, 0)}
|
||||||
|
if _, err := worktree.Commit(message, &git.CommitOptions{Author: signature, Committer: signature}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitChecks(t *testing.T) {
|
||||||
|
valid := newCommitRepo(t, "feat: valid", "student@example.edu")
|
||||||
|
if err := NonASCIIMsg(valid); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := AuthorEmailCheck(valid, []string{"example.edu"}, filepath.Join(valid, "missing.csv")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidMessage := newCommitRepo(t, "feat: 测试", "student@invalid.test")
|
||||||
|
if err := NonASCIIMsg(invalidMessage); err == nil || !strings.Contains(err.Error(), "测试") {
|
||||||
|
t.Fatalf("NonASCIIMsg() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := AuthorEmailCheck(invalidMessage, []string{"example.edu"}, filepath.Join(invalidMessage, "missing.csv")); err == nil || !strings.Contains(err.Error(), "allowed domains") {
|
||||||
|
t.Fatalf("AuthorEmailCheck(domain) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorEmailCheckActorCSV(t *testing.T) {
|
||||||
|
repo := newCommitRepo(t, "feat: valid", "student@example.edu")
|
||||||
|
csvPath := filepath.Join(t.TempDir(), "actors.csv")
|
||||||
|
if err := os.WriteFile(csvPath, []byte("name,id,student\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := AuthorEmailCheck(repo, []string{"example.edu"}, csvPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(csvPath, []byte("name,id,other\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := AuthorEmailCheck(repo, []string{"example.edu"}, csvPath); err == nil || !strings.Contains(err.Error(), "not stored") {
|
||||||
|
t.Fatalf("AuthorEmailCheck(actor) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitChecksRejectNonRepository(t *testing.T) {
|
||||||
|
if err := NonASCIIMsg(t.TempDir()); err == nil || !strings.Contains(err.Error(), "opening git repo") {
|
||||||
|
t.Fatalf("NonASCIIMsg() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := AuthorEmailCheck(t.TempDir(), nil, ""); err == nil || !strings.Contains(err.Error(), "opening git repo") {
|
||||||
|
t.Fatalf("AuthorEmailCheck() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
pkg/healthcheck/files_test.go
Normal file
67
pkg/healthcheck/files_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package healthcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseWhitelistedChars(t *testing.T) {
|
||||||
|
got := parseWhitelistedChars("你, 好, a, invalid, ,你")
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("parseWhitelistedChars() = %v", got)
|
||||||
|
}
|
||||||
|
if _, ok := got['你']; !ok {
|
||||||
|
t.Fatal("missing whitelisted rune")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonASCIIFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("hello 你\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := NonASCIIFiles(dir, "你"); err != nil {
|
||||||
|
t.Fatalf("whitelisted NonASCIIFiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := NonASCIIFiles(dir, ""); err == nil || !strings.Contains(err.Error(), "source.txt") {
|
||||||
|
t.Fatalf("NonASCIIFiles() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForbiddenCheck(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.out\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "result.out"), nil, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ForbiddenCheck("."); err == nil || !strings.Contains(err.Error(), "result.out") {
|
||||||
|
t.Fatalf("ForbiddenCheck() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "protected.txt")
|
||||||
|
content := []byte("original")
|
||||||
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(content)
|
||||||
|
checksum := hex.EncodeToString(sum[:])
|
||||||
|
if err := VerifyFiles(dir, "protected.txt", checksum); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := VerifyFiles(dir, "protected.txt", "bad"); err == nil || !strings.Contains(err.Error(), "altered") {
|
||||||
|
t.Fatalf("VerifyFiles(altered) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := VerifyFiles(dir, "one,two", checksum); err == nil || !strings.Contains(err.Error(), "do not match") {
|
||||||
|
t.Fatalf("VerifyFiles(mismatch) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user