[mirror] Scalable static site server for Git forges (like GitHub Pages)
1package git_pages
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "strings"
9
10 "google.golang.org/protobuf/proto"
11)
12
13type UpdateOutcome int
14
15const (
16 UpdateError UpdateOutcome = iota
17 UpdateTimeout
18 UpdateCreated
19 UpdateReplaced
20 UpdateDeleted
21 UpdateNoChange
22)
23
24type UpdateResult struct {
25 outcome UpdateOutcome
26 manifest *Manifest
27 err error
28}
29
30func Update(
31 ctx context.Context, webRoot string, oldManifest, newManifest *Manifest,
32 opts ModifyManifestOptions,
33) UpdateResult {
34 var err error
35 var storedManifest *Manifest
36
37 outcome := UpdateError
38 if IsManifestEmpty(newManifest) {
39 storedManifest, err = newManifest, backend.DeleteManifest(ctx, webRoot, opts)
40 if err == nil {
41 if oldManifest == nil {
42 outcome = UpdateNoChange
43 } else {
44 outcome = UpdateDeleted
45 }
46 }
47 } else if err = PrepareManifest(ctx, newManifest); err == nil {
48 storedManifest, err = StoreManifest(ctx, webRoot, newManifest, opts)
49 if err == nil {
50 domain, _, _ := strings.Cut(webRoot, "/")
51 err = backend.CreateDomain(ctx, domain)
52 }
53 if err == nil {
54 if oldManifest == nil {
55 outcome = UpdateCreated
56 } else if CompareManifest(oldManifest, storedManifest) {
57 outcome = UpdateNoChange
58 } else {
59 outcome = UpdateReplaced
60 }
61 }
62 }
63
64 if err == nil {
65 status := ""
66 switch outcome {
67 case UpdateCreated:
68 status = "created"
69 case UpdateReplaced:
70 status = "replaced"
71 case UpdateDeleted:
72 status = "deleted"
73 case UpdateNoChange:
74 status = "unchanged"
75 }
76 if storedManifest.Commit != nil {
77 logc.Printf(ctx, "update %s ok: %s %s", webRoot, *storedManifest.Commit, status)
78 } else {
79 logc.Printf(ctx, "update %s ok: %s", webRoot, status)
80 }
81 } else {
82 logc.Printf(ctx, "update %s err: %s", webRoot, err)
83 }
84
85 return UpdateResult{outcome, storedManifest, err}
86}
87
88func UpdateFromRepository(
89 ctx context.Context,
90 webRoot string,
91 repoURL string,
92 branch string,
93) (result UpdateResult) {
94 span, ctx := ObserveFunction(ctx, "UpdateFromRepository", "repo.url", repoURL)
95 defer span.Finish()
96
97 logc.Printf(ctx, "update %s: %s %s\n", webRoot, repoURL, branch)
98
99 // Ignore errors; worst case we have to re-fetch all of the blobs.
100 oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{})
101
102 newManifest, err := FetchRepository(ctx, repoURL, branch, oldManifest)
103 if errors.Is(err, context.DeadlineExceeded) {
104 result = UpdateResult{UpdateTimeout, nil, fmt.Errorf("update timeout")}
105 } else if err != nil {
106 result = UpdateResult{UpdateError, nil, err}
107 } else {
108 result = Update(ctx, webRoot, oldManifest, newManifest, ModifyManifestOptions{})
109 }
110
111 observeUpdateResult(result)
112 return result
113}
114
115var errArchiveFormat = errors.New("unsupported archive format")
116
117func UpdateFromArchive(
118 ctx context.Context,
119 webRoot string,
120 contentType string,
121 reader io.Reader,
122) (result UpdateResult) {
123 var err error
124
125 // Ignore errors; worst case we have to re-fetch all of the blobs.
126 oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{})
127
128 extractTar := func(ctx context.Context, reader io.Reader) (*Manifest, error) {
129 return ExtractTar(ctx, reader, oldManifest)
130 }
131
132 var newManifest *Manifest
133 switch contentType {
134 case "application/x-tar":
135 logc.Printf(ctx, "update %s: (tar)", webRoot)
136 newManifest, err = extractTar(ctx, reader) // yellow?
137 case "application/x-tar+gzip":
138 logc.Printf(ctx, "update %s: (tar.gz)", webRoot)
139 newManifest, err = ExtractGzip(ctx, reader, extractTar) // definitely yellow.
140 case "application/x-tar+zstd":
141 logc.Printf(ctx, "update %s: (tar.zst)", webRoot)
142 newManifest, err = ExtractZstd(ctx, reader, extractTar)
143 case "application/zip":
144 logc.Printf(ctx, "update %s: (zip)", webRoot)
145 newManifest, err = ExtractZip(ctx, reader, oldManifest)
146 default:
147 err = errArchiveFormat
148 }
149
150 if err != nil {
151 logc.Printf(ctx, "update %s err: %s", webRoot, err)
152 result = UpdateResult{UpdateError, nil, err}
153 } else {
154 result = Update(ctx, webRoot, oldManifest, newManifest, ModifyManifestOptions{})
155 }
156
157 observeUpdateResult(result)
158 return
159}
160
161func PartialUpdateFromArchive(
162 ctx context.Context,
163 webRoot string,
164 contentType string,
165 reader io.Reader,
166 parents CreateParentsMode,
167) (result UpdateResult) {
168 var err error
169
170 // Here the old manifest is used both as a substrate to which a patch is applied, as well
171 // as a "load linked" operation for a future "store conditional" update which, taken together,
172 // create an atomic compare-and-swap operation.
173 oldManifest, oldMetadata, err := backend.GetManifest(ctx, webRoot,
174 GetManifestOptions{BypassCache: true})
175 if err != nil {
176 logc.Printf(ctx, "patch %s err: %s", webRoot, err)
177 return UpdateResult{UpdateError, nil, err}
178 }
179
180 applyTarPatch := func(ctx context.Context, reader io.Reader) (*Manifest, error) {
181 // Clone the manifest before starting to mutate it. `GetManifest` may return cached
182 // `*Manifest` objects, which should never be mutated.
183 newManifest := &Manifest{}
184 proto.Merge(newManifest, oldManifest)
185 newManifest.RepoUrl = nil
186 newManifest.Branch = nil
187 newManifest.Commit = nil
188 if err := ApplyTarPatch(newManifest, reader, parents); err != nil {
189 return nil, err
190 } else {
191 return newManifest, nil
192 }
193 }
194
195 var newManifest *Manifest
196 switch contentType {
197 case "application/x-tar":
198 logc.Printf(ctx, "patch %s: (tar)", webRoot)
199 newManifest, err = applyTarPatch(ctx, reader)
200 case "application/x-tar+gzip":
201 logc.Printf(ctx, "patch %s: (tar.gz)", webRoot)
202 newManifest, err = ExtractGzip(ctx, reader, applyTarPatch)
203 case "application/x-tar+zstd":
204 logc.Printf(ctx, "patch %s: (tar.zst)", webRoot)
205 newManifest, err = ExtractZstd(ctx, reader, applyTarPatch)
206 default:
207 err = errArchiveFormat
208 }
209
210 if err != nil {
211 logc.Printf(ctx, "patch %s err: %s", webRoot, err)
212 result = UpdateResult{UpdateError, nil, err}
213 } else {
214 result = Update(ctx, webRoot, oldManifest, newManifest,
215 ModifyManifestOptions{
216 IfUnmodifiedSince: oldMetadata.LastModified,
217 IfMatch: oldMetadata.ETag,
218 })
219 // The `If-Unmodified-Since` precondition is internally generated here, which means its
220 // failure shouldn't be surfaced as-is in the HTTP response. If we also accepted options
221 // from the client, then that precondition failure should surface in the response.
222 if errors.Is(result.err, ErrPreconditionFailed) {
223 result.err = ErrWriteConflict
224 }
225 }
226
227 observeUpdateResult(result)
228 return
229}
230
231func observeUpdateResult(result UpdateResult) {
232 var unresolvedRefErr UnresolvedRefError
233 if errors.As(result.err, &unresolvedRefErr) {
234 // This error is an expected outcome of an incremental update's probe phase.
235 } else if errors.Is(result.err, ErrWriteConflict) {
236 // This error is an expected outcome of an incremental update losing a race.
237 } else if result.err != nil {
238 ObserveError(result.err)
239 }
240}