Skip to content

Commit 54f1361

Browse files
authored
Merge pull request #18 from Hack4Impact-UMD/firestore-reporter
Firestore reporter implementation
2 parents c422d77 + c75c538 commit 54f1361

10 files changed

Lines changed: 656 additions & 16 deletions

File tree

.dockerignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
.dockerignore
22
.git
33
.gitignore
4+
*.log
45

56
# default build name
6-
professor
7+
professor
8+
9+
firebase.json
10+
firestore.rules

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ professor
77
node_modules
88
playwright-report
99
test-results
10+
11+
*.log

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
Worker for the [Hack4Impact-UMD App Portal](apply.umd.hack4impact.org) assessment autograder.
44

5+
## Architecture
6+
7+
```mermaid
8+
graph TB
9+
User[Applicant] -->|Submits Assessment| Frontend[App Portal - Frontend]
10+
Frontend[App Portal - Frontend] -->|Reads Status and Results| User[Applicant]
11+
Frontend -->|Requests Grading| Backend[App Portal - Backend]
12+
13+
Backend -->|Creates Documents| Firestore[(Firestore)]
14+
Backend -->|Enqueues Job| CloudTasks[Queue - Cloud Tasks]
15+
16+
CloudTasks -->|Delivers Job| Professor[Professor - Cloud Run]
17+
18+
Professor -->|Updates Documents| Firestore
19+
Professor -->|Clones Repo| GitHub[GitHub]
20+
Professor -->|Tests Repo| Playwright[Playwright]
21+
22+
Firestore -->|Reads Documents| Frontend
23+
24+
style Frontend fill:#4285f4,color:#fff
25+
style Backend fill:#4285f4,color:#fff
26+
style Professor fill:#9334e9,color:#fff
27+
style Firestore fill:#ffa000,color:#fff
28+
style CloudTasks fill:#34a853,color:#fff
29+
```
30+
531
## Tech Stack
632

733
- **Language**: Go

db/types.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package db
33
import "time"
44

55
const (
6-
StatusPending = "pending"
7-
StatusCloning = "cloning"
8-
StatusBuilding = "building"
9-
StatusTesting = "testing"
10-
StatusCompleted = "completed"
11-
StatusFailed = "failed"
6+
StatusQueued = "queued"
7+
StatusPending = "pending"
8+
StatusCloning = "cloning"
9+
StatusInstalling = "installing"
10+
StatusBuilding = "building"
11+
StatusServing = "serving"
12+
StatusTesting = "testing"
13+
StatusCompleted = "completed"
14+
StatusFailed = "failed"
1215
)
1316

1417
type TestResult struct {

firebase.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"emulators": {
3+
"firestore": {
4+
"port": 8080
5+
},
6+
"ui": {
7+
"enabled": false
8+
}
9+
}
10+
}

firebase/firestore.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,21 @@ func GetFirestoreClient(app *firebase.App) (*firestore.Client, error) {
1212
client, err := app.Firestore(context.Background())
1313

1414
if err != nil {
15-
log.Println("Faild to get firestore client instance:", err)
15+
log.Println("ERROR: Failed to get firestore client instance:", err)
1616
return nil, err
1717
}
1818

1919
return client, nil
2020
}
21+
22+
func UpdateDoc(client *firestore.Client, collection, docId string, data map[string]any) error {
23+
ctx := context.Background()
24+
_, err := client.Collection(collection).Doc(docId).Set(ctx, data, firestore.MergeAll)
25+
26+
if err != nil {
27+
log.Printf("ERROR: Failed to update %s/%s: %v", collection, docId, err)
28+
return err
29+
}
30+
31+
return nil
32+
}

firestore.rules

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
rules_version = '2';
2+
service cloud.firestore {
3+
match /databases/{database}/documents {
4+
// ⚠️ WARNING: EMULATOR ONLY!!
5+
6+
match /gradingJobs/{jobId} {
7+
allow read, update: if true;
8+
allow create, delete: if false;
9+
}
10+
11+
match /gradingJobsInternal/{jobId} {
12+
allow read, update: if true;
13+
allow create, delete: if false;
14+
15+
match /{document=**} {
16+
allow read, write: if false;
17+
}
18+
}
19+
}

reporter/firestore_reporter.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package reporter
2+
3+
import (
4+
"errors"
5+
6+
"cloud.google.com/go/firestore"
7+
"github.com/Hack4Impact-UMD/professor/db"
8+
"github.com/Hack4Impact-UMD/professor/firebase"
9+
"github.com/Hack4Impact-UMD/professor/util"
10+
)
11+
12+
const (
13+
maxLogBytes = 50 * 1024 // 50KB
14+
maxTestOutputBytes = 10 * 1024 // 10KB
15+
16+
collectionPublic = "gradingJobs"
17+
collectionInternal = "gradingJobsInternal"
18+
)
19+
20+
type FirestoreReporter struct {
21+
fsClient *firestore.Client
22+
tests map[string][]db.TestResult
23+
}
24+
25+
func NewFirestoreReporter(fsClient *firestore.Client) (*FirestoreReporter, error) {
26+
if fsClient == nil {
27+
return nil, errors.New("fsClient argument for reporter is nil")
28+
}
29+
30+
return &FirestoreReporter{
31+
fsClient: fsClient,
32+
tests: make(map[string][]db.TestResult),
33+
}, nil
34+
}
35+
36+
func (r *FirestoreReporter) updatePublicDoc(jobId string, data map[string]any) error {
37+
return firebase.UpdateDoc(r.fsClient, collectionPublic, jobId, data)
38+
}
39+
40+
func (r *FirestoreReporter) updateInternalDoc(jobId string, data map[string]any) error {
41+
return firebase.UpdateDoc(r.fsClient, collectionInternal, jobId, data)
42+
}
43+
44+
func truncateLog(log string, maxBytes int) string {
45+
if len(log) <= maxBytes {
46+
return log
47+
}
48+
return "Tail:\n" + log[len(log)-maxBytes:]
49+
}
50+
51+
func (r *FirestoreReporter) OnGradeStart(jobId string) {
52+
_ = r.updatePublicDoc(jobId, map[string]any{
53+
"status": db.StatusPending,
54+
"updated": firestore.ServerTimestamp,
55+
})
56+
}
57+
58+
func (r *FirestoreReporter) OnCloneStart(jobId, assessmentRepo, testRepo string) {
59+
_ = r.updatePublicDoc(jobId, map[string]any{
60+
"status": db.StatusCloning,
61+
"updated": firestore.ServerTimestamp,
62+
})
63+
64+
_ = r.updateInternalDoc(jobId, map[string]any{
65+
"testRepo": testRepo,
66+
})
67+
}
68+
69+
func (r *FirestoreReporter) OnCloneEnd(jobId, assessmentRepo, testRepo string, err error) {
70+
if err != nil {
71+
_ = r.updatePublicDoc(jobId, map[string]any{
72+
"status": db.StatusFailed,
73+
"error": err.Error(),
74+
"completed": firestore.ServerTimestamp,
75+
"updated": firestore.ServerTimestamp,
76+
})
77+
78+
_ = r.updateInternalDoc(jobId, map[string]any{
79+
"error": err.Error(),
80+
})
81+
82+
return
83+
}
84+
85+
_ = r.updatePublicDoc(jobId, map[string]any{
86+
"updated": firestore.ServerTimestamp,
87+
})
88+
}
89+
90+
func (r *FirestoreReporter) OnInstallStart(jobId string) {
91+
_ = r.updatePublicDoc(jobId, map[string]any{
92+
"status": db.StatusInstalling,
93+
"updated": firestore.ServerTimestamp,
94+
})
95+
}
96+
97+
func (r *FirestoreReporter) OnInstallEnd(jobId, out string, err error) {
98+
if err != nil {
99+
_ = r.updatePublicDoc(jobId, map[string]any{
100+
"status": db.StatusFailed,
101+
"error": err.Error(),
102+
"completed": firestore.ServerTimestamp,
103+
"updated": firestore.ServerTimestamp,
104+
})
105+
106+
_ = r.updateInternalDoc(jobId, map[string]any{
107+
"error": err.Error(),
108+
"installLog": truncateLog(out, maxLogBytes),
109+
})
110+
111+
return
112+
}
113+
114+
_ = r.updatePublicDoc(jobId, map[string]any{
115+
"updated": firestore.ServerTimestamp,
116+
})
117+
118+
_ = r.updateInternalDoc(jobId, map[string]any{
119+
"installLog": truncateLog(out, maxLogBytes),
120+
})
121+
}
122+
123+
func (r *FirestoreReporter) OnBuildStart(jobId string) {
124+
_ = r.updatePublicDoc(jobId, map[string]any{
125+
"status": db.StatusBuilding,
126+
"updated": firestore.ServerTimestamp,
127+
})
128+
}
129+
130+
func (r *FirestoreReporter) OnBuildEnd(jobId, out string, err error) {
131+
if err != nil {
132+
_ = r.updatePublicDoc(jobId, map[string]any{
133+
"status": db.StatusFailed,
134+
"error": err.Error(),
135+
"completed": firestore.ServerTimestamp,
136+
"updated": firestore.ServerTimestamp,
137+
})
138+
139+
_ = r.updateInternalDoc(jobId, map[string]any{
140+
"error": err.Error(),
141+
"buildLog": truncateLog(out, maxLogBytes),
142+
})
143+
144+
return
145+
}
146+
147+
_ = r.updatePublicDoc(jobId, map[string]any{
148+
"updated": firestore.ServerTimestamp,
149+
})
150+
151+
_ = r.updateInternalDoc(jobId, map[string]any{
152+
"buildLog": truncateLog(out, maxLogBytes),
153+
})
154+
}
155+
156+
func (r *FirestoreReporter) OnServe(jobId string, err error) {
157+
if err != nil {
158+
_ = r.updatePublicDoc(jobId, map[string]any{
159+
"status": db.StatusFailed,
160+
"error": err.Error(),
161+
"completed": firestore.ServerTimestamp,
162+
"updated": firestore.ServerTimestamp,
163+
})
164+
165+
_ = r.updateInternalDoc(jobId, map[string]any{
166+
"error": err.Error(),
167+
})
168+
169+
return
170+
}
171+
172+
_ = r.updatePublicDoc(jobId, map[string]any{
173+
"status": db.StatusServing,
174+
"updated": firestore.ServerTimestamp,
175+
})
176+
}
177+
178+
func (r *FirestoreReporter) OnTestingStart(jobId string, suites []string, err error) {
179+
if err != nil {
180+
_ = r.updatePublicDoc(jobId, map[string]any{
181+
"status": db.StatusFailed,
182+
"error": err.Error(),
183+
"completed": firestore.ServerTimestamp,
184+
"updated": firestore.ServerTimestamp,
185+
})
186+
187+
_ = r.updateInternalDoc(jobId, map[string]any{
188+
"error": err.Error(),
189+
})
190+
191+
return
192+
}
193+
194+
_ = r.updatePublicDoc(jobId, map[string]any{
195+
"status": db.StatusTesting,
196+
"updated": firestore.ServerTimestamp,
197+
})
198+
}
199+
200+
func (r *FirestoreReporter) OnTestStart(jobId, suite, testName string) {
201+
// No-op rn as it would incur a lot of extra writes, but could update timestamp later
202+
}
203+
204+
func (r *FirestoreReporter) OnTestEnd(jobId, suite, testName string, passed bool, stdout, stderr string, testErrors []string, durationMs int64, err error) {
205+
result := db.TestResult{
206+
Suite: suite,
207+
TestName: testName,
208+
Passed: passed,
209+
Stdout: truncateLog(stdout, maxTestOutputBytes),
210+
Stderr: truncateLog(stderr, maxTestOutputBytes),
211+
Errors: testErrors,
212+
DurationMs: durationMs,
213+
Points: 0, // TODO: implement point extraction to fill this
214+
}
215+
216+
_ = r.updateInternalDoc(jobId, map[string]any{
217+
"tests": map[string]any{
218+
suite: firestore.ArrayUnion(result),
219+
},
220+
})
221+
222+
publicTestUpdates := map[string]any{
223+
"suiteName": suite,
224+
"total": firestore.Increment(1),
225+
"durationMs": firestore.Increment(durationMs),
226+
}
227+
228+
if passed {
229+
publicTestUpdates["passed"] = firestore.Increment(1)
230+
} else {
231+
publicTestUpdates["failed"] = firestore.Increment(1)
232+
}
233+
234+
publicTestUpdates["points"] = firestore.Increment(result.Points)
235+
publicTestUpdates["totalPoints"] = firestore.Increment(result.Points)
236+
237+
_ = r.updatePublicDoc(jobId, map[string]any{
238+
"completedTests": firestore.Increment(1),
239+
"updated": firestore.ServerTimestamp,
240+
"publicTests": map[string]any{
241+
suite: publicTestUpdates,
242+
},
243+
})
244+
}
245+
246+
func (r *FirestoreReporter) OnTestingEnd(jobId string, err error) {
247+
if err != nil {
248+
_ = r.updatePublicDoc(jobId, map[string]any{
249+
"status": db.StatusFailed,
250+
"error": err.Error(),
251+
"completed": firestore.ServerTimestamp,
252+
"updated": firestore.ServerTimestamp,
253+
})
254+
255+
_ = r.updateInternalDoc(jobId, map[string]any{
256+
"error": err.Error(),
257+
})
258+
259+
return
260+
}
261+
262+
_ = r.updatePublicDoc(jobId, map[string]any{
263+
"status": db.StatusCompleted,
264+
"completed": firestore.ServerTimestamp,
265+
"updated": firestore.ServerTimestamp,
266+
})
267+
}
268+
269+
var _ util.GradingJobReporter = (*FirestoreReporter)(nil)

0 commit comments

Comments
 (0)