Skip to content

Commit 76aa2d2

Browse files
author
James Munson
committed
Expose net/http/pprof handlers under /api/debug/pprof/* when the new
enablePprofEndpoint (JSON) / enable_pprof_endpoint (YAML) config option is true. The endpoint is disabled by default.
1 parent f59b0d2 commit 76aa2d2

10 files changed

Lines changed: 338 additions & 0 deletions

File tree

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,4 @@ List of contributors, in chronological order:
8484
* Zhang Xiao (https://github.com/xzhang1)
8585
* Tom Nguyen (https://github.com/lecafard)
8686
* Philip Cramer (https://github.com/PhilipCramer)
87+
* James Munson (https://github.com/jmunson)

api/api_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func createTestConfig() *os.File {
4242
jsonString, err := json.Marshal(gin.H{
4343
"architectures": []string{},
4444
"enableMetricsEndpoint": true,
45+
"enablePprofEndpoint": false,
4546
"S3PublishEndpoints": map[string]map[string]string{
4647
"test-s3": {
4748
"region": "us-east-1",

api/pprof_integration_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http/httptest"
7+
"os"
8+
"os/exec"
9+
"strings"
10+
"testing"
11+
12+
"github.com/aptly-dev/aptly/aptly"
13+
ctx "github.com/aptly-dev/aptly/context"
14+
"github.com/gin-gonic/gin"
15+
)
16+
17+
// pprofTestServer starts a real httptest.Server with the aptly router configured
18+
// with enablePprofEndpoint: true. The caller is responsible for calling
19+
// server.Close() and aptlyCtx.Shutdown().
20+
func pprofTestServer(t *testing.T) (*httptest.Server, *ctx.AptlyContext) {
21+
t.Helper()
22+
23+
aptly.Version = "testVersion"
24+
25+
file, err := os.CreateTemp("", "aptly-pprof-integration")
26+
if err != nil {
27+
t.Fatal(err)
28+
}
29+
t.Cleanup(func() { _ = os.Remove(file.Name()) })
30+
31+
jsonString, err := json.Marshal(gin.H{
32+
"architectures": []string{},
33+
"enablePprofEndpoint": true,
34+
})
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
if _, err = file.Write(jsonString); err != nil {
39+
t.Fatal(err)
40+
}
41+
_ = file.Close()
42+
43+
aptlyCtx, router, err := newPprofContext(file.Name())
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
48+
return httptest.NewServer(router), aptlyCtx
49+
}
50+
51+
// runPprof runs "go tool pprof -top <url>" and returns the combined output.
52+
func runPprof(t *testing.T, url string) string {
53+
t.Helper()
54+
cmd := exec.Command("go", "tool", "pprof", "-top", url)
55+
out, err := cmd.CombinedOutput()
56+
if err != nil {
57+
t.Fatalf("go tool pprof -top %s failed: %v\noutput:\n%s", url, err, out)
58+
}
59+
return string(out)
60+
}
61+
62+
// TestPprofToolGoroutine verifies that go tool pprof can fetch and parse the
63+
// goroutine profile from the /api/debug/pprof/goroutine endpoint.
64+
func TestPprofToolGoroutine(t *testing.T) {
65+
server, aptlyCtx := pprofTestServer(t)
66+
defer server.Close()
67+
defer aptlyCtx.Shutdown()
68+
69+
url := fmt.Sprintf("%s/api/debug/pprof/goroutine", server.URL)
70+
out := runPprof(t, url)
71+
t.Logf("go tool pprof goroutine output:\n%s", out)
72+
73+
if !strings.Contains(out, "Type: goroutine") {
74+
t.Errorf("expected 'Type: goroutine' in output, got:\n%s", out)
75+
}
76+
if !strings.Contains(out, "flat") {
77+
t.Errorf("expected 'flat' column header in output, got:\n%s", out)
78+
}
79+
}
80+
81+
// TestPprofToolHeap verifies that go tool pprof can fetch and parse the heap
82+
// profile from the /api/debug/pprof/heap endpoint.
83+
func TestPprofToolHeap(t *testing.T) {
84+
server, aptlyCtx := pprofTestServer(t)
85+
defer server.Close()
86+
defer aptlyCtx.Shutdown()
87+
88+
url := fmt.Sprintf("%s/api/debug/pprof/heap", server.URL)
89+
out := runPprof(t, url)
90+
t.Logf("go tool pprof heap output:\n%s", out)
91+
92+
// Heap profiles are typed as "inuse_space" by default.
93+
if !strings.Contains(out, "Type: inuse_space") {
94+
t.Errorf("expected 'Type: inuse_space' in output, got:\n%s", out)
95+
}
96+
if !strings.Contains(out, "flat") {
97+
t.Errorf("expected 'flat' column header in output, got:\n%s", out)
98+
}
99+
}
100+
101+
// TestPprofToolCmdline verifies that the cmdline endpoint is reachable.
102+
// cmdline returns plain text (the process argv), not a pprof binary profile,
103+
// so we confirm the HTTP fetch succeeds rather than parsing it as a profile.
104+
func TestPprofToolCmdline(t *testing.T) {
105+
server, aptlyCtx := pprofTestServer(t)
106+
defer server.Close()
107+
defer aptlyCtx.Shutdown()
108+
109+
url := fmt.Sprintf("%s/api/debug/pprof/cmdline", server.URL)
110+
111+
resp, err := server.Client().Get(url)
112+
if err != nil {
113+
t.Fatalf("GET %s: %v", url, err)
114+
}
115+
defer func() { _ = resp.Body.Close() }()
116+
117+
if resp.StatusCode != 200 {
118+
t.Errorf("expected 200, got %d", resp.StatusCode)
119+
}
120+
t.Logf("cmdline endpoint status: %d", resp.StatusCode)
121+
}

api/pprof_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
9+
"github.com/aptly-dev/aptly/aptly"
10+
ctx "github.com/aptly-dev/aptly/context"
11+
"github.com/gin-gonic/gin"
12+
"github.com/smira/flag"
13+
. "gopkg.in/check.v1"
14+
)
15+
16+
// newPprofContext builds an aptly context and router from the given config file
17+
// path. It returns an error if context initialisation fails. The caller is
18+
// responsible for calling aptlyCtx.Shutdown() when done.
19+
func newPprofContext(configPath string) (*ctx.AptlyContext, http.Handler, error) {
20+
flags := flag.NewFlagSet("fakeFlags", flag.ContinueOnError)
21+
flags.Bool("no-lock", false, "dummy")
22+
flags.Int("db-open-attempts", 3, "dummy")
23+
flags.String("config", configPath, "dummy")
24+
flags.String("architectures", "", "dummy")
25+
26+
aptlyCtx, err := ctx.NewContext(flags)
27+
if err != nil {
28+
return nil, nil, err
29+
}
30+
return aptlyCtx, Router(aptlyCtx), nil
31+
}
32+
33+
// PprofEnabledSuite tests pprof endpoints when EnablePprofEndpoint is true.
34+
// It uses a dedicated router built from a config that enables only the pprof
35+
// endpoint, ensuring no interaction with other optional features.
36+
type PprofEnabledSuite struct {
37+
configFile *os.File
38+
aptlyCtx *ctx.AptlyContext
39+
router http.Handler
40+
}
41+
42+
var _ = Suite(&PprofEnabledSuite{})
43+
44+
func (s *PprofEnabledSuite) SetUpSuite(c *C) {
45+
aptly.Version = "testVersion"
46+
47+
file, err := os.CreateTemp("", "aptly-pprof")
48+
c.Assert(err, IsNil)
49+
s.configFile = file
50+
51+
jsonString, err := json.Marshal(gin.H{
52+
"architectures": []string{},
53+
"enablePprofEndpoint": true,
54+
})
55+
c.Assert(err, IsNil)
56+
_, err = file.Write(jsonString)
57+
c.Assert(err, IsNil)
58+
_ = file.Close()
59+
60+
aptlyCtx, router, err := newPprofContext(file.Name())
61+
c.Assert(err, IsNil)
62+
s.aptlyCtx = aptlyCtx
63+
s.router = router
64+
}
65+
66+
func (s *PprofEnabledSuite) TearDownSuite(c *C) {
67+
if s.configFile != nil {
68+
_ = os.Remove(s.configFile.Name())
69+
}
70+
if s.aptlyCtx != nil {
71+
s.aptlyCtx.Shutdown()
72+
}
73+
}
74+
75+
func (s *PprofEnabledSuite) request(method, url string) *httptest.ResponseRecorder {
76+
w := httptest.NewRecorder()
77+
req, err := http.NewRequest(method, url, nil)
78+
if err != nil {
79+
panic(err)
80+
}
81+
s.router.ServeHTTP(w, req)
82+
return w
83+
}
84+
85+
// TestPprofIndexReturns200 verifies the pprof index page is served when the
86+
// endpoint is enabled.
87+
func (s *PprofEnabledSuite) TestPprofIndexReturns200(c *C) {
88+
w := s.request("GET", "/api/debug/pprof/")
89+
c.Check(w.Code, Equals, 200)
90+
c.Check(w.Header().Get("Content-Type"), Matches, "text/html.*")
91+
}
92+
93+
// TestPprofGoroutineReturns200 verifies the goroutine sub-profile is served.
94+
func (s *PprofEnabledSuite) TestPprofGoroutineReturns200(c *C) {
95+
w := s.request("GET", "/api/debug/pprof/goroutine?debug=1")
96+
c.Check(w.Code, Equals, 200)
97+
}
98+
99+
// TestPprofHeapReturns200 verifies the heap sub-profile is served.
100+
func (s *PprofEnabledSuite) TestPprofHeapReturns200(c *C) {
101+
w := s.request("GET", "/api/debug/pprof/heap?debug=1")
102+
c.Check(w.Code, Equals, 200)
103+
}
104+
105+
// TestPprofCmdlineReturns200 verifies the cmdline handler is served.
106+
func (s *PprofEnabledSuite) TestPprofCmdlineReturns200(c *C) {
107+
w := s.request("GET", "/api/debug/pprof/cmdline")
108+
c.Check(w.Code, Equals, 200)
109+
}
110+
111+
// TestPprofSymbolReturns200 verifies the symbol lookup handler responds (GET
112+
// with no addresses returns 200 with a count of 0 symbols found).
113+
func (s *PprofEnabledSuite) TestPprofSymbolReturns200(c *C) {
114+
w := s.request("GET", "/api/debug/pprof/symbol")
115+
c.Check(w.Code, Equals, 200)
116+
}
117+
118+
// TestPprofTraceReturns200 verifies the execution trace handler is wired
119+
// correctly. The minimum trace duration is 1 second, so this test blocks
120+
// briefly but confirms end-to-end handler registration.
121+
func (s *PprofEnabledSuite) TestPprofTraceReturns200(c *C) {
122+
w := s.request("GET", "/api/debug/pprof/trace?seconds=1")
123+
c.Check(w.Code, Equals, 200)
124+
c.Check(w.Header().Get("Content-Type"), Equals, "application/octet-stream")
125+
}
126+
127+
// TestNonPprofRoutesUnaffectedWhenEnabled verifies that existing API routes
128+
// continue to work correctly when the pprof endpoint is enabled.
129+
func (s *PprofEnabledSuite) TestNonPprofRoutesUnaffectedWhenEnabled(c *C) {
130+
w := s.request("GET", "/api/version")
131+
c.Check(w.Code, Equals, 200)
132+
}
133+
134+
// PprofDisabledSuite tests that pprof endpoints are NOT exposed when the config
135+
// option is off. It embeds APISuite, whose config does not set
136+
// enablePprofEndpoint, confirming the default state is safe. The inherited
137+
// APISuite tests also run here, verifying that normal API operation is
138+
// unaffected when pprof is disabled.
139+
type PprofDisabledSuite struct {
140+
APISuite
141+
}
142+
143+
var _ = Suite(&PprofDisabledSuite{})
144+
145+
// TestPprofIndexNotExposed verifies the pprof index is not reachable when
146+
// enablePprofEndpoint is false (the default).
147+
func (s *PprofDisabledSuite) TestPprofIndexNotExposed(c *C) {
148+
response, err := s.HTTPRequest("GET", "/api/debug/pprof/", nil)
149+
c.Assert(err, IsNil)
150+
c.Check(response.Code, Equals, 404)
151+
}
152+
153+
// TestPprofGoroutineNotExposed verifies the goroutine profile is not reachable
154+
// when disabled.
155+
func (s *PprofDisabledSuite) TestPprofGoroutineNotExposed(c *C) {
156+
response, err := s.HTTPRequest("GET", "/api/debug/pprof/goroutine", nil)
157+
c.Assert(err, IsNil)
158+
c.Check(response.Code, Equals, 404)
159+
}
160+
161+
// TestPprofHeapNotExposed verifies the heap profile is not reachable when
162+
// disabled.
163+
func (s *PprofDisabledSuite) TestPprofHeapNotExposed(c *C) {
164+
response, err := s.HTTPRequest("GET", "/api/debug/pprof/heap", nil)
165+
c.Assert(err, IsNil)
166+
c.Check(response.Code, Equals, 404)
167+
}
168+
169+
// TestPprofCmdlineNotExposed verifies the cmdline endpoint is not reachable
170+
// when disabled.
171+
func (s *PprofDisabledSuite) TestPprofCmdlineNotExposed(c *C) {
172+
response, err := s.HTTPRequest("GET", "/api/debug/pprof/cmdline", nil)
173+
c.Assert(err, IsNil)
174+
c.Check(response.Code, Equals, 404)
175+
}
176+
177+
// TestPprofTraceNotExposed verifies the trace endpoint is not reachable when
178+
// disabled.
179+
func (s *PprofDisabledSuite) TestPprofTraceNotExposed(c *C) {
180+
response, err := s.HTTPRequest("GET", "/api/debug/pprof/trace", nil)
181+
c.Assert(err, IsNil)
182+
c.Check(response.Code, Equals, 404)
183+
}

api/router.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package api
22

33
import (
44
"net/http"
5+
"net/http/pprof"
6+
"strings"
57
"sync/atomic"
68

79
"github.com/aptly-dev/aptly/aptly"
@@ -82,6 +84,27 @@ func Router(c *ctx.AptlyContext) http.Handler {
8284
MetricsCollectorRegistrar.Register(router)
8385
}
8486

87+
if c.Config().EnablePprofEndpoint {
88+
pprofMux := http.NewServeMux()
89+
pprofMux.HandleFunc("/debug/pprof/", pprof.Index)
90+
pprofMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
91+
pprofMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
92+
pprofMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
93+
pprofMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
94+
95+
router.Any("/api/debug/pprof/*any", func(c *gin.Context) {
96+
origPath := c.Request.URL.Path
97+
origRawPath := c.Request.URL.RawPath
98+
c.Request.URL.Path = strings.TrimPrefix(origPath, "/api")
99+
if origRawPath != "" {
100+
c.Request.URL.RawPath = strings.TrimPrefix(origRawPath, "/api")
101+
}
102+
pprofMux.ServeHTTP(c.Writer, c.Request)
103+
c.Request.URL.Path = origPath
104+
c.Request.URL.RawPath = origRawPath
105+
})
106+
}
107+
85108
if c.Config().ServeInAPIMode {
86109
router.GET("/repos/", reposListInAPIMode(c.Config().FileSystemPublishRoots))
87110
router.GET("/repos/:storage/*pkgPath", reposServeInAPIMode)

system/t02_config/ConfigShowTest_gold

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"serveInAPIMode": true,
1717
"enableMetricsEndpoint": true,
1818
"enableSwaggerEndpoint": false,
19+
"enablePprofEndpoint": false,
1920
"AsyncAPI": false,
2021
"databaseBackend": {
2122
"type": "",

system/t02_config/ConfigShowYAMLTest_gold

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ ppa_baseurl: http://ppa.launchpad.net
1515
serve_in_api_mode: true
1616
enable_metrics_endpoint: true
1717
enable_swagger_endpoint: false
18+
enable_pprof_endpoint: false
1819
async_api: false
1920
database_backend:
2021
type: ""

system/t02_config/CreateConfigTest_gold

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ enable_metrics_endpoint: false
8686
# Enable API documentation on /docs
8787
enable_swagger_endpoint: false
8888

89+
# Enable pprof profiling endpoints on /api/debug/pprof/*
90+
enable_pprof_endpoint: false
91+
8992
# OBSOLETE: use via url param ?_async=true
9093
async_api: false
9194

utils/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type ConfigStructure struct { // nolint: maligned
3737
ServeInAPIMode bool `json:"serveInAPIMode" yaml:"serve_in_api_mode"`
3838
EnableMetricsEndpoint bool `json:"enableMetricsEndpoint" yaml:"enable_metrics_endpoint"`
3939
EnableSwaggerEndpoint bool `json:"enableSwaggerEndpoint" yaml:"enable_swagger_endpoint"`
40+
EnablePprofEndpoint bool `json:"enablePprofEndpoint" yaml:"enable_pprof_endpoint"`
4041
AsyncAPI bool `json:"AsyncAPI" yaml:"async_api"` // OBSOLETE
4142

4243
// Database

0 commit comments

Comments
 (0)