Save uploaded logs during upload (#17)

I got fed up with shoving everything into the structure the JSON api uses for
upload, so now parseRequest returns its own structure, and saves the log files
to disk in the same way as the other attachments.

This also means that we can test the saving of log files more representatively.
This commit is contained in:
Richard van der Hoff 2017-05-04 15:54:25 +01:00 committed by GitHub
parent bc292e4399
commit f630c6a78a
2 changed files with 159 additions and 92 deletions

View file

@ -51,22 +51,32 @@ type submitServer struct {
githubProjectMappings map[string]string githubProjectMappings map[string]string
} }
type payload struct { // the type of payload which can be uploaded as JSON to the submit endpoint
type jsonPayload struct {
Text string `json:"text"` Text string `json:"text"`
AppName string `json:"app"` AppName string `json:"app"`
Version string `json:"version"` Version string `json:"version"`
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
Logs []logEntry `json:"logs"` Logs []jsonLogEntry `json:"logs"`
Data map[string]string `json:"data"` Data map[string]string `json:"data"`
Labels []string `json:"labels"` Labels []string `json:"labels"`
Files []string
} }
type logEntry struct { type jsonLogEntry struct {
ID string `json:"id"` ID string `json:"id"`
Lines string `json:"lines"` Lines string `json:"lines"`
} }
// the payload after parsing
type parsedPayload struct {
UserText string
AppName string
Data map[string]string
Labels []string
Logs []string
Files []string
}
type submitResponse struct { type submitResponse struct {
ReportURL string `json:"report_url,omitempty"` ReportURL string `json:"report_url,omitempty"`
} }
@ -125,7 +135,7 @@ func (s *submitServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// parseRequest attempts to parse a received request as a bug report. If // parseRequest attempts to parse a received request as a bug report. If
// the request cannot be parsed, it responds with an error and returns nil. // the request cannot be parsed, it responds with an error and returns nil.
func parseRequest(w http.ResponseWriter, req *http.Request, reportDir string) *payload { func parseRequest(w http.ResponseWriter, req *http.Request, reportDir string) *parsedPayload {
length, err := strconv.Atoi(req.Header.Get("Content-Length")) length, err := strconv.Atoi(req.Header.Get("Content-Length"))
if err != nil { if err != nil {
log.Println("Couldn't parse content-length", err) log.Println("Couldn't parse content-length", err)
@ -144,7 +154,7 @@ func parseRequest(w http.ResponseWriter, req *http.Request, reportDir string) *p
if d == "multipart/form-data" { if d == "multipart/form-data" {
p, err1 := parseMultipartRequest(w, req, reportDir) p, err1 := parseMultipartRequest(w, req, reportDir)
if err1 != nil { if err1 != nil {
log.Println("Error parsing multipart data", err1) log.Println("Error parsing multipart data:", err1)
http.Error(w, "Bad multipart data", 400) http.Error(w, "Bad multipart data", 400)
return nil return nil
} }
@ -152,7 +162,7 @@ func parseRequest(w http.ResponseWriter, req *http.Request, reportDir string) *p
} }
} }
p, err := parseJSONRequest(w, req) p, err := parseJSONRequest(w, req, reportDir)
if err != nil { if err != nil {
log.Println("Error parsing JSON body", err) log.Println("Error parsing JSON body", err)
http.Error(w, fmt.Sprintf("Could not decode payload: %s", err.Error()), 400) http.Error(w, fmt.Sprintf("Could not decode payload: %s", err.Error()), 400)
@ -161,22 +171,35 @@ func parseRequest(w http.ResponseWriter, req *http.Request, reportDir string) *p
return p return p
} }
func parseJSONRequest(w http.ResponseWriter, req *http.Request) (*payload, error) { func parseJSONRequest(w http.ResponseWriter, req *http.Request, reportDir string) (*parsedPayload, error) {
var p payload var p jsonPayload
if err := json.NewDecoder(req.Body).Decode(&p); err != nil { if err := json.NewDecoder(req.Body).Decode(&p); err != nil {
return nil, err return nil, err
} }
p.Text = strings.TrimSpace(p.Text) parsed := parsedPayload{
UserText: strings.TrimSpace(p.Text),
Data: make(map[string]string),
Labels: p.Labels,
}
if p.Data == nil { if p.Data != nil {
p.Data = make(map[string]string) parsed.Data = p.Data
}
for i, log := range p.Logs {
buf := bytes.NewBufferString(log.Lines)
leafName, err := saveLogPart(i, buf, reportDir)
if err != nil {
return nil, err
}
parsed.Logs = append(parsed.Logs, leafName)
} }
// backwards-compatibility hack: current versions of riot-android // backwards-compatibility hack: current versions of riot-android
// don't set 'app', so we don't correctly file github issues. // don't set 'app', so we don't correctly file github issues.
if p.AppName == "" && p.UserAgent == "Android" { if p.AppName == "" && p.UserAgent == "Android" {
p.AppName = "riot-android" parsed.AppName = "riot-android"
// they also shove lots of stuff into 'Version' which we don't really // they also shove lots of stuff into 'Version' which we don't really
// want in the github report // want in the github report
@ -191,22 +214,28 @@ func parseJSONRequest(w http.ResponseWriter, req *http.Request) (*payload, error
if len(parts) > 1 { if len(parts) > 1 {
val = strings.TrimSpace(parts[1]) val = strings.TrimSpace(parts[1])
} }
p.Data[key] = val parsed.Data[key] = val
}
} else {
if p.UserAgent != "" {
parsed.Data["User-Agent"] = p.UserAgent
}
if p.Version != "" {
parsed.Data["Version"] = p.Version
} }
p.Version = ""
} }
return &p, nil return &parsed, nil
} }
func parseMultipartRequest(w http.ResponseWriter, req *http.Request, reportDir string) (*payload, error) { func parseMultipartRequest(w http.ResponseWriter, req *http.Request, reportDir string) (*parsedPayload, error) {
rdr, err := req.MultipartReader() rdr, err := req.MultipartReader()
if err != nil { if err != nil {
return nil, err return nil, err
} }
p := payload{ p := parsedPayload{
Logs: make([]logEntry, 0), Logs: make([]string, 0),
Data: make(map[string]string), Data: make(map[string]string),
} }
@ -225,13 +254,17 @@ func parseMultipartRequest(w http.ResponseWriter, req *http.Request, reportDir s
return &p, nil return &p, nil
} }
func parseFormPart(part *multipart.Part, p *payload, reportDir string) error { func parseFormPart(part *multipart.Part, p *parsedPayload, reportDir string) error {
defer part.Close() defer part.Close()
field := part.FormName() field := part.FormName()
var partReader io.Reader var partReader io.Reader
if field == "compressed-log" { if field == "compressed-log" {
// decompress logs as we read them // decompress logs as we read them.
//
// we could save the log directly rather than unzipping and re-zipping,
// but doing so conveys the benefit of checking the validity of the
// gzip at upload time.
zrdr, err := gzip.NewReader(part) zrdr, err := gzip.NewReader(part)
if err != nil { if err != nil {
return err return err
@ -252,36 +285,35 @@ func parseFormPart(part *multipart.Part, p *payload, reportDir string) error {
return nil return nil
} }
if field == "log" || field == "compressed-log" {
leafName, err := saveLogPart(len(p.Logs), partReader, reportDir)
if err != nil {
return err
}
p.Logs = append(p.Logs, leafName)
return nil
}
b, err := ioutil.ReadAll(partReader) b, err := ioutil.ReadAll(partReader)
if err != nil { if err != nil {
return err return err
} }
data := string(b) data := string(b)
if field == "log" || field == "compressed-log" {
// todo: we could save the log directly rather than pointlessly
// unzipping and re-zipping.
p.Logs = append(p.Logs, logEntry{
ID: part.FileName(),
Lines: data,
})
} else {
formPartToPayload(field, data, p) formPartToPayload(field, data, p)
}
return nil return nil
} }
// formPartToPayload updates the relevant part of *p from a name/value pair // formPartToPayload updates the relevant part of *p from a name/value pair
// read from the form data. // read from the form data.
func formPartToPayload(field, data string, p *payload) { func formPartToPayload(field, data string, p *parsedPayload) {
if field == "text" { if field == "text" {
p.Text = data p.UserText = data
} else if field == "app" { } else if field == "app" {
p.AppName = data p.AppName = data
} else if field == "version" { } else if field == "version" {
p.Version = data p.Data["Version"] = data
} else if field == "user_agent" { } else if field == "user_agent" {
p.UserAgent = data p.Data["User-Agent"] = data
} else if field == "label" { } else if field == "label" {
p.Labels = append(p.Labels, data) p.Labels = append(p.Labels, data)
} else { } else {
@ -305,7 +337,6 @@ var filenameRegexp = regexp.MustCompile(`^[a-zA-Z0-9_-]+\.(jpg|png|txt)$`)
// //
// Returns the leafname of the saved file. // Returns the leafname of the saved file.
func saveFormPart(leafName string, reader io.Reader, reportDir string) (string, error) { func saveFormPart(leafName string, reader io.Reader, reportDir string) (string, error) {
if !filenameRegexp.MatchString(leafName) { if !filenameRegexp.MatchString(leafName) {
return "", fmt.Errorf("Invalid upload filename") return "", fmt.Errorf("Invalid upload filename")
} }
@ -328,14 +359,38 @@ func saveFormPart(leafName string, reader io.Reader, reportDir string) (string,
return leafName, nil return leafName, nil
} }
func (s *submitServer) saveReport(ctx context.Context, p payload, reportDir, listingURL string) (*submitResponse, error) { // saveLogPart saves a log upload to the report directory.
//
// Returns the leafname of the saved file.
func saveLogPart(logNum int, reader io.Reader, reportDir string) (string, error) {
leafName := fmt.Sprintf("logs-%04d.log.gz", logNum)
fullname := filepath.Join(reportDir, leafName)
f, err := os.Create(fullname)
if err != nil {
return "", err
}
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close()
_, err = io.Copy(gz, reader)
if err != nil {
return "", err
}
return leafName, nil
}
func (s *submitServer) saveReport(ctx context.Context, p parsedPayload, reportDir, listingURL string) (*submitResponse, error) {
resp := submitResponse{} resp := submitResponse{}
var summaryBuf bytes.Buffer var summaryBuf bytes.Buffer
fmt.Fprintf( fmt.Fprintf(
&summaryBuf, &summaryBuf,
"%s\n\nNumber of logs: %d\nApplication: %s\nVersion: %s\nUser-Agent: %s\n", "%s\n\nNumber of logs: %d\nApplication: %s\n",
p.Text, len(p.Logs), p.AppName, p.Version, p.UserAgent, p.UserText, len(p.Logs), p.AppName,
) )
fmt.Fprintf(&summaryBuf, "Labels: %s\n", strings.Join(p.Labels, ", ")) fmt.Fprintf(&summaryBuf, "Labels: %s\n", strings.Join(p.Labels, ", "))
for k, v := range p.Data { for k, v := range p.Data {
@ -345,12 +400,6 @@ func (s *submitServer) saveReport(ctx context.Context, p payload, reportDir, lis
return nil, err return nil, err
} }
for i, log := range p.Logs {
if err := gzipAndSave([]byte(log.Lines), reportDir, fmt.Sprintf("logs-%04d.log.gz", i)); err != nil {
return nil, err // TODO: Rollback?
}
}
if s.ghClient == nil { if s.ghClient == nil {
// we're done here // we're done here
log.Println("GH issue submission disabled") log.Println("GH issue submission disabled")
@ -383,27 +432,24 @@ func (s *submitServer) saveReport(ctx context.Context, p payload, reportDir, lis
return &resp, nil return &resp, nil
} }
func buildGithubIssueRequest(p payload, listingURL string) github.IssueRequest { func buildGithubIssueRequest(p parsedPayload, listingURL string) github.IssueRequest {
var title string var title string
if p.Text == "" { if p.UserText == "" {
title = "Untitled report" title = "Untitled report"
} else { } else {
// set the title to the first line of the user's report // set the title to the first line of the user's report
if i := strings.IndexAny(p.Text, "\r\n"); i < 0 { if i := strings.IndexAny(p.UserText, "\r\n"); i < 0 {
title = p.Text title = p.UserText
} else { } else {
title = p.Text[0:i] title = p.UserText[0:i]
} }
} }
var bodyBuf bytes.Buffer var bodyBuf bytes.Buffer
fmt.Fprintf(&bodyBuf, "User message:\n```\n%s\n```\n", p.Text) fmt.Fprintf(&bodyBuf, "User message:\n```\n%s\n```\n", p.UserText)
for k, v := range p.Data { for k, v := range p.Data {
fmt.Fprintf(&bodyBuf, "%s: `%s`\n", k, v) fmt.Fprintf(&bodyBuf, "%s: `%s`\n", k, v)
} }
if p.Version != "" {
fmt.Fprintf(&bodyBuf, "Version: `%s`\n", p.Version)
}
fmt.Fprintf(&bodyBuf, "[Logs](%s)", listingURL) fmt.Fprintf(&bodyBuf, "[Logs](%s)", listingURL)
for _, file := range p.Files { for _, file := range p.Files {

View file

@ -17,6 +17,8 @@ limitations under the License.
package main package main
import ( import (
"compress/gzip"
"io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -32,7 +34,7 @@ import (
// //
// if tempDir is empty, a new temp dir is created, and deleted when the test // if tempDir is empty, a new temp dir is created, and deleted when the test
// completes. // completes.
func testParsePayload(t *testing.T, body, contentType string, tempDir string) (*payload, *http.Response) { func testParsePayload(t *testing.T, body, contentType string, tempDir string) (*parsedPayload, *http.Response) {
req, err := http.NewRequest("POST", "/api/submit", strings.NewReader(body)) req, err := http.NewRequest("POST", "/api/submit", strings.NewReader(body))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -73,14 +75,14 @@ func TestUnpickAndroidMangling(t *testing.T) {
if p == nil { if p == nil {
t.Fatal("parseRequest returned nil") t.Fatal("parseRequest returned nil")
} }
if p.Text != "test ylc 001" { if p.UserText != "test ylc 001" {
t.Errorf("user text: got %s, want %s", p.Text, "test ylc 001") t.Errorf("user text: got %s, want %s", p.UserText, "test ylc 001")
} }
if p.AppName != "riot-android" { if p.AppName != "riot-android" {
t.Errorf("appname: got %s, want %s", p.AppName, "riot-android") t.Errorf("appname: got %s, want %s", p.AppName, "riot-android")
} }
if p.Version != "" { if p.Data["Version"] != "" {
t.Errorf("version: got %s, want ''", p.Version) t.Errorf("version: got %s, want ''", p.Data["Version"])
} }
if p.Data["User"] != "@ylc8001:matrix.org" { if p.Data["User"] != "@ylc8001:matrix.org" {
t.Errorf("data.user: got %s, want %s", p.Data["User"], "@ylc8001:matrix.org") t.Errorf("data.user: got %s, want %s", p.Data["User"], "@ylc8001:matrix.org")
@ -108,17 +110,13 @@ func TestMultipartUpload(t *testing.T) {
checkParsedMultipartUpload(t, p) checkParsedMultipartUpload(t, p)
// check logs uploaded correctly
checkUploadedFile(t, reportDir, "logs-0000.log.gz", true, "log\nlog\nlog")
checkUploadedFile(t, reportDir, "logs-0001.log.gz", true, "log")
checkUploadedFile(t, reportDir, "logs-0002.log.gz", true, "test\n")
// check file uploaded correctly // check file uploaded correctly
dat, err := ioutil.ReadFile(filepath.Join(reportDir, "passwd.txt")) checkUploadedFile(t, reportDir, "passwd.txt", false, "bibblybobbly")
if err != nil {
t.Error("unable to read uploaded file", err)
} else {
datstr := string(dat)
wanted := "bibblybobbly"
if datstr != wanted {
t.Errorf("File contents: got %s, want %s", datstr, wanted)
}
}
} }
func multipartBody() (body string) { func multipartBody() (body string) {
@ -186,16 +184,16 @@ bibblybobbly
return return
} }
func checkParsedMultipartUpload(t *testing.T, p *payload) { func checkParsedMultipartUpload(t *testing.T, p *parsedPayload) {
wanted := "test words." wanted := "test words."
if p.Text != wanted { if p.UserText != wanted {
t.Errorf("User text: got %s, want %s", p.Text, wanted) t.Errorf("User text: got %s, want %s", p.UserText, wanted)
} }
if len(p.Logs) != 3 { if len(p.Logs) != 3 {
t.Errorf("Log length: got %d, want 3", len(p.Logs)) t.Errorf("Log length: got %d, want 3", len(p.Logs))
} }
if len(p.Data) != 1 { if len(p.Data) != 3 {
t.Errorf("Data length: got %d, want 1", len(p.Data)) t.Errorf("Data length: got %d, want 3", len(p.Data))
} }
wantedLabels := []string{"label1", "label2"} wantedLabels := []string{"label1", "label2"}
if !stringSlicesEqual(p.Labels, wantedLabels) { if !stringSlicesEqual(p.Labels, wantedLabels) {
@ -205,25 +203,17 @@ func checkParsedMultipartUpload(t *testing.T, p *payload) {
if p.Data["test-field"] != wanted { if p.Data["test-field"] != wanted {
t.Errorf("test-field: got %s, want %s", p.Data["test-field"], wanted) t.Errorf("test-field: got %s, want %s", p.Data["test-field"], wanted)
} }
wanted = "log\nlog\nlog" wanted = "logs-0000.log.gz"
if p.Logs[0].Lines != wanted { if p.Logs[0] != wanted {
t.Errorf("Log 0: got %s, want %s", p.Logs[0].Lines, wanted) t.Errorf("Log 0: got %s, want %s", p.Logs[0], wanted)
} }
wanted = "instance-0.215954445471346461492087122412" wanted = "logs-0001.log.gz"
if p.Logs[0].ID != wanted { if p.Logs[1] != wanted {
t.Errorf("Log 0 ID: got %s, want %s", p.Logs[0].ID, wanted) t.Errorf("Log 1: got %s, want %s", p.Logs[1], wanted)
} }
wanted = "log" wanted = "logs-0002.log.gz"
if p.Logs[1].Lines != wanted { if p.Logs[2] != wanted {
t.Errorf("Log 1: got %s, want %s", p.Logs[1].Lines, wanted) t.Errorf("Log 2: got %s, want %s", p.Logs[2], wanted)
}
wanted = "instance-0.067644760733513781492004890379"
if p.Logs[1].ID != wanted {
t.Errorf("Log 1 ID: got %s, want %s", p.Logs[1].ID, wanted)
}
wanted = "test\n"
if p.Logs[2].Lines != wanted {
t.Errorf("Log 2: got %s, want %s", p.Logs[2].Lines, wanted)
} }
} }
@ -274,6 +264,37 @@ file
} }
} }
func checkUploadedFile(t *testing.T, reportDir, leafName string, gzipped bool, wanted string) {
fi, err := os.Open(filepath.Join(reportDir, leafName))
if err != nil {
t.Errorf("unable to open uploaded file %s: %v", leafName, err)
return
}
defer fi.Close()
var rdr io.Reader
if !gzipped {
rdr = fi
} else {
gz, err2 := gzip.NewReader(fi)
if err2 != nil {
t.Errorf("unable to ungzip uploaded file %s: %v", leafName, err2)
return
}
defer gz.Close()
rdr = gz
}
dat, err := ioutil.ReadAll(rdr)
if err != nil {
t.Errorf("unable to read uploaded file %s: %v", leafName, err)
return
}
datstr := string(dat)
if datstr != wanted {
t.Errorf("File %s: got %s, want %s", leafName, datstr, wanted)
}
}
func mkTempDir(t *testing.T) string { func mkTempDir(t *testing.T) string {
td, err := ioutil.TempDir("", "rageshake_test") td, err := ioutil.TempDir("", "rageshake_test")
if err != nil { if err != nil {