fix(wiki): purge through the plugin page actions and count reset deletions
ci / ci (pull_request) Successful in 3m52s
ci / ci (pull_request) Successful in 3m52s
deploy-wiki purged pages with DELETE /api/v3/topics/{tid}. A NodeBB
running nodebb-plugin-westgate-wiki refuses that for topics in wiki
categories, because wiki revision history is plugin-owned, so every
purge failed with HTTP 400 and the deploy exited 1. Purge now goes
through the plugin's own page actions: tombstone the page, then
hard-purge it, since a page must be tombstoned before it can be purged.
Archiving is unaffected: it rewrites the page through the ordinary post
edit the plugin allows, rather than deleting anything.
Every fake NodeBB in the deploy tests now goes through one constructor
that refuses native topic mutation the way the plugin does. The old
fakes answered the core API, which is why this shipped green.
A namespace reset can reach pages NodeBB will not delete at all - the
wiki home topic above all - so those are skipped rather than aborting
the reset. NodeBB answers 403 for that and for a token without purge
privileges alike, and the response cannot tell them apart; what can is
scope, so a category where nothing at all could be deleted still fails
the run.
Under --reset-managed-namespaces the preview reported stale: 0 on a run
that would delete every topic in the managed categories, because the
reset purge never went through stale computation. The reset deletions
are now counted as stale, which is the number callers word their
destructive-policy warning around, and the summary names how many of
them the manifest has no record of writing - the deletions a re-seed
cannot undo.
Closes #99
Closes #100
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+127
-28
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -52,7 +53,7 @@ type DeployWikiOptions struct {
|
||||
TitlePrefixMinLength int
|
||||
}
|
||||
|
||||
type deployResult struct {
|
||||
type DeployResult struct {
|
||||
LocalPages int
|
||||
Created int
|
||||
Updated int
|
||||
@@ -63,6 +64,19 @@ type deployResult struct {
|
||||
Drifted int
|
||||
Renamed int
|
||||
Manifest string
|
||||
// ResetPurged counts the deletions queued by --reset-managed-namespaces.
|
||||
// They are also included in Stale and Purged, because callers warn about
|
||||
// destructive policies in terms of the stale count: sow-topdata's
|
||||
// deploy-wiki wrapper prints "of the pages counted as 'stale' below, they
|
||||
// will be DELETED from NodeBB" directly above this block.
|
||||
ResetPurged int
|
||||
// ResetUnrecognized counts the subset of ResetPurged that the deploy
|
||||
// manifest has no record of writing. Those deletions are the ones a
|
||||
// re-seed cannot undo.
|
||||
ResetUnrecognized int
|
||||
// ResetSkipped counts reset targets NodeBB refused to delete, such as the
|
||||
// wiki home topic, which the plugin excludes from tombstone and purge.
|
||||
ResetSkipped int
|
||||
}
|
||||
|
||||
type wikiDeployPage struct {
|
||||
@@ -100,6 +114,12 @@ type wikiDeployPlan struct {
|
||||
Content string
|
||||
RemoteHash string
|
||||
Title string
|
||||
// Reset marks a purge queued by --reset-managed-namespaces rather than by
|
||||
// stale computation over the manifest.
|
||||
Reset bool
|
||||
// Unrecognized marks a reset purge whose topic the manifest has no record
|
||||
// of writing.
|
||||
Unrecognized bool
|
||||
}
|
||||
|
||||
type wikiNamespacesDocument struct {
|
||||
@@ -116,7 +136,7 @@ type wikiNamespaceDeclaration struct {
|
||||
EditPolicy string `json:"edit_policy" yaml:"edit_policy"`
|
||||
}
|
||||
|
||||
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (deployResult, error) {
|
||||
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (DeployResult, error) {
|
||||
if progress == nil {
|
||||
progress = func(string) {}
|
||||
}
|
||||
@@ -132,19 +152,19 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(opts.SourceDir); err != nil {
|
||||
return deployResult{}, fmt.Errorf("wiki source directory not found: %w", err)
|
||||
return DeployResult{}, fmt.Errorf("wiki source directory not found: %w", err)
|
||||
}
|
||||
if opts.Endpoint == "" {
|
||||
return deployResult{}, errors.New("NODEBB_API_ENDPOINT is required")
|
||||
return DeployResult{}, errors.New("NODEBB_API_ENDPOINT is required")
|
||||
}
|
||||
if opts.Token == "" {
|
||||
return deployResult{}, errors.New("NODEBB_API_TOKEN is required")
|
||||
return DeployResult{}, errors.New("NODEBB_API_TOKEN is required")
|
||||
}
|
||||
if opts.Username != "" || opts.Password != "" || opts.NotesDelimiter != "" {
|
||||
return deployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings")
|
||||
return DeployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings")
|
||||
}
|
||||
if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" && opts.StalePolicy != "purge" {
|
||||
return deployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy)
|
||||
return DeployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy)
|
||||
}
|
||||
if opts.TitlePrefixMinLength <= 0 {
|
||||
opts.TitlePrefixMinLength = p.EffectiveConfig().TopData.Wiki.TitlePrefixMinLength
|
||||
@@ -154,7 +174,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
|
||||
if len(namespaces) == 0 {
|
||||
declarations, err := loadWikiNamespaceDeclarations(p)
|
||||
if err != nil {
|
||||
return deployResult{}, err
|
||||
return DeployResult{}, err
|
||||
}
|
||||
for _, declaration := range declarations {
|
||||
namespaces = append(namespaces, declaration.ID)
|
||||
@@ -164,7 +184,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
|
||||
}
|
||||
envCategories, err := categoryIDsFromNamespaceEnv(declarations)
|
||||
if err != nil {
|
||||
return deployResult{}, err
|
||||
return DeployResult{}, err
|
||||
}
|
||||
if len(envCategories) > 0 {
|
||||
merged := envCategories
|
||||
@@ -183,7 +203,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
|
||||
progress(fmt.Sprintf("Collecting local wiki pages from %s", opts.SourceDir))
|
||||
pages, err := collectLocalPages(opts.SourceDir, opts.PageIndexPath, namespaces)
|
||||
if err != nil {
|
||||
return deployResult{}, err
|
||||
return DeployResult{}, err
|
||||
}
|
||||
progress(fmt.Sprintf("Loaded %d local wiki page(s)", len(pages)))
|
||||
manifest := loadDeployManifest(manifestPath)
|
||||
@@ -193,7 +213,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
|
||||
progress(fmt.Sprintf("Planning NodeBB wiki deploy for %d local page(s)", len(pages)))
|
||||
plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client, progress)
|
||||
if err != nil {
|
||||
return deployResult{}, err
|
||||
return DeployResult{}, err
|
||||
}
|
||||
result.LocalPages = len(pages)
|
||||
result.Manifest = manifestPath
|
||||
@@ -212,6 +232,8 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
|
||||
}
|
||||
orderedPlans := orderNodeBBDeployPlans(plans)
|
||||
progress(fmt.Sprintf("Executing NodeBB wiki actions: total %d, create %d, update %d, rename %d, archive %d, purge %d", len(orderedPlans), result.Created, result.Updated, result.Renamed, result.Archived, result.Purged))
|
||||
resetPurgedByCID := map[int]int{}
|
||||
resetSkippedByCID := map[int]int{}
|
||||
for i, plan := range orderedPlans {
|
||||
if shouldReportNodeBBDeployActionProgress(i, len(orderedPlans)) {
|
||||
progress(fmt.Sprintf("Executing NodeBB wiki action %d/%d: %s %s", i+1, len(orderedPlans), plan.Action, plan.Page.PageID))
|
||||
@@ -242,19 +264,52 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
|
||||
return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
|
||||
}
|
||||
case "archive":
|
||||
// Archiving rewrites the page rather than removing it, so it goes
|
||||
// through the ordinary post edit the wiki plugin allows; only
|
||||
// delete, restore, and purge are reserved to the page actions.
|
||||
if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil {
|
||||
return result, fmt.Errorf("deploy wiki page %q: archive NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
|
||||
}
|
||||
case "purge":
|
||||
if err := client.purgeTopic(plan.Entry.TID); err != nil {
|
||||
if err := client.purgeWikiPage(plan.Entry.TID); err != nil {
|
||||
// A namespace reset sweeps every topic in the category, so it
|
||||
// can reach pages NodeBB will not delete at all — the wiki home
|
||||
// topic above all. Skip those rather than abandoning the reset.
|
||||
if plan.Reset && isNodeBBWikiPageUndeletable(err) {
|
||||
result.ResetSkipped++
|
||||
result.ResetPurged--
|
||||
result.Purged--
|
||||
result.Stale--
|
||||
if plan.Unrecognized {
|
||||
result.ResetUnrecognized--
|
||||
}
|
||||
resetSkippedByCID[plan.Entry.CID]++
|
||||
progress(fmt.Sprintf("NodeBB refused to delete wiki topic %d during managed namespace reset; skipping it", plan.Entry.TID))
|
||||
continue
|
||||
}
|
||||
return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err)
|
||||
}
|
||||
if plan.Reset {
|
||||
resetPurgedByCID[plan.Entry.CID]++
|
||||
}
|
||||
case "rename":
|
||||
if err := client.renameWikiPage(plan.Entry.TID, plan.Entry.CID, plan.Title); err != nil {
|
||||
return result, fmt.Errorf("deploy wiki page %q: rename NodeBB topic %d to %q: %w", plan.Page.PageID, plan.Entry.TID, plan.Title, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// NodeBB answers 403 both for the pages it will never delete — the wiki
|
||||
// home topic — and for a token without purge privileges, and the two are
|
||||
// not distinguishable from the response. What tells them apart is scope: a
|
||||
// category where nothing at all could be deleted is a privilege problem,
|
||||
// not a home page. Reporting that as a completed reset would leave the
|
||||
// manifest claiming a fresh start over pages that are all still there, so
|
||||
// the next deploy would recreate every one of them as a duplicate.
|
||||
for _, cid := range slices.Sorted(maps.Keys(resetSkippedByCID)) {
|
||||
if resetPurgedByCID[cid] == 0 {
|
||||
return result, fmt.Errorf("NodeBB refused every managed namespace reset deletion in category %d (%d topic(s)); check that the deploy token has wiki purge privileges there", cid, resetSkippedByCID[cid])
|
||||
}
|
||||
}
|
||||
if err := saveDeployManifest(manifestPath, nextManifest); err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -486,7 +541,7 @@ func isDir(path string) bool {
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, deployResult, wikiDeployManifest, error) {
|
||||
func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, DeployResult, wikiDeployManifest, error) {
|
||||
if progress == nil {
|
||||
progress = func(string) {}
|
||||
}
|
||||
@@ -494,17 +549,23 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
|
||||
next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
|
||||
remotePagesByCID := map[int][]nodeBBWikiPage{}
|
||||
var plans []wikiDeployPlan
|
||||
var result deployResult
|
||||
var result DeployResult
|
||||
if opts.ResetManagedNamespaces {
|
||||
if !opts.AllowCreates && len(pageIDs) > 0 {
|
||||
return nil, result, next, errors.New("wiki managed namespace reset requires --create so current generated pages can be recreated")
|
||||
}
|
||||
resetPlans, purged, err := planManagedNamespaceReset(opts, client, progress)
|
||||
resetPlans, purged, unrecognized, err := planManagedNamespaceReset(opts, manifest, client, progress)
|
||||
if err != nil {
|
||||
return nil, result, next, err
|
||||
}
|
||||
plans = append(plans, resetPlans...)
|
||||
result.Purged += purged
|
||||
// A namespace reset deletes remote pages the same way stale purge does,
|
||||
// so it is counted as stale: that is the number callers word their
|
||||
// destructive-policy warning around.
|
||||
result.Stale += purged
|
||||
result.ResetPurged = purged
|
||||
result.ResetUnrecognized = unrecognized
|
||||
manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
|
||||
for _, cid := range opts.CategoryIDs {
|
||||
if cid != 0 {
|
||||
@@ -682,7 +743,11 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
|
||||
return plans, result, next, nil
|
||||
}
|
||||
|
||||
func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, error) {
|
||||
// planManagedNamespaceReset queues a purge for every topic in each managed
|
||||
// category, not only the ones the manifest says we wrote. The second return is
|
||||
// the plan count; the third is how many of those topics the manifest has no
|
||||
// record of, which is the subset a re-seed cannot put back.
|
||||
func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManifest, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, int, error) {
|
||||
namespaces := slices.Clone(opts.Namespaces)
|
||||
if len(namespaces) == 0 {
|
||||
for namespace := range opts.CategoryIDs {
|
||||
@@ -691,7 +756,15 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro
|
||||
}
|
||||
slices.Sort(namespaces)
|
||||
|
||||
manifestTIDs := map[int]struct{}{}
|
||||
for _, entry := range manifest.Pages {
|
||||
if entry.TID != 0 {
|
||||
manifestTIDs[entry.TID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
seenTIDs := map[int]struct{}{}
|
||||
unrecognized := 0
|
||||
var plans []wikiDeployPlan
|
||||
for _, namespace := range namespaces {
|
||||
cid := opts.CategoryIDs[namespace]
|
||||
@@ -701,7 +774,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro
|
||||
progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid))
|
||||
remotePages, err := client.listNamespacePages(cid)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int {
|
||||
return a.TID - b.TID
|
||||
@@ -714,19 +787,25 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro
|
||||
continue
|
||||
}
|
||||
seenTIDs[remotePage.TID] = struct{}{}
|
||||
_, known := manifestTIDs[remotePage.TID]
|
||||
if !known {
|
||||
unrecognized++
|
||||
}
|
||||
title := strings.TrimSpace(remotePage.Title)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(remotePage.TitleLeaf)
|
||||
}
|
||||
pageID := fmt.Sprintf("%s:reset-topic-%d", namespace, remotePage.TID)
|
||||
plans = append(plans, wikiDeployPlan{
|
||||
Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace},
|
||||
Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title},
|
||||
Action: "purge",
|
||||
Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace},
|
||||
Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title},
|
||||
Action: "purge",
|
||||
Reset: true,
|
||||
Unrecognized: !known,
|
||||
})
|
||||
}
|
||||
}
|
||||
return plans, len(plans), nil
|
||||
return plans, len(plans), unrecognized, nil
|
||||
}
|
||||
|
||||
func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) {
|
||||
@@ -1611,16 +1690,36 @@ func (c *nodeBBClient) renameWikiPage(tid, cid int, title string) error {
|
||||
return c.request("PUT", "/api/v3/plugins/westgate-wiki/page/move", body, nil)
|
||||
}
|
||||
|
||||
func (c *nodeBBClient) purgeTopic(tid int) error {
|
||||
// purgeWikiPage deletes a wiki topic through the wiki plugin's own page
|
||||
// actions. The core DELETE /api/v3/topics/{tid} route is refused for topics in
|
||||
// wiki categories, because revision history is plugin-owned, so a purge has to
|
||||
// tombstone the page first and then hard-purge it.
|
||||
func (c *nodeBBClient) purgeWikiPage(tid int) error {
|
||||
if tid == 0 {
|
||||
return fmt.Errorf("NodeBB topic purge requires topic id")
|
||||
return fmt.Errorf("NodeBB wiki page purge requires topic id")
|
||||
}
|
||||
err := c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil)
|
||||
body := map[string]any{"tid": tid}
|
||||
if err := c.request(http.MethodPut, "/api/v3/plugins/westgate-wiki/page/tombstone", body, nil); err != nil {
|
||||
if isNodeBBMissingResource(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := c.request(http.MethodDelete, "/api/v3/plugins/westgate-wiki/page/hard-purge", body, nil); err != nil {
|
||||
if isNodeBBMissingResource(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNodeBBWikiPageUndeletable reports whether NodeBB refused to delete the page
|
||||
// outright rather than failing transiently. The wiki home topic answers this
|
||||
// way: the plugin excludes it from tombstone, restore, and purge alike.
|
||||
func isNodeBBWikiPageUndeletable(err error) bool {
|
||||
var httpErr nodeBBHTTPError
|
||||
if errors.As(err, &httpErr) && (httpErr.Status == http.StatusNotFound || httpErr.Status == http.StatusGone) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
return errors.As(err, &httpErr) && httpErr.Status == http.StatusForbidden
|
||||
}
|
||||
|
||||
func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) {
|
||||
|
||||
Reference in New Issue
Block a user