Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/gateway-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Integration test: publish through a real cvmfs_gateway.
#
# Builds a mountless cvmfs_gateway + cvmfs_receiver from cvmfs@devel, backed by
# S3 (Garage), and drives a full publish through cvmfs-prepub in gateway mode.
# This is the only test that exercises the live lease → payload → commit/graft
# path against the actual gateway; the Go unit tests only cover the client side.
#
# It is heavy (builds cvmfs from source), so it runs on demand and on pull
# requests that touch the gateway client or the fixtures — not on every push.

name: gateway-publish

on:
workflow_dispatch:
inputs:
cvmfs_ref:
description: "cvmfs git ref to build the gateway from"
required: false
default: "devel"
pull_request:
paths:
- "internal/lease/**"
- "internal/api/**"
- "cmd/prepub/**"
- "test/integration/gateway/**"
- ".github/workflows/gateway-publish.yml"
push:
branches:
- "gateway-dedicated-graft-endpoint-test"

# A gateway build from source is expensive; don't pile up concurrent runs.
concurrency:
group: gateway-publish-${{ github.ref }}
cancel-in-progress: true

jobs:
gateway-publish:
runs-on: ubuntu-latest
timeout-minutes: 90
env:
CVMFS_REF: ${{ github.event.inputs.cvmfs_ref || 'devel' }}
steps:
- name: Check out cvmfs-bits
uses: actions/checkout@v4
with:
path: cvmfs-bits

- name: Check out cvmfs (${{ env.CVMFS_REF }})
uses: actions/checkout@v4
with:
repository: cvmfs/cvmfs
ref: ${{ env.CVMFS_REF }}
path: cvmfs

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: cvmfs-bits/go.mod
cache-dependency-path: cvmfs-bits/go.sum

- name: Run gateway publish integration test
working-directory: cvmfs-bits
env:
CVMFS_SRC: ${{ github.workspace }}/cvmfs
run: ./test/integration/gateway/run.sh

- name: Dump gateway logs on failure
if: failure()
working-directory: cvmfs-bits
env:
CVMFS_SRC: ${{ github.workspace }}/cvmfs
run: docker compose -f test/integration/gateway/docker-compose.yml logs --no-color || true
25 changes: 21 additions & 4 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,29 @@ prepub service itself defines:
|---|---|
| `POST /api/v1/leases/{path}` | Reserve a path-scoped lease for a sub-path |
| `POST /api/v1/payloads` | Submit pre-processed catalog diff + objects |
| `DELETE /api/v1/leases/{token}` | Release the lease (commit or abort) |
| `POST /api/v1/leases/{token}` | Commit the lease (standard `DiffRec` merge) |
| `POST /api/v1/leases/{token}/graft` | Commit the lease via the DirectGraft fast path (see below) |
| `DELETE /api/v1/leases/{token}` | Release the lease (abort) |

> The real gateway does not implement `PUT /api/v1/leases/{token}` (it returns
> `405`); there is no client-side lease heartbeat against this endpoint.

The pre-publisher targets this API directly as a first-class gateway client. No gateway modifications are required.
**DirectGraft fast path.** When the publisher is publishing a brand-new
directory subtree — nothing to diff against the parent — it can request the
`.../graft` endpoint instead of the standard commit. The gateway then grafts the
pre-built subtree catalog directly into the parent catalog
(`WritableCatalogManager::TryGraftNestedCatalog`), skipping the `DiffRec`
catalog merge entirely. The request body is **identical** to a standard commit
(`old_root_hash`, `new_root_hash`, `tag_name`, `tag_description`); the dedicated
endpoint — not a body flag — selects the fast path. It is toggled client-side by
`--gateway-direct-graft` (the `DirectGraft` commit field) and is a pure
performance optimisation: both paths produce identical repository state for the
"publish new subtree" case.

The pre-publisher targets this API directly as a first-class gateway client. The
standard commit, payload, and lease endpoints require no gateway modifications;
the experimental `.../graft` endpoint requires the dedicated graft support added
in cvmfs PR #4296.

---

Expand Down Expand Up @@ -1444,7 +1461,7 @@ for manual or infrequent publishes on other repositories.

| Layer | Status |
|---|---|
| `cvmfs_gateway` | Unmodified; targeted via existing lease-and-payload API |
| `cvmfs_gateway` | Targeted via the existing lease-and-payload API; the DirectGraft fast path additionally requires the `.../graft` endpoint from cvmfs PR #4296 |
| Stratum 1 replication daemon | Unmodified (`cvmfs_server snapshot` unchanged) |
| Stratum 0 CAS layout | Identical; objects written with the same path structure |
| Signed manifest format | Identical; gateway signs as usual |
Expand Down Expand Up @@ -2131,7 +2148,7 @@ Use this checklist before deploying. Full detail is in Chapter 34.

**Publisher (Stratum 0):**
- [ ] Recent Go toolchain on the Stratum 0 node
- [ ] `cvmfs_gateway` ≥ 1.2 with the lease-and-payload API
- [ ] `cvmfs_gateway` ≥ 1.2 with the lease-and-payload API (plus the `.../graft` endpoint from cvmfs PR #4296 if using `--gateway-direct-graft`)
- [ ] Write access to the CAS backend (local filesystem or S3-compatible)
- [ ] Outbound HTTPS from the publisher to the gateway

Expand Down
10 changes: 6 additions & 4 deletions internal/api/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,12 @@ type Orchestrator struct {
Stratum0URL string
// DirectGraft enables the fast-path commit on the receiver side.
//
// When true, the commit POST body carries "direct_graft":true, instructing
// cvmfs_receiver to skip DiffRec and graft the pre-built subtree catalog
// directly into the parent catalog. This is correct only when the lease
// path is a brand-new directory with no pre-existing content.
// When true, the finalise step POSTs to the dedicated gateway graft
// endpoint (/api/v1/leases/<token>/graft) instead of the standard commit
// endpoint, instructing cvmfs_receiver to skip DiffRec and graft the
// pre-built subtree catalog directly into the parent catalog. This is
// correct only when the lease path is a brand-new directory with no
// pre-existing content.
//
// Set to false (default) to use the standard CommitProcessor/DiffRec path,
// which handles arbitrary add/remove/modify operations safely. Both paths
Expand Down
11 changes: 6 additions & 5 deletions internal/lease/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ type CommitRequest struct {

// DirectGraft requests the fast-path commit on the receiver side.
//
// When true the commit POST body carries "direct_graft":true, instructing
// the cvmfs_receiver to skip DiffRec entirely and graft the pre-built
// subtree catalog (already uploaded via SubmitPayload) directly into the
// parent catalog. This is correct only when the lease path is a brand-new
// directory with no pre-existing content.
// When true the finalise step POSTs to the dedicated gateway graft endpoint
// /api/v1/leases/<token>/graft instead of the standard commit endpoint,
// instructing the cvmfs_receiver to skip DiffRec entirely and graft the
// pre-built subtree catalog (already uploaded via SubmitPayload) directly
// into the parent catalog. This is correct only when the lease path is a
// brand-new directory with no pre-existing content.
//
// Set to false (the default) to use the standard CommitProcessor / DiffRec
// path, which works for arbitrary add/remove/modify operations. Both paths
Expand Down
81 changes: 51 additions & 30 deletions internal/lease/lease.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,10 +505,53 @@ func (c *Client) Heartbeat(ctx context.Context, token string, interval time.Dura

// ── Backend interface implementation ─────────────────────────────────────────

// gatewayCommitURL returns the endpoint that finalises a publish transaction.
//
// The standard commit is POST /api/v1/leases/<token>. When req.DirectGraft is
// set the request instead targets the dedicated graft endpoint
// POST /api/v1/leases/<token>/graft (gateway/frontend/leases.go
// MakeGraftHandler): the gateway grafts the pre-built subtree catalog into the
// parent, skipping the DiffRec catalog merge. The fast path is selected by the
// endpoint itself, not by a flag in the request body.
func (c *Client) gatewayCommitURL(req CommitRequest) string {
u := fmt.Sprintf("%s/api/v1/leases/%s", c.BaseURL, url.PathEscape(req.Token))
if req.DirectGraft {
u += "/graft"
}
return u
}

// gatewayCommitBody marshals the JSON body shared by the commit and graft
// endpoints. Both decode an identical body — catalog root hashes plus the
// optional snapshot tag — so no "direct_graft" field is sent; the endpoint
// (see gatewayCommitURL) selects the DirectGraft fast path.
func gatewayCommitBody(req CommitRequest) ([]byte, error) {
// Use NewRootHashSuffixed if provided; otherwise fall back to CatalogHash
// for backward compatibility.
newHash := req.NewRootHashSuffixed
if newHash == "" {
newHash = req.CatalogHash
}

body, err := json.Marshal(map[string]interface{}{
"old_root_hash": req.OldRootHash,
"new_root_hash": newHash,
"tag_name": req.TagName,
"tag_description": req.TagDescription,
})
if err != nil {
return nil, fmt.Errorf("marshalling commit body: %w", err)
}
return body, nil
}

// Commit implements Backend for the gateway mode. It uploads all staged CAS
// objects to the gateway via SubmitPayload, then finalises the publish by
// POSTing to /api/v1/leases/<token> — distinct from DELETE which cancels.
//
// When req.DirectGraft is set the finalise step instead POSTs to the dedicated
// graft endpoint /api/v1/leases/<token>/graft; see gatewayCommitURL.
//
// Per gateway/frontend/leases.go:
//
// POST /api/v1/leases/<token> → handleCommitLease (finalise/publish)
Expand All @@ -534,25 +577,14 @@ func (c *Client) Commit(ctx context.Context, req CommitRequest) error {
return fmt.Errorf("gateway submit payload: %w", err)
}

// 2. POST /api/v1/leases/<token> to finalise the publish transaction.
commitURL := fmt.Sprintf("%s/api/v1/leases/%s", c.BaseURL, url.PathEscape(req.Token))

// Use NewRootHashSuffixed if provided; otherwise fall back to CatalogHash for backward compatibility.
newHash := req.NewRootHashSuffixed
if newHash == "" {
newHash = req.CatalogHash
}
// 2. POST the finalise request. DirectGraft targets the dedicated graft
// endpoint; otherwise the standard commit endpoint (see gatewayCommitURL).
commitURL := c.gatewayCommitURL(req)

commitBody, err := json.Marshal(map[string]interface{}{
"old_root_hash": req.OldRootHash,
"new_root_hash": newHash,
"tag_name": req.TagName,
"tag_description": req.TagDescription,
"direct_graft": req.DirectGraft,
})
commitBody, err := gatewayCommitBody(req)
if err != nil {
span.RecordError(err)
return fmt.Errorf("marshalling commit body: %w", err)
return err
}

postReq, err := http.NewRequestWithContext(ctx, "POST", commitURL, bytes.NewReader(commitBody))
Expand Down Expand Up @@ -614,23 +646,12 @@ func (c *Client) CommitFinalizeOnly(ctx context.Context, req CommitRequest) erro
ctx, span := c.obs.Tracer.Start(ctx, "lease.commit_finalize_only")
defer span.End()

commitURL := fmt.Sprintf("%s/api/v1/leases/%s", c.BaseURL, url.PathEscape(req.Token))

newHash := req.NewRootHashSuffixed
if newHash == "" {
newHash = req.CatalogHash
}
commitURL := c.gatewayCommitURL(req)

commitBody, err := json.Marshal(map[string]interface{}{
"old_root_hash": req.OldRootHash,
"new_root_hash": newHash,
"tag_name": req.TagName,
"tag_description": req.TagDescription,
"direct_graft": req.DirectGraft,
})
commitBody, err := gatewayCommitBody(req)
if err != nil {
span.RecordError(err)
return fmt.Errorf("marshalling commit body: %w", err)
return err
}

postReq, err := http.NewRequestWithContext(ctx, "POST", commitURL, bytes.NewReader(commitBody))
Expand Down
78 changes: 78 additions & 0 deletions internal/lease/lease_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,84 @@ func TestCommit_SendsTagFields(t *testing.T) {
}
}

// TestCommit_DirectGraftRouting verifies that DirectGraft selects the dedicated
// graft endpoint (POST /api/v1/leases/<token>/graft) while a plain commit uses
// POST /api/v1/leases/<token>, and that no "direct_graft" flag is sent in the
// body (the endpoint, not a body field, selects the fast path).
func TestCommit_DirectGraftRouting(t *testing.T) {
const (
token = "lease-tok-graft"
catalogHash = "feedface"
)

for _, tc := range []struct {
name string
directGraft bool
wantPath string
}{
{"plain commit", false, "/api/v1/leases/" + token},
{"direct graft", true, "/api/v1/leases/" + token + "/graft"},
} {
t.Run(tc.name, func(t *testing.T) {
var (
mu sync.Mutex
capturedPath string
capturedBody []byte
)

srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/payloads":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/leases/"):
data, _ := io.ReadAll(r.Body)
mu.Lock()
capturedPath = r.URL.Path
capturedBody = data
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()

c := newTestClient(t, srv)
store := &mockObjectReader{
objects: map[string][]byte{catalogHash: []byte("fake-compressed-catalog")},
}

req := CommitRequest{
Token: token,
CatalogHash: catalogHash,
ObjectStore: store,
DirectGraft: tc.directGraft,
}
if err := c.Commit(context.Background(), req); err != nil {
t.Fatalf("Commit: %v", err)
}

mu.Lock()
path := capturedPath
body := capturedBody
mu.Unlock()

if path != tc.wantPath {
t.Errorf("commit POST path = %q; want %q", path, tc.wantPath)
}
var got map[string]interface{}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode commit body %q: %v", body, err)
}
if _, ok := got["direct_graft"]; ok {
t.Errorf("commit body must not carry a direct_graft flag; got %q", body)
}
})
}
}

// TestCommit_EmptyTagName checks that when TagName is empty the commit body
// still contains a "tag_name" key (empty string) so the gateway receives a
// well-formed request.
Expand Down
Loading