[mirror] Scalable static site server for Git forges (like GitHub Pages)
at v0.1.0 11 kB view raw
1package git_pages 2 3import ( 4 "context" 5 "errors" 6 "flag" 7 "fmt" 8 "io" 9 "log" 10 "log/slog" 11 "net" 12 "net/http" 13 "net/url" 14 "os" 15 "runtime/debug" 16 "strings" 17 18 automemlimit "github.com/KimMachineGun/automemlimit/memlimit" 19 "github.com/c2h5oh/datasize" 20 "github.com/prometheus/client_golang/prometheus/promhttp" 21) 22 23var config *Config 24var wildcards []*WildcardPattern 25var backend Backend 26 27func configureFeatures() (err error) { 28 if len(config.Features) > 0 { 29 log.Println("features:", strings.Join(config.Features, ", ")) 30 } 31 return 32} 33 34func configureMemLimit() (err error) { 35 // Avoid being OOM killed by not garbage collecting early enough. 36 memlimitBefore := datasize.ByteSize(debug.SetMemoryLimit(-1)) 37 automemlimit.SetGoMemLimitWithOpts( 38 automemlimit.WithLogger(slog.New(slog.DiscardHandler)), 39 automemlimit.WithProvider( 40 automemlimit.ApplyFallback( 41 automemlimit.FromCgroup, 42 automemlimit.FromSystem, 43 ), 44 ), 45 automemlimit.WithRatio(float64(config.Limits.MaxHeapSizeRatio)), 46 ) 47 memlimitAfter := datasize.ByteSize(debug.SetMemoryLimit(-1)) 48 if memlimitBefore == memlimitAfter { 49 log.Println("memlimit: now", memlimitBefore.HR()) 50 } else { 51 log.Println("memlimit: was", memlimitBefore.HR(), "now", memlimitAfter.HR()) 52 } 53 return 54} 55 56func configureWildcards() (err error) { 57 newWildcards, err := TranslateWildcards(config.Wildcard) 58 if err != nil { 59 return err 60 } else { 61 wildcards = newWildcards 62 return nil 63 } 64} 65 66func listen(name string, listen string) net.Listener { 67 if listen == "-" { 68 return nil 69 } 70 71 protocol, address, ok := strings.Cut(listen, "/") 72 if !ok { 73 log.Fatalf("%s: %s: malformed endpoint", name, listen) 74 } 75 76 listener, err := net.Listen(protocol, address) 77 if err != nil { 78 log.Fatalf("%s: %s\n", name, err) 79 } 80 81 return listener 82} 83 84func panicHandler(handler http.Handler) http.Handler { 85 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 86 defer func() { 87 if err := recover(); err != nil { 88 log.Printf("panic: %s %s %s: %s\n%s", 89 r.Method, r.Host, r.URL.Path, err, string(debug.Stack())) 90 http.Error(w, 91 fmt.Sprintf("internal server error: %s", err), 92 http.StatusInternalServerError, 93 ) 94 } 95 }() 96 handler.ServeHTTP(w, r) 97 }) 98} 99 100func serve(listener net.Listener, handler http.Handler) { 101 if listener != nil { 102 handler = panicHandler(handler) 103 104 server := http.Server{Handler: handler} 105 server.Protocols = new(http.Protocols) 106 server.Protocols.SetHTTP1(true) 107 if config.Feature("serve-h2c") { 108 server.Protocols.SetUnencryptedHTTP2(true) 109 } 110 log.Fatalln(server.Serve(listener)) 111 } 112} 113 114func webRootArg(arg string) string { 115 switch strings.Count(arg, "/") { 116 case 0: 117 return arg + "/.index" 118 case 1: 119 return arg 120 default: 121 log.Fatalf("webroot argument must be either 'domain.tld' or 'domain.tld/dir") 122 return "" 123 } 124} 125 126func fileOutputArg() (writer io.WriteCloser) { 127 var err error 128 if flag.NArg() == 0 { 129 writer = os.Stdout 130 } else { 131 writer, err = os.Create(flag.Arg(0)) 132 if err != nil { 133 log.Fatalln(err) 134 } 135 } 136 return 137} 138 139func usage() { 140 fmt.Fprintf(os.Stderr, "Usage:\n") 141 fmt.Fprintf(os.Stderr, "(server) "+ 142 "git-pages [-config <file>|-no-config]\n") 143 fmt.Fprintf(os.Stderr, "(admin) "+ 144 "git-pages {-run-migration <name>}\n") 145 fmt.Fprintf(os.Stderr, "(info) "+ 146 "git-pages {-print-config-env-vars|-print-config}\n") 147 fmt.Fprintf(os.Stderr, "(cli) "+ 148 "git-pages {-get-blob|-get-manifest|-get-archive|-update-site} <ref> [file]\n") 149 flag.PrintDefaults() 150} 151 152func Main() { 153 flag.Usage = usage 154 printConfigEnvVars := flag.Bool("print-config-env-vars", false, 155 "print every recognized configuration environment variable and exit") 156 printConfig := flag.Bool("print-config", false, 157 "print configuration as JSON and exit") 158 configTomlPath := flag.String("config", "", 159 "load configuration from `filename` (default: 'config.toml')") 160 noConfig := flag.Bool("no-config", false, 161 "run without configuration file (configure via environment variables)") 162 runMigration := flag.String("run-migration", "", 163 "run a store `migration` (one of: create-domain-markers)") 164 getBlob := flag.String("get-blob", "", 165 "write contents of `blob` ('sha256-xxxxxxx...xxx')") 166 getManifest := flag.String("get-manifest", "", 167 "write manifest for `site` (either 'domain.tld' or 'domain.tld/dir') as ProtoJSON") 168 getArchive := flag.String("get-archive", "", 169 "write archive for `site` (either 'domain.tld' or 'domain.tld/dir') in tar format") 170 updateSite := flag.String("update-site", "", 171 "update `site` (either 'domain.tld' or 'domain.tld/dir') from archive or repository URL") 172 flag.Parse() 173 174 var cliOperations int 175 if *getBlob != "" { 176 cliOperations += 1 177 } 178 if *getManifest != "" { 179 cliOperations += 1 180 } 181 if *getArchive != "" { 182 cliOperations += 1 183 } 184 if cliOperations > 1 { 185 log.Fatalln("-get-blob, -get-manifest, and -get-archive are mutually exclusive") 186 } 187 188 if *configTomlPath != "" && *noConfig { 189 log.Fatalln("-no-config and -config are mutually exclusive") 190 } 191 192 if *printConfigEnvVars { 193 PrintConfigEnvVars() 194 return 195 } 196 197 var err error 198 if *configTomlPath == "" && !*noConfig { 199 *configTomlPath = "config.toml" 200 } 201 if config, err = Configure(*configTomlPath); err != nil { 202 log.Fatalln("config:", err) 203 } 204 205 if *printConfig { 206 fmt.Println(config.DebugJSON()) 207 return 208 } 209 210 InitObservability() 211 defer FiniObservability() 212 213 if err = errors.Join( 214 configureFeatures(), 215 configureMemLimit(), 216 configureWildcards(), 217 ); err != nil { 218 log.Fatalln(err) 219 } 220 221 switch { 222 case *runMigration != "": 223 if backend, err = CreateBackend(&config.Storage); err != nil { 224 log.Fatalln(err) 225 } 226 227 if err := RunMigration(context.Background(), *runMigration); err != nil { 228 log.Fatalln(err) 229 } 230 231 case *getBlob != "": 232 if backend, err = CreateBackend(&config.Storage); err != nil { 233 log.Fatalln(err) 234 } 235 236 reader, _, _, err := backend.GetBlob(context.Background(), *getBlob) 237 if err != nil { 238 log.Fatalln(err) 239 } 240 io.Copy(fileOutputArg(), reader) 241 242 case *getManifest != "": 243 if backend, err = CreateBackend(&config.Storage); err != nil { 244 log.Fatalln(err) 245 } 246 247 webRoot := webRootArg(*getManifest) 248 manifest, _, err := backend.GetManifest(context.Background(), webRoot, GetManifestOptions{}) 249 if err != nil { 250 log.Fatalln(err) 251 } 252 fmt.Fprintln(fileOutputArg(), ManifestDebugJSON(manifest)) 253 254 case *getArchive != "": 255 if backend, err = CreateBackend(&config.Storage); err != nil { 256 log.Fatalln(err) 257 } 258 259 webRoot := webRootArg(*getArchive) 260 manifest, manifestMtime, err := 261 backend.GetManifest(context.Background(), webRoot, GetManifestOptions{}) 262 if err != nil { 263 log.Fatalln(err) 264 } 265 CollectTar(context.Background(), fileOutputArg(), manifest, manifestMtime) 266 267 case *updateSite != "": 268 if backend, err = CreateBackend(&config.Storage); err != nil { 269 log.Fatalln(err) 270 } 271 272 if flag.NArg() != 1 { 273 log.Fatalln("update source must be provided as the argument") 274 } 275 276 sourceURL, err := url.Parse(flag.Arg(0)) 277 if err != nil { 278 log.Fatalln(err) 279 } 280 281 var result UpdateResult 282 if sourceURL.Scheme == "" { 283 file, err := os.Open(sourceURL.Path) 284 if err != nil { 285 log.Fatalln(err) 286 } 287 defer file.Close() 288 289 var contentType string 290 switch { 291 case strings.HasSuffix(sourceURL.Path, ".zip"): 292 contentType = "application/zip" 293 case strings.HasSuffix(sourceURL.Path, ".tar"): 294 contentType = "application/x-tar" 295 case strings.HasSuffix(sourceURL.Path, ".tar.gz"): 296 contentType = "application/x-tar+gzip" 297 case strings.HasSuffix(sourceURL.Path, ".tar.zst"): 298 contentType = "application/x-tar+zstd" 299 default: 300 log.Fatalf("cannot determine content type from filename %q\n", sourceURL) 301 } 302 303 webRoot := webRootArg(*updateSite) 304 result = UpdateFromArchive(context.Background(), webRoot, contentType, file) 305 } else { 306 branch := "pages" 307 if sourceURL.Fragment != "" { 308 branch, sourceURL.Fragment = sourceURL.Fragment, "" 309 } 310 311 webRoot := webRootArg(*updateSite) 312 result = UpdateFromRepository(context.Background(), webRoot, sourceURL.String(), branch) 313 } 314 315 switch result.outcome { 316 case UpdateError: 317 log.Printf("error: %s\n", result.err) 318 os.Exit(2) 319 case UpdateTimeout: 320 log.Println("timeout") 321 os.Exit(1) 322 case UpdateCreated: 323 log.Println("created") 324 case UpdateReplaced: 325 log.Println("replaced") 326 case UpdateDeleted: 327 log.Println("deleted") 328 case UpdateNoChange: 329 log.Println("no-change") 330 } 331 332 default: 333 // Hook a signal (SIGHUP on *nix, nothing on Windows) for reloading the configuration 334 // at runtime. This is useful because it preserves S3 backend cache contents. Failed 335 // configuration reloads will not crash the process; you may want to check the syntax 336 // first with `git-pages -config ... -print-config` since there is no other feedback. 337 // 338 // Note that not all of the configuration is updated on reload. Listeners are kept as-is. 339 // The backend is not recreated (this is intentional as it allows preserving the cache). 340 OnReload(func() { 341 if newConfig, err := Configure(*configTomlPath); err != nil { 342 log.Println("config: reload err:", err) 343 } else { 344 // From https://go.dev/ref/mem: 345 // > A read r of a memory location x holding a value that is not larger than 346 // > a machine word must observe some write w such that r does not happen before 347 // > w and there is no write w' such that w happens before w' and w' happens 348 // > before r. That is, each read must observe a value written by a preceding or 349 // > concurrent write. 350 config = newConfig 351 if err = errors.Join( 352 configureFeatures(), 353 configureMemLimit(), 354 configureWildcards(), 355 ); err != nil { 356 // At this point the configuration is in an in-between, corrupted state, so 357 // the only reasonable choice is to crash. 358 log.Fatalln("config: reload fail:", err) 359 } else { 360 log.Println("config: reload ok") 361 } 362 } 363 }) 364 365 // Start listening on all ports before initializing the backend, otherwise if the backend 366 // spends some time initializing (which the S3 backend does) a proxy like Caddy can race 367 // with git-pages on startup and return errors for requests that would have been served 368 // just 0.5s later. 369 pagesListener := listen("pages", config.Server.Pages) 370 caddyListener := listen("caddy", config.Server.Caddy) 371 metricsListener := listen("metrics", config.Server.Metrics) 372 373 if backend, err = CreateBackend(&config.Storage); err != nil { 374 log.Fatalln(err) 375 } 376 backend = NewObservedBackend(backend) 377 378 go serve(pagesListener, ObserveHTTPHandler(http.HandlerFunc(ServePages))) 379 go serve(caddyListener, ObserveHTTPHandler(http.HandlerFunc(ServeCaddy))) 380 go serve(metricsListener, promhttp.Handler()) 381 382 if config.Insecure { 383 log.Println("serve: ready (INSECURE)") 384 } else { 385 log.Println("serve: ready") 386 } 387 select {} 388 } 389}