Compare commits
3 Commits
master
...
fix-pack-s
| Author | SHA1 | Date | |
|---|---|---|---|
| e751c9a237 | |||
| 9b23765848 | |||
| 369d004394 |
|
|
@ -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,7 +8,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -28,15 +27,18 @@ lint:
|
||||||
prepare-test:
|
prepare-test:
|
||||||
git submodule update --init --remote
|
git submodule update --init --remote
|
||||||
|
|
||||||
test: build
|
test:
|
||||||
./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 -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./...
|
go test -count=1 -v ./...
|
||||||
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 -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./...
|
GITHUB_ACTOR="" go test -count=1 -v ./...
|
||||||
go tool cover -func=$(COVERAGE_FILE) | tail -n 1
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# JOJ3
|
# JOJ3
|
||||||
|
|
||||||
[](https://focs.gc.sjtu.edu.cn/git/JOJ/JOJ3/actions?workflow=build.yaml)
|
[](https://goreportcard.com/report/github.com/joint-online-judge/JOJ3)
|
||||||
[](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,7 +100,6 @@ 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,9 +175,6 @@ 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)
|
||||||
|
|
@ -204,13 +201,8 @@ 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{}
|
||||||
requestedGroups := make(map[string]bool)
|
loweredCommitGroup := strings.ToLower(conventionalCommit.Group)
|
||||||
for _, group := range strings.FieldsFunc(conventionalCommit.Group, func(r rune) bool {
|
matchAllGroups := loweredCommitGroup == "all"
|
||||||
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...)
|
||||||
|
|
@ -229,7 +221,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 || requestedGroups[keyword] {
|
if matchAllGroups || strings.Contains(loweredCommitGroup, keyword) {
|
||||||
groups = append(groups, keyword)
|
groups = append(groups, keyword)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,10 @@
|
||||||
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
|
||||||
|
|
@ -209,34 +130,3 @@ 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,8 +1,6 @@
|
||||||
package conf
|
package conf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"github.com/joint-online-judge/JOJ3/internal/stage"
|
"github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,16 +32,6 @@ 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,7 +104,6 @@ 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,16 +87,7 @@ 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"
|
||||||
|
|
@ -115,54 +106,3 @@ 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,9 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -126,7 +124,6 @@ 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,
|
||||||
|
|
@ -156,42 +153,36 @@ func runStages(
|
||||||
stageResults, forceQuitStageName = newErrorStageResults(err)
|
stageResults, forceQuitStageName = newErrorStageResults(err)
|
||||||
return stageResults, forceQuitStageName, err
|
return stageResults, forceQuitStageName, err
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
defer stage.Cleanup()
|
||||||
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")
|
||||||
_, _, preErr := stage.Run(ctx, preStages)
|
_, _, err = stage.Run(preStages)
|
||||||
if preErr != nil {
|
if err != nil {
|
||||||
slog.Error("run preStages", "error", preErr)
|
slog.Error("run preStages", "error", err)
|
||||||
}
|
}
|
||||||
slog.Info("run stages")
|
slog.Info("run stages")
|
||||||
stageResults, forceQuitStageName, mainErr := stage.Run(ctx, stages)
|
stageResults, forceQuitStageName, err = stage.Run(stages)
|
||||||
if mainErr != nil {
|
if err != nil {
|
||||||
slog.Error("run stages", "error", mainErr)
|
slog.Error("run stages", "error", err)
|
||||||
stageResults, forceQuitStageName = newErrorStageResults(mainErr)
|
stageResults, forceQuitStageName = newErrorStageResults(err)
|
||||||
}
|
}
|
||||||
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, marshalErr := json.Marshal(stageResults)
|
content, err := json.Marshal(stageResults)
|
||||||
if marshalErr != nil {
|
if err != nil {
|
||||||
slog.Error("marshal stageResults", "error", marshalErr)
|
slog.Error("marshal stageResults", "error", err)
|
||||||
}
|
|
||||||
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")
|
||||||
_, _, postErr := stage.Run(ctx, postStages)
|
_, _, err = stage.Run(postStages)
|
||||||
if postErr != nil {
|
if err != nil {
|
||||||
slog.Error("run postStages", "error", postErr)
|
slog.Error("run postStages", "error", err)
|
||||||
}
|
}
|
||||||
return stageResults, forceQuitStageName, errors.Join(preErr, mainErr, marshalErr, outputErr, postErr)
|
return stageResults, forceQuitStageName, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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,7 +7,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
|
@ -16,8 +15,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(flags *flag.FlagSet, values *[]string, flagName, description string) {
|
func parseMultiValueFlag(values *[]string, flagName, description string) {
|
||||||
flags.Var((*multiStringValue)(values), flagName, description)
|
flag.Var((*multiStringValue)(values), flagName, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
type multiStringValue []string
|
type multiStringValue []string
|
||||||
|
|
@ -40,12 +39,7 @@ func setupSlog() {
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
var Version string
|
var (
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -54,23 +48,27 @@ func run(args []string, stdout io.Writer) error {
|
||||||
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")
|
|
||||||
flags.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB")
|
func init() {
|
||||||
flags.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check")
|
showVersion = flag.Bool("version", false, "print current version")
|
||||||
flags.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums")
|
flag.StringVar(&rootDir, "root", ".", "root dir for forbidden files check")
|
||||||
flags.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files")
|
flag.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB")
|
||||||
flags.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email")
|
flag.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check")
|
||||||
flags.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file")
|
flag.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums")
|
||||||
parseMultiValueFlag(flags, &metaFile, "meta", "meta files to check")
|
flag.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files")
|
||||||
if err := flags.Parse(args); err != nil {
|
flag.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email")
|
||||||
return err
|
flag.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file")
|
||||||
}
|
parseMultiValueFlag(&metaFile, "meta", "meta files to check")
|
||||||
if showVersion {
|
}
|
||||||
_, err := fmt.Fprintln(stdout, Version)
|
|
||||||
return err
|
func main() {
|
||||||
|
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)
|
||||||
|
|
@ -94,15 +92,7 @@ func run(args []string, stdout io.Writer) error {
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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,12 +1,8 @@
|
||||||
package dummy
|
package dummy
|
||||||
|
|
||||||
import (
|
import "github.com/joint-online-judge/JOJ3/internal/stage"
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/joint-online-judge/JOJ3/internal/stage"
|
func (e *Dummy) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
)
|
|
||||||
|
|
||||||
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{
|
||||||
|
|
@ -23,6 +19,6 @@ func (e *Dummy) Run(_ context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Dummy) Cleanup(_ context.Context) error {
|
func (e *Dummy) Cleanup() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package local
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
|
@ -71,12 +70,14 @@ 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()
|
||||||
if signal == syscall.SIGXCPU {
|
switch signal {
|
||||||
|
case syscall.SIGXCPU:
|
||||||
result.Status = stage.StatusTimeLimitExceeded
|
result.Status = stage.StatusTimeLimitExceeded
|
||||||
|
default:
|
||||||
|
result.Status = stage.StatusNonzeroExitStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.Error = exitErr.Error()
|
result.Error = exitErr.Error()
|
||||||
|
|
@ -104,16 +105,10 @@ func (e *Local) generateResult(
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Local) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
func (e *Local) Run(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 {
|
||||||
|
|
@ -172,10 +167,6 @@ func (e *Local) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResu
|
||||||
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
|
||||||
|
|
@ -225,6 +216,6 @@ func handleCopyOut(result *stage.ExecutorResult, cmd stage.Cmd) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Local) Cleanup(_ context.Context) error {
|
func (e *Local) Cleanup() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
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 (
|
||||||
"fmt"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -12,22 +12,14 @@ 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, error) {
|
func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType {
|
||||||
ret := make([]*pb.Request_CmdType, 0, len(cmd))
|
ret := make([]*pb.Request_CmdType, 0, len(cmd))
|
||||||
for index, c := range cmd {
|
for _, 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(files)
|
req.SetFiles(convertPBFiles([]*stage.CmdFile{c.Stdin, c.Stdout, c.Stderr}))
|
||||||
req.SetCpuTimeLimit(c.CPULimit)
|
req.SetCpuTimeLimit(c.CPULimit)
|
||||||
req.SetClockTimeLimit(c.ClockLimit)
|
req.SetClockTimeLimit(c.ClockLimit)
|
||||||
req.SetMemoryLimit(c.MemoryLimit)
|
req.SetMemoryLimit(c.MemoryLimit)
|
||||||
|
|
@ -37,7 +29,7 @@ func convertPBCmd(cmd []stage.Cmd) ([]*pb.Request_CmdType, error) {
|
||||||
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(copyIn)
|
req.SetCopyIn(convertPBCopyIn(c.CopyIn, c.CopyInDir))
|
||||||
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)
|
||||||
|
|
@ -45,25 +37,25 @@ func convertPBCmd(cmd []stage.Cmd) ([]*pb.Request_CmdType, error) {
|
||||||
req.SetSymlinks(convertSymlink(c.CopyIn))
|
req.SetSymlinks(convertSymlink(c.CopyIn))
|
||||||
ret = append(ret, req)
|
ret = append(ret, req)
|
||||||
}
|
}
|
||||||
return ret, nil
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBCopyIn(
|
func convertPBCopyIn(
|
||||||
copyIn map[string]stage.CmdFile, copyInDir string,
|
copyIn map[string]stage.CmdFile, copyInDir string,
|
||||||
) (map[string]*pb.Request_File, error) {
|
) map[string]*pb.Request_File {
|
||||||
if copyInDir != "" {
|
if copyInDir != "" {
|
||||||
err := filepath.Walk(copyInDir,
|
_ = 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 err
|
return nil
|
||||||
}
|
}
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
relPath, err := filepath.Rel(copyInDir, path)
|
relPath, err := filepath.Rel(copyInDir, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
_, exists := copyIn[relPath]
|
_, exists := copyIn[relPath]
|
||||||
if !info.IsDir() && !exists {
|
if !info.IsDir() && !exists {
|
||||||
|
|
@ -71,22 +63,15 @@ 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
|
||||||
}
|
}
|
||||||
file, err := convertPBFile(i)
|
rt[k] = convertPBFile(i)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("file %q: %w", k, err)
|
|
||||||
}
|
}
|
||||||
rt[k] = file
|
return rt
|
||||||
}
|
|
||||||
return rt, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile {
|
func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile {
|
||||||
|
|
@ -116,67 +101,65 @@ func convertSymlink(copyIn map[string]stage.CmdFile) map[string]string {
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBFiles(files []*stage.CmdFile) ([]*pb.Request_File, error) {
|
func convertPBFiles(files []*stage.CmdFile) []*pb.Request_File {
|
||||||
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 {
|
||||||
file, err := convertPBFile(*f)
|
ret = append(ret, convertPBFile(*f))
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret = append(ret, file)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ret, nil
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBFile(i stage.CmdFile) (*pb.Request_File, error) {
|
func convertPBFile(i stage.CmdFile) *pb.Request_File {
|
||||||
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 {
|
||||||
return nil, fmt.Errorf("resolve source path %q: %w", *i.Src, err)
|
slog.Error("convert pb file get abs path", "path", *i.Src, "error", 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 {
|
||||||
return nil, fmt.Errorf("read source file %q: %w", *i.Src, err)
|
s = []byte{}
|
||||||
|
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, nil
|
return req
|
||||||
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, nil
|
return req
|
||||||
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, nil
|
return req
|
||||||
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, nil
|
return req
|
||||||
case i.StreamIn:
|
case i.StreamIn:
|
||||||
req.SetStreamIn(&emptypb.Empty{})
|
req.SetStreamIn(&emptypb.Empty{})
|
||||||
return req, nil
|
return req
|
||||||
case i.StreamOut:
|
case i.StreamOut:
|
||||||
req.SetStreamOut(&emptypb.Empty{})
|
req.SetStreamOut(&emptypb.Empty{})
|
||||||
return req, nil
|
return req
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult {
|
func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult {
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +1,89 @@
|
||||||
package sandbox
|
package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"os"
|
||||||
"time"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"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(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
const (
|
||||||
|
tarSizeThreshold = 128 * 1024 // 128 KB
|
||||||
|
tarCountThreshold = 100 // 100 files
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Sandbox) Run(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, e.conn, err = createExecClient(e.execServer, e.token)
|
e.execClient, err = createExecClient(e.execServer, e.token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// cannot use range loop since we need to change the value
|
|
||||||
for i := 0; i < len(cmds); i += 1 {
|
for i := 0; i < len(cmds); i += 1 {
|
||||||
cmd := &cmds[i]
|
if cmd := &cmds[i]; cmd.CopyIn == nil {
|
||||||
if cmd.CopyIn == nil {
|
|
||||||
cmd.CopyIn = make(map[string]stage.CmdFile)
|
cmd.CopyIn = make(map[string]stage.CmdFile)
|
||||||
}
|
}
|
||||||
for k, v := range cmd.CopyInCached {
|
for k, v := range cmds[i].CopyInCached {
|
||||||
if fileID, ok := e.cachedMap[v]; ok {
|
if fileID, ok := e.cachedMap[v]; ok {
|
||||||
cmd.CopyIn[k] = stage.CmdFile{FileID: &fileID}
|
cmds[i].CopyIn[k] = stage.CmdFile{FileID: &fileID}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pbCmds, err := convertPBCmd(cmds)
|
if needTar, tarData := prepareTar(cmds); needTar {
|
||||||
if err != nil {
|
return e.runWithTar(cmds, tarData)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
return e.runUnary(cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareTar(cmds []stage.Cmd) (bool, []byte) {
|
||||||
|
if len(cmds) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for i := range cmds {
|
||||||
|
if shouldTar(&cmds[i]) {
|
||||||
|
tarData, keysInTar := createCopyInTar(&cmds[i])
|
||||||
|
if tarData == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
slog.Debug("prepareTar", "tarSize", len(tarData), "keysInTar", len(keysInTar))
|
||||||
|
for j := range cmds {
|
||||||
|
cmds[j].CopyInDir = ""
|
||||||
|
for _, k := range keysInTar {
|
||||||
|
delete(cmds[j].CopyIn, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, tarData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldTar(cmd *stage.Cmd) bool {
|
||||||
|
size, count := estimateCopyIn(cmd)
|
||||||
|
return size >= tarSizeThreshold || count >= tarCountThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Sandbox) runUnary(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
|
||||||
|
pbCmds := convertPBCmd(cmds)
|
||||||
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))
|
||||||
callCtx, cancel := context.WithTimeout(ctx, execRPCTimeout(cmds))
|
pbRet, err := e.execClient.Exec(context.TODO(), pbReq)
|
||||||
defer cancel()
|
|
||||||
pbRet, err := e.execClient.Exec(callCtx, pbReq)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -61,50 +97,222 @@ func (e *Sandbox) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorRe
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Sandbox) Cleanup(ctx context.Context) error {
|
func (e *Sandbox) runWithTar(cmds []stage.Cmd, tarData []byte) ([]stage.ExecutorResult, error) {
|
||||||
var cleanupErr error
|
fc := &pb.FileContent{}
|
||||||
|
fc.SetContent(tarData)
|
||||||
|
fileIDResp, err := e.execClient.FileAdd(context.TODO(), fc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("file add tar: %w", err)
|
||||||
|
}
|
||||||
|
fid := fileIDResp.GetFileID()
|
||||||
|
slog.Debug("sandbox tar uploaded", "fileID", fid, "tarSize", len(tarData))
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
deleteReq := &pb.FileID{}
|
||||||
|
deleteReq.SetFileID(fid)
|
||||||
|
if _, err := e.execClient.FileDelete(context.TODO(), deleteReq); err != nil {
|
||||||
|
slog.Warn("sandbox tar file delete", "fileID", fid, "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tarFileName := "/w/__joj3_copyin.tar"
|
||||||
|
script := fmt.Sprintf(
|
||||||
|
"/bin/tar xf %s -C / --no-same-owner && rm -f %s && exec \"$@\"",
|
||||||
|
tarFileName, tarFileName,
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range cmds {
|
||||||
|
if cmds[i].CopyIn == nil {
|
||||||
|
cmds[i].CopyIn = make(map[string]stage.CmdFile)
|
||||||
|
}
|
||||||
|
cmds[i].CopyIn[tarFileName] = stage.CmdFile{FileID: &fid}
|
||||||
|
cmds[i].Args = append([]string{
|
||||||
|
"/bin/sh", "-c", script, "_",
|
||||||
|
}, cmds[i].Args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("sandbox tar exec", "cmd", cmds[0].Args[:3])
|
||||||
|
|
||||||
|
return e.runUnary(cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateCopyIn(cmd *stage.Cmd) (int, int) {
|
||||||
|
totalSize := 0
|
||||||
|
totalCount := 0
|
||||||
|
if cmd.CopyInDir != "" {
|
||||||
|
_ = filepath.Walk(cmd.CopyInDir,
|
||||||
|
func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
relPath, err := filepath.Rel(cmd.CopyInDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, exists := cmd.CopyIn[relPath]; !exists {
|
||||||
|
totalSize += int(info.Size())
|
||||||
|
totalCount++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, f := range cmd.CopyIn {
|
||||||
|
if f.Symlink != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Src != nil {
|
||||||
|
if fi, err := os.Stat(*f.Src); err == nil {
|
||||||
|
totalSize += int(fi.Size())
|
||||||
|
totalCount++
|
||||||
|
}
|
||||||
|
} else if f.Content != nil {
|
||||||
|
totalSize += len(*f.Content)
|
||||||
|
totalCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalSize, totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTarPath(p string) string {
|
||||||
|
if filepath.IsAbs(p) {
|
||||||
|
return strings.TrimPrefix(p, "/")
|
||||||
|
}
|
||||||
|
return "w/" + p
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCopyInTar(cmd *stage.Cmd) ([]byte, []string) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
tarKeys := make([]string, 0, len(cmd.CopyIn))
|
||||||
|
|
||||||
|
if cmd.CopyInDir != "" {
|
||||||
|
err := filepath.Walk(cmd.CopyInDir,
|
||||||
|
func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relPath, err := filepath.Rel(cmd.CopyInDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if relPath == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, exists := cmd.CopyIn[relPath]; exists {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
link, err := os.Readlink(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hdr, err := tar.FileInfoHeader(info, link)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hdr.Name = formatTarPath(relPath)
|
||||||
|
return tw.WriteHeader(hdr)
|
||||||
|
}
|
||||||
|
hdr, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hdr.Name = formatTarPath(relPath)
|
||||||
|
if info.IsDir() {
|
||||||
|
hdr.Name += "/"
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(tw, f)
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create copyIn tar walk", "error", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, f := range cmd.CopyIn {
|
||||||
|
if f.FileID != nil || f.Symlink != nil || f.StreamIn || f.StreamOut || f.Pipe {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Content != nil {
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Name: formatTarPath(k),
|
||||||
|
Mode: 0o644,
|
||||||
|
Size: int64(len(*f.Content)),
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
slog.Error("create copyIn tar write header", "key", k, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tw.Write([]byte(*f.Content)); err != nil {
|
||||||
|
slog.Error("create copyIn tar write content", "key", k, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tarKeys = append(tarKeys, k)
|
||||||
|
} else if f.Src != nil {
|
||||||
|
fi, err := os.Stat(*f.Src)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create copyIn tar stat", "key", k, "src", *f.Src, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hdr, err := tar.FileInfoHeader(fi, "")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create copyIn tar file info header", "key", k, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hdr.Name = formatTarPath(k)
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
slog.Error("create copyIn tar write header", "key", k, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
srcFile, err := os.Open(*f.Src)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create copyIn tar open src", "key", k, "src", *f.Src, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err = io.Copy(tw, srcFile)
|
||||||
|
srcFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create copyIn tar copy", "key", k, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tarKeys = append(tarKeys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
slog.Error("create copyIn tar close", "error", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return buf.Bytes(), tarKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Sandbox) Cleanup() 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)
|
||||||
callCtx, cancel := context.WithTimeout(ctx, rpcTimeoutMargin)
|
_, err := e.execClient.FileDelete(context.TODO(), req)
|
||||||
_, 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)
|
||||||
}
|
}
|
||||||
if e.conn != nil {
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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, *grpc.ClientConn, error) {
|
func createExecClient(execServer, token string) (pb.ExecutorClient, 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, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return pb.NewExecutorClient(conn), conn, nil
|
return pb.NewExecutorClient(conn), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createGRPCConnection(addr, token string) (*grpc.ClientConn, error) {
|
func createGRPCConnection(addr, token string) (*grpc.ClientConn, error) {
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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,11 +5,8 @@
|
||||||
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"
|
||||||
|
|
@ -18,11 +15,8 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
package stage
|
package stage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -10,8 +9,8 @@ import (
|
||||||
var executorMap = map[string]Executor{}
|
var executorMap = map[string]Executor{}
|
||||||
|
|
||||||
type Executor interface {
|
type Executor interface {
|
||||||
Run(context.Context, []Cmd) ([]ExecutorResult, error)
|
Run([]Cmd) ([]ExecutorResult, error)
|
||||||
Cleanup(context.Context) error
|
Cleanup() 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
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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,13 +4,11 @@
|
||||||
package stage
|
package stage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(ctx context.Context, stages []Stage) (
|
func Run(stages []Stage) (
|
||||||
stageResults []StageResult, forceQuitStageName string, err error,
|
stageResults []StageResult, forceQuitStageName string, err error,
|
||||||
) {
|
) {
|
||||||
var executorResults []ExecutorResult
|
var executorResults []ExecutorResult
|
||||||
|
|
@ -61,10 +59,9 @@ func Run(ctx context.Context, 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(ctx, stage.Executor.Cmds)
|
executorResults, err = executor.Run(stage.Executor.Cmds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error(
|
slog.Error(
|
||||||
"executor run error",
|
"executor run error",
|
||||||
|
|
@ -72,7 +69,6 @@ func Run(ctx context.Context, 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 {
|
||||||
|
|
@ -119,7 +115,6 @@ func Run(ctx context.Context, 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
|
||||||
|
|
@ -135,14 +130,6 @@ func Run(ctx context.Context, 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
|
||||||
}
|
}
|
||||||
|
|
@ -198,15 +185,12 @@ func Run(ctx context.Context, stages []Stage) (
|
||||||
return stageResults, forceQuitStageName, err
|
return stageResults, forceQuitStageName, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func Cleanup(ctx context.Context) error {
|
func Cleanup() {
|
||||||
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(ctx)
|
err := executor.Cleanup()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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