Files
pad/internal/server/handlers_export_bundle.go
T
xarmian a0336e0248 feat(attachments): bundle attachments + manifest in workspace export (TASK-884) (#305)
* feat(attachments): bundle attachments + manifest in workspace export (TASK-884)

GET /workspaces/{ws}/export?format=tar streams a gzip'd tar bundle:

  pad-export.json              # the existing WorkspaceExport JSON
  attachments/manifest.json    # uuid → {filename, mime, size, hash, ...}
  attachments/<uuid>.<ext>     # original blobs only — no thumbnails

Default (no ?format) keeps returning JSON so existing automation
hitting the endpoint without a query param continues to work
unchanged. The CLI's pad workspace export now opts into the bundle
by default; pass --json for the legacy items-only output.

Implementation:
- store.WorkspaceAttachmentsForExport returns originals only
  (parent_id IS NULL); thumbnails are re-derived on import via the
  existing pipeline so shipping them would double the bundle size.
- handleExportWorkspaceBundle streams chunks straight into the
  response writer rather than buffering — a workspace with multi-
  GB of attachments would otherwise pin that much memory.
- AttachmentManifest is versioned (separate from WorkspaceExport
  version) so the bundle layout can evolve independently.
- bundleAttachmentPath is exported (lowercase package fn) so the
  import path in TASK-885 can resolve manifest entries to tar
  entries without duplicating the filename logic.
- CLI gates against writing binary tar.gz to a TTY and appends the
  conventional extension when -o is passed without one.

Tests:
- TestExportBundle_RoundTrip: two uploads → bundle contains
  pad-export.json + manifest + 2 blobs whose bytes match the
  uploads + manifest decodes cleanly + WorkspaceExport decodes.
- TestExportBundle_HidesThumbnails: synthetic thumbnail row, the
  manifest excludes it.
- TestExportBundle_LegacyJSONStillWorks: no ?format param returns
  application/json with a decodable WorkspaceExport (backward
  compat regression guard).

Parent: PLAN-866. TASK-885 (import path + UUID remap) consumes the
manifest produced here.

* fix(attachments): stream export bundle + revert default to JSON per Codex (round 1)

Two findings from Codex on PR #305:

1. CLI buffered the entire response in memory via RawGet → io.ReadAll,
   defeating the server-side streaming design and risking OOM on a
   multi-GB bundle. Added Client.RawStream which copies the response
   body straight into an io.Writer; export now opens the target file
   and streams directly into it.

2. Default tar.gz output broke `pad export → pad import` round trip
   because the import handler still only accepts JSON. Reverted the
   CLI default to JSON; bundle is now opt-in via --bundle. The flag
   docstring notes that TASK-885 will flip the default once import
   handles bundles.

* fix(attachments): surface tar/gzip close errors and truncation per Codex (round 2)

Codex round 2 finding: deferred tw.Close() / gzw.Close() ignored
errors. If a backend returned fewer bytes than size_bytes claimed,
io.Copy returned nil, the tar writer's "missed N bytes" trip fired
at Close, and the handler still completed a 200 OK with a corrupt
bundle that gunzip would later refuse to decompress — silently from
the operator's perspective.

Two changes:
1. The deferred close now logs both tw.Close() and gzw.Close()
   errors with structured context, so a corruption-on-finalize
   trip shows up in the operator log.
2. streamAttachmentToTar checks the bytes-copied count against
   a.SizeBytes after io.Copy and returns a per-attachment error
   when they disagree. The error is logged with attachment_id +
   storage_key so an operator can correlate the corruption with
   the row to investigate.

Regression test: TestExportBundle_TruncatedBlobLogsError forces a
size_bytes/blob desync via direct UPDATE and asserts the resulting
bundle bytes don't decode cleanly. (HTTP status stays 200 because
headers are already on the wire by the time we detect the desync;
that's an inherent limitation of mid-stream errors, but the new
logs + close-error surfacing make the failure observable.)

* fix(attachments): X-Bundle-Status trailer for export-stream success per Codex (round 3)

Codex P1 round 3: even with the per-blob truncation log + tar/gzip
close-error logs, mid-stream failures looked successful to clients.
The CLI's RawStream finished without a transport error, the file
landed on disk, and "Exported workspace" printed regardless of
whether the bundle was actually complete.

Two complementary signals now mark a clean stream:

1. HTTP trailer X-Bundle-Status. The handler declares the trailer
   in the initial Trailer header and sets it to "ok" only after
   tw.Close() and gzw.Close() both return without error. CLI checks
   the trailer after streaming and discards the file + returns
   error if it's absent or non-"ok".

2. The handler skips the deferred clean close on the error path,
   leaving the gzip footer unwritten. A client that ignores the
   trailer (curl, third-party tooling) still sees a corrupt gzip
   stream that gunzip refuses to decompress.

CLI: pad workspace export --bundle now removes any partial output
file on failure rather than leaving a corrupt one behind.
Client.RawStream signature changed to return (bytes, *http.Response,
error) so callers can inspect resp.Trailer; the only caller is the
export command.

Tests: TestExportBundle_TruncatedBlobAbortsStream now asserts both
signals (trailer absent + gzip/tar can't fully decode), and
TestExportBundle_SuccessTrailer pins the happy-path trailer.
2026-04-29 18:30:43 -04:00

284 lines
9.7 KiB
Go

package server
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// exportBundleVersion pins the on-disk bundle layout. Bumped if the
// internal structure changes in a way the import path can't handle
// transparently. Independent of WorkspaceExport.Version so we can
// evolve the JSON schema and the bundle format on different cadences.
const exportBundleVersion = 1
// handleExportWorkspaceBundle streams a tar.gz containing the
// workspace JSON export plus every original (non-thumbnail)
// attachment blob and a manifest. Bundle layout:
//
// pad-export.json
// attachments/manifest.json
// attachments/<uuid>.<ext>
//
// Tar entries are written in a deterministic order so byte-identical
// workspaces produce byte-identical bundles (modulo `exported_at`):
//
// 1. pad-export.json
// 2. attachments/manifest.json
// 3. attachments/<uuid>.<ext> — sorted by (created_at, id) via the
// store's ORDER BY in WorkspaceAttachmentsForExport.
//
// We stream chunks straight to the response writer rather than
// buffering — a workspace with multi-GB of attachments would otherwise
// pin that much memory for the duration of the download.
//
// On error mid-stream the connection is dropped (the client sees a
// truncated tar that gunzip will fail to decompress); that's the
// least-bad option since headers + early bytes are already on the
// wire. The error is logged with attachment_id context so operators
// can diagnose without re-running the export.
//
// Auth: owner. The plain JSON path is owner-only too; the bundle
// has the same access scope plus the user-uploaded attachment
// blobs, so don't loosen.
func (s *Server) handleExportWorkspaceBundle(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
ws, ok := s.getWorkspace(w, r)
if !ok {
return
}
export, err := s.store.ExportWorkspace(ws.Slug)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
// Build the manifest before writing anything to the response so a
// store error doesn't strand half a tar header. Streaming the
// blobs themselves still happens after we commit to the response.
attachments, err := s.store.WorkspaceAttachmentsForExport(ws.ID)
if err != nil {
writeInternalError(w, err)
return
}
// If attachments exist, the registry must be wired — otherwise
// the blobs aren't reachable and the bundle would be a lie.
if len(attachments) > 0 && s.attachments == nil {
writeError(w, http.StatusServiceUnavailable, "attachments_disabled",
"Attachment storage is not configured on this server")
return
}
manifest := models.AttachmentManifest{
Version: exportBundleVersion,
Entries: make([]models.AttachmentManifestEntry, 0, len(attachments)),
}
for _, a := range attachments {
entry := models.AttachmentManifestEntry{
ID: a.ID,
Filename: a.Filename,
MIME: a.MimeType,
SizeBytes: a.SizeBytes,
ContentHash: a.ContentHash,
Width: a.Width,
Height: a.Height,
UploadedBy: a.UploadedBy,
CreatedAt: a.CreatedAt.UTC().Format(time.RFC3339),
}
if a.ItemID != nil {
entry.ItemID = *a.ItemID
}
// ParentID + Variant stay empty for shipped entries — derived
// rows are filtered out in WorkspaceAttachmentsForExport.
manifest.Entries = append(manifest.Entries, entry)
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-export.tar.gz"`, ws.Slug))
// Bundles are streamed; we don't know the final size up front. No
// Content-Length header — http.Server falls through to chunked
// transfer-encoding, which the gzip+tar pair handles fine.
//
// X-Bundle-Status is an HTTP trailer that CLI clients check after
// streaming — without it, mid-stream errors are invisible because
// headers + the first tar entries are already on the wire. The
// trailer is set to "ok" only when every entry wrote cleanly;
// otherwise the client treats the file as corrupt and discards it.
// Codex caught the silent-corruption gap on PR #305 round 3.
w.Header().Set("Trailer", BundleStatusTrailer)
gzw := gzip.NewWriter(w)
tw := tar.NewWriter(gzw)
// Track streaming success. On error we want the gzip stream to
// terminate WITHOUT a clean trailer so a client that ignores the
// HTTP trailer still detects corruption via gunzip failure.
// Closing tw/gzw flushes the gzip trailer (CRC + size); skipping
// those calls leaves the gzip footer unwritten and the client's
// gzip reader returns ErrUnexpectedEOF.
streamOK := false
defer func() {
if !streamOK {
// Mid-stream failure path. Don't write a clean gzip
// trailer — CLI clients will see an io error reading
// the gzip stream AND a missing X-Bundle-Status:ok
// trailer. Either signal is sufficient on its own.
return
}
if err := tw.Close(); err != nil {
s.logBundleStreamError(r.Context(), "tar close", err)
return
}
if err := gzw.Close(); err != nil {
s.logBundleStreamError(r.Context(), "gzip close", err)
return
}
// Only mark the bundle ok in the trailer once every byte is
// flushed and both close calls returned without error.
w.Header().Set(BundleStatusTrailer, BundleStatusOK)
}()
// 1. pad-export.json
exportJSON, err := json.MarshalIndent(export, "", " ")
if err != nil {
s.logBundleStreamError(r.Context(), "marshal export", err)
return
}
if err := writeTarFile(tw, "pad-export.json", exportJSON); err != nil {
s.logBundleStreamError(r.Context(), "write pad-export.json", err)
return
}
// 2. attachments/manifest.json
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
s.logBundleStreamError(r.Context(), "marshal manifest", err)
return
}
if err := writeTarFile(tw, "attachments/manifest.json", manifestJSON); err != nil {
s.logBundleStreamError(r.Context(), "write manifest", err)
return
}
// 3. attachment blobs
for _, a := range attachments {
if err := s.streamAttachmentToTar(r.Context(), tw, &a); err != nil {
s.logBundleStreamError(r.Context(), "stream attachment", err,
"attachment_id", a.ID, "storage_key", a.StorageKey)
return
}
}
// All entries wrote cleanly. The deferred close + trailer set
// run after this returns; nothing else to do here.
streamOK = true
}
// BundleStatusTrailer is the HTTP response trailer that signals
// whether the export bundle stream completed without errors. A
// successful response sets it to BundleStatusOK; mid-stream errors
// leave it absent. Exported so the CLI can check after streaming.
const (
BundleStatusTrailer = "X-Bundle-Status"
BundleStatusOK = "ok"
)
// streamAttachmentToTar resolves the storage backend for one
// attachment row, writes a tar header sized to size_bytes, and copies
// the blob from the backend into the tar writer in 32 KiB chunks.
//
// Filename inside the tar is `attachments/<uuid><ext>` where ext
// comes from the original filename. Falls back to .bin when the
// upload had no extension. Using <uuid> avoids name collisions when
// two distinct attachments share the same display filename, which
// happens routinely with screenshots ("Screenshot 2025-...png").
func (s *Server) streamAttachmentToTar(ctx context.Context, tw *tar.Writer, a *models.Attachment) error {
store, err := s.attachments.Resolve(a.StorageKey)
if err != nil {
return fmt.Errorf("resolve storage backend: %w", err)
}
body, err := store.Get(ctx, a.StorageKey)
if err != nil {
return fmt.Errorf("get blob: %w", err)
}
defer body.Close()
hdr := &tar.Header{
Name: bundleAttachmentPath(a.ID, a.Filename),
Mode: 0o644,
Size: a.SizeBytes,
ModTime: a.CreatedAt.UTC(),
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("tar header: %w", err)
}
n, err := io.Copy(tw, body)
if err != nil {
return fmt.Errorf("copy blob: %w", err)
}
// io.Copy on a backend that returns fewer bytes than expected
// would otherwise return nil and the tar writer would surface a
// "missed N bytes" error only at Close. Catch the truncation
// here so the per-blob log carries the attachment id +
// storage_key; the deferred tw.Close() then trips its own
// missed-bytes error which we already log.
if n != a.SizeBytes {
return fmt.Errorf("blob truncated: copied %d bytes, expected %d (size_bytes column out of sync with backend?)",
n, a.SizeBytes)
}
return nil
}
// bundleAttachmentPath builds the tar entry name for an attachment.
// Exported as a function (not a const helper) so the import path can
// import it and resolve manifest entries without duplicating the
// filename logic.
func bundleAttachmentPath(id, filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
if ext == "" {
ext = ".bin"
}
return "attachments/" + id + ext
}
// writeTarFile writes a single buffered file into the tar archive.
// Used for the small JSON entries (pad-export.json + manifest.json);
// blob entries stream through streamAttachmentToTar instead.
func writeTarFile(tw *tar.Writer, name string, data []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(data)),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
return nil
}
// logBundleStreamError logs at warn level with structured context.
// Mid-stream failures can't be turned into a clean HTTP error response
// (we've already started the response body), so the operator-facing
// log is the best we can do for diagnostics.
func (s *Server) logBundleStreamError(_ context.Context, op string, err error, kv ...any) {
args := append([]any{"op", op, "error", err}, kv...)
slog.Warn("export bundle stream failed", args...)
}