2 Commits
Author SHA1 Message Date
sneak 4f513bede8 Exit with the shutdown's code and wait for image processing (closes #86)
check / check (push) Successful in 5m14s
fx alone handles SIGINT and SIGTERM; the server's own handler, which
only cancelled a context that fx's stop did not wait for, is gone.
runApp starts the fx app, waits for a signal or a shutdown request,
stops the app and returns the exit code, which main passes to os.Exit.
A listen error asks fx to shut down with exit code 1. A Sentry DSN that
cannot be used fails the server's start hook, so fx stops what had
already started. The server's stop hook stops the HTTP server, then
waits for the images still being processed, both within
ShutdownTimeout; images still being processed after that are logged and
fail the stop, so the exit code is 1.

Model: opus-5-5
2026-10-03 14:42:41 +00:00
clawbot b6b48bc94a Test shutdown exit codes, Sentry startup failure and the processing wait
These tests fail until the change that follows: runApp and
WaitForProcessing do not exist yet, the server has no shutdowner, and a
Sentry DSN that cannot be used exits the process from a goroutine
instead of failing the server's start hook.

runApp must return the exit code a shutdown request carries, 0 without
one, and 1 when the app fails to start or stop. A listen error must ask
fx to shut down with exit code 1. WaitForProcessing must wait for an
image being processed and report it when its context ends first.

Model: opus-5-5
2026-10-03 14:06:56 +00:00
8 changed files with 145 additions and 117 deletions
+3 -4
View File
@@ -238,7 +238,7 @@ variables set by the file's `env:` section are checked the same way.
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read image responses: `*` or one origin; default `*` |
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
@@ -247,9 +247,8 @@ variables set by the file's `env:` section are checked the same way.
Key settings in more detail:
- `access_control_allow_origin` — the origin a browser lets read the responses
of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the
- `access_control_allow_origin` — the origin a browser lets read pixa's
responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the
default, is any site; otherwise one `http` or `https` origin such as
`https://example.com`, whose host is a lowercase host name (letters,
digits, hyphens and dots, with a letter in its last part) or an IP address
+9 -15
View File
@@ -31,21 +31,15 @@ P2: security: referer blacklist
- 2026-10-03 shutdown sets the exit code and waits for image processing
(closes #86): fx alone handles SIGINT and SIGTERM, and the server's own
signal handler is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's
code: 0 for a signal, 1 when the HTTP server cannot listen or the app fails
to start or to stop; the server's stop hook, which fx waits for, stops the
HTTP server, waits for the images still being processed, both within 5
seconds, then flushes Sentry; images still being processed after that are
logged with their count and make the exit code 1; a Sentry DSN that cannot be
used fails startup, so the stop hooks of what had already started run,
instead of exiting the process from a goroutine; the eviction loop is left to
#102.
- 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
middleware, with the `access_control_allow_origin` origin, moved from the
router root onto a `/v1` subrouter holding `/v1/image/` and `/v1/e/`, where it
still answers a preflight `OPTIONS` request; the login and URL generator
pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
documented in `README.md` and `config.example.yml`.
signal handler is gone; `cmd/pixad` starts the fx app, waits for a signal or
a shutdown request, stops the app and exits with its code: 0 for a signal, 1
when the HTTP server cannot listen or the app fails to start or to stop; the
server's stop hook, which fx waits for, stops the HTTP server, waits for the
images still being processed, both within 5 seconds, then flushes Sentry;
images still being processed after that are logged with their count and make
the exit code 1; a Sentry DSN that cannot be used fails startup, so the stop
hooks of what had already started run, instead of exiting the process from a
goroutine; the eviction loop is left to #102.
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not
`dev` (closes #166): `.dockerignore` lets `.git` into the build context,
without `.git/config`; with no `VERSION` build argument the `Dockerfile`
+34 -2
View File
@@ -2,6 +2,7 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
@@ -50,7 +51,7 @@ func run(_ *cobra.Command, _ []string) {
// A write to a closed stdout or stderr must not end the process.
signal.Ignore(syscall.SIGPIPE)
fx.New(
app := fx.New(
fx.Provide(
config.New,
database.New,
@@ -65,5 +66,36 @@ func run(_ *cobra.Command, _ []string) {
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run()
)
os.Exit(runApp(app))
}
// runApp starts app, waits for SIGINT, SIGTERM or a shutdown request, then
// stops app. It returns the exit code: the one the shutdown request carries,
// 0 for a signal, or 1 when app fails to start or to stop; fx logs that
// error itself. It does what fx's App.Run does, but returns the exit code
// instead of exiting.
func runApp(app *fx.App) int {
startCtx, cancelStart := context.WithTimeout(
context.Background(), app.StartTimeout())
defer cancelStart()
err := app.Start(startCtx)
if err != nil {
return 1
}
shutdown := <-app.Wait()
stopCtx, cancelStop := context.WithTimeout(
context.Background(), app.StopTimeout())
defer cancelStop()
err = app.Stop(stopCtx)
if err != nil {
return 1
}
return shutdown.ExitCode
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"errors"
"testing"
"go.uber.org/fx"
)
// errTestHook is the error returned by the test hooks that fail.
var errTestHook = errors.New("test hook failed")
// TestRunAppExitCode checks the exit code runApp returns: the one a
// shutdown request carries, 0 for a request without one (as for SIGINT or
// SIGTERM), and 1 when the app fails to start or to stop.
func TestRunAppExitCode(t *testing.T) {
t.Parallel()
cases := []struct {
name string
hook func(shutdowner fx.Shutdowner) fx.Hook
want int
}{
{
name: "shutdown requested with exit code 1",
hook: func(shutdowner fx.Shutdowner) fx.Hook {
return fx.StartHook(func() error {
return shutdowner.Shutdown(fx.ExitCode(1))
})
},
want: 1,
},
{
name: "shutdown requested without an exit code",
hook: func(shutdowner fx.Shutdowner) fx.Hook {
return fx.StartHook(func() error {
return shutdowner.Shutdown()
})
},
want: 0,
},
{
name: "start fails",
hook: func(fx.Shutdowner) fx.Hook {
return fx.StartHook(func() error { return errTestHook })
},
want: 1,
},
{
name: "stop fails",
hook: func(shutdowner fx.Shutdowner) fx.Hook {
return fx.StartStopHook(
func() error { return shutdowner.Shutdown() },
func() error { return errTestHook },
)
},
want: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
app := fx.New(
fx.NopLogger,
fx.Invoke(func(lc fx.Lifecycle, shutdowner fx.Shutdowner) {
lc.Append(tc.hook(shutdowner))
}),
)
got := runApp(app)
t.Logf("runApp() = %d", got)
if got != tc.want {
t.Errorf("runApp() = %d, want %d", got, tc.want)
}
})
}
}
+2 -3
View File
@@ -103,9 +103,8 @@ upstream_max_response_size: 52428800
# longer than upstream_fetch_timeout plus 20 seconds.
downstream_timeout: 60s
# The origin a browser lets read the responses of the image routes,
# /v1/image/ and /v1/e/, sent as the CORS Access-Control-Allow-Origin
# header; no other route sends it. "*" (the default) is any site;
# The origin a browser lets read pixa's responses, sent as the CORS
# Access-Control-Allow-Origin header: "*" (the default) is any site;
# otherwise one http or https origin such as https://example.com, whose
# host is a lowercase host name (letters, digits, hyphens and dots, with a
# letter in its last part) or an IP address (IPv6 in brackets, in its
-64
View File
@@ -1,64 +0,0 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestCORSOnlyOnImageRoutes verifies that the image routes answer with the
// configured access_control_allow_origin, a preflight request included, and
// that the login and URL generator pages send no Access-Control-Allow-Origin,
// so no other site can read them. /metrics is left out: its middleware
// registers with the process-wide Prometheus registry, which only one test
// in this package can do.
func TestCORSOnlyOnImageRoutes(t *testing.T) {
t.Parallel()
const appOrigin = "https://app.example.com"
s := newTestServer(t)
s.config.AccessControlAllowOrigin = appOrigin
s.SetupRoutes()
requests := []struct {
method string
path string
want string
}{
{http.MethodGet, unsignedImagePath, appOrigin},
{http.MethodHead, unsignedImagePath, appOrigin},
{http.MethodOptions, unsignedImagePath, appOrigin},
{http.MethodGet, encryptedImagePath, appOrigin},
{http.MethodGet, "/", ""},
{http.MethodOptions, "/", ""},
{http.MethodPost, "/generate", ""},
{http.MethodGet, "/logout", ""},
}
for _, tc := range requests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(
t.Context(), tc.method, tc.path, nil)
req.Header.Set("Origin", appOrigin)
// An OPTIONS request naming the method it asks about is the
// preflight a browser sends before some cross-origin requests.
if tc.method == http.MethodOptions {
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
t.Logf("status %d", rec.Code)
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != tc.want {
t.Errorf("Access-Control-Allow-Origin = %q, want %q",
got, tc.want)
}
})
}
}
+2 -6
View File
@@ -13,10 +13,6 @@ import (
// unsignedImagePath is an image URL that carries no signature.
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
// encryptedImagePath is an encrypted image URL whose token cannot be
// decrypted.
const encryptedImagePath = "/v1/e/token/cat.jpg"
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance
// mode is on, both image routes answer 503 Service Unavailable with a
// Retry-After header and the JSON error body the image handlers send.
@@ -32,7 +28,7 @@ func TestMaintenanceModeRefusesImageRequests(t *testing.T) {
}{
{http.MethodGet, unsignedImagePath},
{http.MethodHead, unsignedImagePath},
{http.MethodGet, encryptedImagePath},
{http.MethodGet, "/v1/e/token/cat.jpg"},
}
for _, tc := range requests {
@@ -99,7 +95,7 @@ func TestImageRequestsServedWithoutMaintenanceMode(t *testing.T) {
}{
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
{http.MethodGet, "/v1/e/token/cat.jpg", http.StatusBadRequest},
}
for _, tc := range requests {
+15 -23
View File
@@ -38,6 +38,7 @@ func (s *Server) SetupRoutes() {
s.router.Use(s.mw.Metrics())
}
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
if s.sentryEnabled {
@@ -73,31 +74,22 @@ func (s *Server) SetupRoutes() {
s.router.Get("/logout", s.h.HandleLogout())
// Image routes, the only ones that send CORS headers, as pages on other
// sites read them. They are a subrouter rather than a group: a group's
// middleware runs only for a request that matches one of its routes,
// and a browser's preflight OPTIONS request matches none, so the CORS
// middleware could not answer it.
s.router.Route("/v1", func(r chi.Router) {
r.Use(s.mw.CORS())
// Image routes, refused while maintenance mode is on. Only these: the
// image's Docker HEALTHCHECK requests the health check, a 503 there
// would make the container unhealthy, and upaas marks a deploy failed
// when its container is unhealthy.
s.router.Group(func(r chi.Router) {
r.Use(s.refuseDuringMaintenance)
// Refused while maintenance mode is on. Only these: the image's
// Docker HEALTHCHECK requests the health check, a 503 there would
// make the container unhealthy, and upaas marks a deploy failed
// when its container is unhealthy.
r.Group(func(r chi.Router) {
r.Use(s.refuseDuringMaintenance)
// Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format>
r.Get("/v1/image/*", s.h.HandleImage())
r.Head("/v1/image/*", s.h.HandleImage())
// Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format>
r.Get("/image/*", s.h.HandleImage())
r.Head("/image/*", s.h.HandleImage())
// Encrypted image URL route
// The trailing filename (e.g., /img.jpg) is ignored but helps
// browsers with content type
r.Get("/e/{token}/*", s.h.HandleImageEnc())
})
// Encrypted image URL route
// The trailing filename (e.g., /img.jpg) is ignored but helps
// browsers with content type
r.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
})
// Metrics endpoint with auth