-
Notifications
You must be signed in to change notification settings - Fork 680
tests: add /debug endpoint to fakestore to allow interrupting downloads #16881
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
69708b0
7db3281
6c3a37d
79a2a29
c55bc94
4e3a531
ee7319d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ import ( | |
| "regexp" | ||
| "strconv" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/snapcore/snapd/asserts" | ||
|
|
@@ -67,6 +68,8 @@ type snapCachedInfo struct { | |
|
|
||
| // Store is our snappy software store implementation | ||
| type Store struct { | ||
| lock sync.Mutex | ||
|
|
||
| url string | ||
| blobDir string | ||
| assertDir string | ||
|
|
@@ -79,6 +82,11 @@ type Store struct { | |
| channelRepository *ChannelRepository | ||
|
|
||
| snapsCache map[string]snapCachedInfo | ||
|
|
||
| // endpoint -> quota value, note this is stateful, i.e. the quota is counted | ||
| // for all requests to a given endpoint and after exceeding it, all | ||
| // subsequent requests will fail until it is reset through a request | ||
| killAfter map[string]int64 | ||
| } | ||
|
|
||
| // NewStore creates a new store server serving snaps from the given top directory and assertions from topDir/asserts. If assertFallback is true missing assertions are looked up in the main online store. | ||
|
|
@@ -105,13 +113,16 @@ func NewStore(topDir, addr string, assertFallback bool) *Store { | |
| rootDir: filepath.Join(topDir, "channels"), | ||
| }, | ||
| snapsCache: make(map[string]snapCachedInfo), | ||
| killAfter: make(map[string]int64), | ||
| } | ||
|
|
||
| mux.HandleFunc("/", rootEndpoint) | ||
| mux.HandleFunc("/api/v1/snaps/search", store.searchEndpoint) | ||
| mux.HandleFunc("/api/v1/snaps/details/", store.detailsEndpoint) | ||
| mux.HandleFunc("/api/v1/snaps/metadata", store.bulkEndpoint) | ||
| mux.Handle("/download/", http.StripPrefix("/download/", http.FileServer(http.Dir(topDir)))) | ||
|
|
||
| fileServer := http.StripPrefix("/download/", http.FileServer(http.Dir(topDir))) | ||
| mux.Handle("/download/", logRangeHeader(store.applyKillAfter(fileServer.ServeHTTP))) | ||
|
|
||
| mux.HandleFunc("/api/v1/snaps/auth/nonces", store.nonceEndpoint) | ||
| mux.HandleFunc("/api/v1/snaps/auth/sessions", store.sessionEndpoint) | ||
|
|
@@ -122,6 +133,8 @@ func NewStore(topDir, addr string, assertFallback bool) *Store { | |
|
|
||
| mux.HandleFunc("/v2/repairs/", store.repairsEndpoint) | ||
|
|
||
| mux.HandleFunc("/debug", store.debugEndpoint) | ||
|
|
||
| return store | ||
| } | ||
|
|
||
|
|
@@ -354,6 +367,178 @@ type detailsReplyJSON struct { | |
| Base string `json:"base,omitempty"` | ||
| } | ||
|
|
||
| type killAfterWriter struct { | ||
| http.ResponseWriter | ||
| killAfter int64 | ||
| } | ||
|
|
||
| func (kaw *killAfterWriter) Write(p []byte) (int, error) { | ||
| if kaw.killAfter <= 0 { | ||
| // already exceeded the quota, kill immediately | ||
| kaw.hijackAndClose() | ||
| return 0, fmt.Errorf("connection killed") | ||
| } | ||
|
|
||
| toWrite := p | ||
| shouldKill := false | ||
| if int64(len(p)) > kaw.killAfter { | ||
| // write only up to the remaining quota | ||
| toWrite = p[:kaw.killAfter] | ||
| shouldKill = true | ||
| } | ||
|
|
||
| n, err := kaw.ResponseWriter.Write(toWrite) | ||
| kaw.killAfter -= int64(n) | ||
|
|
||
| if shouldKill { | ||
| kaw.hijackAndClose() | ||
| return n, fmt.Errorf("connection killed") | ||
| } | ||
|
|
||
| return n, err | ||
| } | ||
|
|
||
| func (kaw *killAfterWriter) hijackAndClose() { | ||
| // flush any buffered data before closing | ||
| if f, ok := kaw.ResponseWriter.(http.Flusher); ok { | ||
| f.Flush() | ||
| } | ||
| // and proceed to close | ||
| hj, ok := kaw.ResponseWriter.(http.Hijacker) | ||
| if ok { | ||
| conn, _, _ := hj.Hijack() | ||
| conn.Close() | ||
| } | ||
| } | ||
|
|
||
| type debugRequestJSON struct { | ||
| Action string `json:"action"` | ||
|
|
||
| KillPath string `json:"kill-path"` | ||
| KillAfter int64 `json:"kill-after"` | ||
| } | ||
|
|
||
| type debugResultJSON struct { | ||
| KillAfter map[string]int64 `json:"kill-after"` | ||
| } | ||
|
|
||
| func (s *Store) debugEndpoint(w http.ResponseWriter, req *http.Request) { | ||
| if req.Method == http.MethodGet { | ||
| out, err := func() ([]byte, error) { | ||
| s.lock.Lock() | ||
| defer s.lock.Unlock() | ||
| res := debugResultJSON{ | ||
| KillAfter: s.killAfter, | ||
| } | ||
| return json.Marshal(res) | ||
| }() | ||
| if err != nil { | ||
| http.Error(w, fmt.Sprintf("cannot marshal: %v", err), 500) | ||
| return | ||
| } | ||
| w.Write(out) | ||
| return | ||
| } | ||
|
|
||
| if req.Method != http.MethodPost { | ||
| w.WriteHeader(405) // Method Not Allowed | ||
| return | ||
| } | ||
|
|
||
| var debugReq *debugRequestJSON | ||
| decoder := json.NewDecoder(req.Body) | ||
| if err := decoder.Decode(&debugReq); err != nil { | ||
| http.Error(w, fmt.Sprintf("cannot decode request body: %v", err), 400) | ||
| return | ||
| } | ||
|
|
||
| var err error | ||
| switch debugReq.Action { | ||
| case "kill-request": | ||
| err = s.debugActionKillDownload(debugReq) | ||
| case "reset": | ||
| s.debugActionReset(debugReq) | ||
| default: | ||
| err = fmt.Errorf("unexpected debug action %q", debugReq.Action) | ||
| } | ||
| if err != nil { | ||
| w.WriteHeader(400) | ||
| fmt.Fprint(w, err.Error()) | ||
| } | ||
| } | ||
|
|
||
| func (s *Store) debugActionKillDownload(debugReq *debugRequestJSON) error { | ||
| if debugReq.KillPath == "" { | ||
| return fmt.Errorf("kill-path cannot be empty") | ||
| } | ||
|
|
||
| if strings.HasPrefix(debugReq.KillPath, "/debug/") { | ||
| return fmt.Errorf("kill-path cannot be applied to /debug/ endpoints") | ||
| } | ||
|
|
||
| s.lock.Lock() | ||
| defer s.lock.Unlock() | ||
|
|
||
| if debugReq.KillAfter == 0 { | ||
| delete(s.killAfter, debugReq.KillPath) | ||
| } else { | ||
| s.killAfter[debugReq.KillPath] = debugReq.KillAfter | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (s *Store) debugActionReset(debugReq *debugRequestJSON) { | ||
| s.lock.Lock() | ||
| defer s.lock.Unlock() | ||
|
|
||
| s.killAfter = map[string]int64{} | ||
| } | ||
|
|
||
| func logRangeHeader(handler http.HandlerFunc) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, req *http.Request) { | ||
| path := req.URL.Path | ||
| if len(req.Header["Range"]) > 0 { | ||
| logger.Noticef(`requested range for %s is %v`, path, req.Header["Range"]) | ||
| } | ||
| handler(w, req) | ||
| } | ||
| } | ||
|
|
||
| func (s *Store) applyKillAfter(handler http.HandlerFunc) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, req *http.Request) { | ||
| path := req.URL.Path | ||
|
|
||
| killAfter, exists := func() (int64, bool) { | ||
| s.lock.Lock() | ||
| defer s.lock.Unlock() | ||
| v, ok := s.killAfter[path] | ||
| return v, ok | ||
| }() | ||
|
|
||
| if !exists { | ||
| handler(w, req) | ||
| return | ||
| } | ||
|
|
||
| kaw := &killAfterWriter{ | ||
| ResponseWriter: w, | ||
| killAfter: killAfter, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hah, unit tests found a funny race with how the counter is used. AFAIU what happens is that within Write() we may update the internal value of
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pushed a fix |
||
| } | ||
| handler(kaw, req) | ||
|
|
||
| if kaw.killAfter < 0 { | ||
| logger.Noticef("%s was force killed, quota exceeded", path) | ||
| } | ||
|
|
||
| // update killAfter for path after write finishes | ||
| func() { | ||
| s.lock.Lock() | ||
| defer s.lock.Unlock() | ||
| s.killAfter[path] = kaw.killAfter | ||
| }() | ||
| } | ||
| } | ||
|
|
||
| func (s *Store) searchEndpoint(w http.ResponseWriter, req *http.Request) { | ||
| w.WriteHeader(501) | ||
| fmt.Fprintf(w, "search not implemented") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note to self, we could switch to gorilla mux to at least get the default logger for every request
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done in #16883