internal/worker: /clean endpoint supports a single module

The /clean endpoint can be used to clean all versions of a given
module.

For golang/go#45852

Change-Id: Ie39d142c9c1049213eb5319d31196d77e3b7052f
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/315829
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/internal/postgres/clean.go b/internal/postgres/clean.go
index 16e9e22..f25cda1 100644
--- a/internal/postgres/clean.go
+++ b/internal/postgres/clean.go
@@ -73,12 +73,12 @@
 
 // CleanModuleVersions deletes each module version from the DB and marks it as cleaned
 // in module_version_states.
-func (db *DB) CleanModuleVersions(ctx context.Context, mvs []ModuleVersion) (err error) {
+func (db *DB) CleanModuleVersions(ctx context.Context, mvs []ModuleVersion, reason string) (err error) {
 	defer derrors.Wrap(&err, "CleanModuleVersions(%d modules)", len(mvs))
 
 	status := derrors.ToStatus(derrors.Cleaned)
 	for _, mv := range mvs {
-		if err := db.UpdateModuleVersionStatus(ctx, mv.ModulePath, mv.Version, status, ""); err != nil {
+		if err := db.UpdateModuleVersionStatus(ctx, mv.ModulePath, mv.Version, status, reason); err != nil {
 			return err
 		}
 		if err := db.DeleteModule(ctx, mv.ModulePath, mv.Version); err != nil {
@@ -87,3 +87,27 @@
 	}
 	return nil
 }
+
+// CleanModule deletes all versions of the given module path from the DB and marks them
+// as cleaned in module_version_states.
+func (db *DB) CleanModule(ctx context.Context, modulePath, reason string) (err error) {
+	defer derrors.Wrap(&err, "CleanModule(%q)", modulePath)
+
+	var mvs []ModuleVersion
+	err = db.db.RunQuery(ctx, `
+		SELECT version
+		FROM modules
+		WHERE module_path = $1
+	`, func(rows *sql.Rows) error {
+		var v string
+		if err := rows.Scan(&v); err != nil {
+			return err
+		}
+		mvs = append(mvs, ModuleVersion{modulePath, v})
+		return nil
+	}, modulePath)
+	if err != nil {
+		return err
+	}
+	return db.CleanModuleVersions(ctx, mvs, reason)
+}
diff --git a/internal/postgres/clean_test.go b/internal/postgres/clean_test.go
index e5aac90..6c65182 100644
--- a/internal/postgres/clean_test.go
+++ b/internal/postgres/clean_test.go
@@ -18,7 +18,7 @@
 	"golang.org/x/pkgsite/internal/testing/sample"
 )
 
-func TestClean(t *testing.T) {
+func TestCleanBulk(t *testing.T) {
 	t.Parallel()
 	ctx := context.Background()
 	testDB, release := acquire(t)
@@ -61,7 +61,7 @@
 		t.Errorf("got  %v\nwant %v", got, want)
 	}
 
-	if err := testDB.CleanModuleVersions(ctx, mvs); err != nil {
+	if err := testDB.CleanModuleVersions(ctx, mvs, "test"); err != nil {
 		t.Fatal(err)
 	}
 
@@ -72,3 +72,27 @@
 		}
 	}
 }
+
+func TestCleanModule(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+	testDB, release := acquire(t)
+	defer release()
+
+	const modulePath = "m.com"
+	versions := []string{"v1.0.0", "v1.2.3"}
+	for _, v := range versions {
+		m := sample.Module(modulePath, v, "")
+		MustInsertModule(ctx, t, testDB, m)
+	}
+	if err := testDB.CleanModule(ctx, modulePath, ""); err != nil {
+		t.Fatal(err)
+	}
+
+	for _, v := range versions {
+		_, err := testDB.GetModuleInfo(ctx, modulePath, v)
+		if !errors.Is(err, derrors.NotFound) {
+			t.Errorf("%s: got %v, want NotFound", v, err)
+		}
+	}
+}
diff --git a/internal/worker/server.go b/internal/worker/server.go
index 6e8a973..745abea 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -204,7 +204,8 @@
 	// manual: delete the specified module version.
 	handle("/delete/", http.StripPrefix("/delete", rmw(s.errorHandler(s.handleDelete))))
 
-	// scheduled: clean some module versions.
+	// scheduled ("limit" query param): clean some eligible module versions selected from the DB
+	// manual ("module" query param): clean all versions of a given module.
 	handle("/clean", rmw(s.errorHandler(s.handleClean)))
 
 	handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath.String()))))
@@ -585,20 +586,48 @@
 // Consider a module version for cleaning only if it is older than this.
 const cleanDays = 7
 
+// handleClean handles a request to clean module versions.
+//
+// If the request has a 'limit' query parameter, then up to that many module versions
+// are selected from the DB among those eligible for cleaning, and they are cleaned.
+//
+// If the request has a 'module' query parameter, all versions of that module path
+// are cleaned.
+//
+// It is an error if neither or both query parameters are provided.
 func (s *Server) handleClean(w http.ResponseWriter, r *http.Request) (err error) {
 	defer derrors.Wrap(&err, "handleClean")
 	ctx := r.Context()
-	limit := parseLimitParam(r, 1000)
-	mvs, err := s.db.GetModuleVersionsToClean(ctx, cleanDays, limit)
-	if err != nil {
-		return err
+
+	limit := r.FormValue("limit")
+	module := r.FormValue("module")
+	switch {
+	case limit == "" && module == "":
+		return errors.New("need 'limit' or 'module' query param")
+
+	case limit != "" && module != "":
+		return errors.New("need exactly one of 'limit' or 'module' query param")
+
+	case limit != "":
+		mvs, err := s.db.GetModuleVersionsToClean(ctx, cleanDays, parseLimitParam(r, 1000))
+		if err != nil {
+			return err
+		}
+		log.Infof(ctx, "cleaning %d modules", len(mvs))
+		if err := s.db.CleanModuleVersions(ctx, mvs, "Bulk deleted via /clean endpoint"); err != nil {
+			return err
+		}
+		fmt.Fprintf(w, "Cleaned %d module versions.\n", len(mvs))
+		return nil
+
+	default: // module != ""
+		log.Infof(ctx, "cleaning module %q", module)
+		if err := s.db.CleanModule(ctx, module, "Manually deleted via /clean endpoint"); err != nil {
+			return err
+		}
+		fmt.Fprintf(w, "Cleaned module %q\n", module)
+		return nil
 	}
-	log.Infof(ctx, "cleaning %d modules", len(mvs))
-	if err := s.db.CleanModuleVersions(ctx, mvs); err != nil {
-		return err
-	}
-	fmt.Fprintf(w, "Cleaned %d module versions.\n", len(mvs))
-	return nil
 }
 
 func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) {