Skip to content

Commit 1fdb4e3

Browse files
authored
Runtime feature toggles via admin server (#1620)
1 parent d73104e commit 1fdb4e3

20 files changed

Lines changed: 792 additions & 216 deletions

api/admin/admin.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,49 @@ import (
1313
"github.com/gorilla/handlers"
1414
"github.com/gorilla/mux"
1515

16-
"github.com/vechain/thor/v2/api/admin/apilogs"
16+
"github.com/vechain/thor/v2/api/admin/featuregate"
1717
"github.com/vechain/thor/v2/api/admin/loglevel"
18+
"github.com/vechain/thor/v2/api/admin/pprof"
1819
"github.com/vechain/thor/v2/cmd/thor/node"
1920

2021
healthAPI "github.com/vechain/thor/v2/api/admin/health"
2122
)
2223

23-
func NewHTTPHandler(logLevel *slog.LevelVar, health *healthAPI.Health, apiLogsToggle *atomic.Bool, master *node.Master) http.HandlerFunc {
24+
func NewHTTPHandler(
25+
logLevel *slog.LevelVar,
26+
health *healthAPI.Health,
27+
apiLogsGate *featuregate.Gate,
28+
txpoolAPIGate *featuregate.Gate,
29+
pprofGate *featuregate.Gate,
30+
master *node.Master,
31+
) http.HandlerFunc {
2432
router := mux.NewRouter()
2533
subRouter := router.PathPrefix("/admin").Subrouter()
2634

2735
loglevel.New(logLevel).Mount(subRouter, "/loglevel")
2836
healthAPI.NewAPI(health, master).Mount(subRouter, "/health")
29-
apilogs.New(apiLogsToggle).Mount(subRouter, "/apilogs")
37+
38+
reg := featuregate.NewRegistry()
39+
reg.Add(apiLogsGate)
40+
reg.Add(txpoolAPIGate)
41+
reg.Add(pprofGate)
42+
reg.MountAPI(subRouter, "/features")
43+
44+
// Legacy alias — /admin/apilogs predates the unified /admin/features
45+
// namespace; kept for backward compatibility with existing clients.
46+
reg.MountLegacyAlias(subRouter, "/apilogs", "apilogs")
47+
48+
// pprof's /debug/pprof/* handlers must live on the router root
49+
// (net/http/pprof.Index hard-codes the prefix). Gated by pprofGate.
50+
pprof.MountHandlers(router, pprofGate)
3051

3152
handler := handlers.CompressHandler(router)
3253
return handler.ServeHTTP
3354
}
55+
56+
// NewGate builds a featuregate.Gate pre-wired with the admin audit metric.
57+
// Callers don't need to know about the metric layer; this keeps the
58+
// "every admin toggle is audited" invariant inside this package.
59+
func NewGate(name string, enabled *atomic.Bool) *featuregate.Gate {
60+
return featuregate.New(name, enabled, recordToggle)
61+
}

api/admin/admin_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2026 The VeChainThor developers
2+
3+
// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying
4+
// file LICENSE or <https://www.gnu.org/licenses/lgpl-3.0.html>
5+
6+
package admin_test
7+
8+
import (
9+
"bytes"
10+
"encoding/json"
11+
"io"
12+
"log/slog"
13+
"net/http"
14+
"net/http/httptest"
15+
"sync/atomic"
16+
"testing"
17+
"time"
18+
19+
"github.com/ethereum/go-ethereum/crypto"
20+
"github.com/gorilla/mux"
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
24+
"github.com/vechain/thor/v2/api"
25+
"github.com/vechain/thor/v2/api/admin"
26+
healthAPI "github.com/vechain/thor/v2/api/admin/health"
27+
apinode "github.com/vechain/thor/v2/api/node"
28+
"github.com/vechain/thor/v2/cmd/thor/node"
29+
"github.com/vechain/thor/v2/comm"
30+
"github.com/vechain/thor/v2/test/testchain"
31+
"github.com/vechain/thor/v2/thor"
32+
"github.com/vechain/thor/v2/txpool"
33+
)
34+
35+
// TestAdminToggleAffectsNodeAPI is the e2e contract test: flipping
36+
// /admin/features/txpool-api via the admin server must immediately gate
37+
// /node/txpool on the business API server, via the shared atomic.Bool.
38+
func TestAdminToggleAffectsNodeAPI(t *testing.T) {
39+
chain, err := testchain.NewDefault()
40+
require.NoError(t, err)
41+
pool := txpool.New(chain.Repo(), chain.Stater(), txpool.Options{
42+
Limit: 100, LimitPerAccount: 16, MaxLifetime: time.Minute,
43+
}, &thor.NoFork)
44+
defer pool.Close()
45+
46+
enableTxPool := &atomic.Bool{}
47+
enableTxPool.Store(true)
48+
txpoolGate := admin.NewGate("txpool-api", enableTxPool)
49+
apiLogsGate := admin.NewGate("apilogs", &atomic.Bool{})
50+
pprofGate := admin.NewGate("pprof", &atomic.Bool{})
51+
52+
// Admin server
53+
privKey, _ := crypto.HexToECDSA("99f0500549792796c14fed62011a51081dc5b5e68fe8bd8a13b86be829c4fd36")
54+
master := &node.Master{PrivateKey: privKey}
55+
adminHandler := admin.NewHTTPHandler(
56+
&slog.LevelVar{},
57+
healthAPI.New(chain.Repo(), comm.New(chain.Repo(), pool)),
58+
apiLogsGate, txpoolGate, pprofGate,
59+
master,
60+
)
61+
adminTS := httptest.NewServer(adminHandler)
62+
defer adminTS.Close()
63+
64+
// Business API server, sharing enableTxPool with the admin gate
65+
nodeRouter := mux.NewRouter()
66+
apinode.New(comm.New(chain.Repo(), pool), pool, enableTxPool).Mount(nodeRouter, "/node")
67+
nodeTS := httptest.NewServer(nodeRouter)
68+
defer nodeTS.Close()
69+
70+
// Sanity: initially enabled
71+
require.Equal(t, http.StatusOK, getStatus(t, nodeTS.URL+"/node/txpool"))
72+
73+
// Toggle off via admin
74+
body, _ := json.Marshal(api.ToggleStatus{Enabled: false})
75+
resp, err := http.Post(adminTS.URL+"/admin/features/txpool-api", "application/json", bytes.NewReader(body))
76+
require.NoError(t, err)
77+
defer resp.Body.Close()
78+
require.Equal(t, http.StatusOK, resp.StatusCode)
79+
80+
// Business endpoint now 503
81+
assert.Equal(t, http.StatusServiceUnavailable, getStatus(t, nodeTS.URL+"/node/txpool"))
82+
83+
// Toggle back on via admin
84+
body, _ = json.Marshal(api.ToggleStatus{Enabled: true})
85+
resp, err = http.Post(adminTS.URL+"/admin/features/txpool-api", "application/json", bytes.NewReader(body))
86+
require.NoError(t, err)
87+
defer resp.Body.Close()
88+
require.Equal(t, http.StatusOK, resp.StatusCode)
89+
90+
assert.Equal(t, http.StatusOK, getStatus(t, nodeTS.URL+"/node/txpool"))
91+
}
92+
93+
func getStatus(t *testing.T, url string) int {
94+
t.Helper()
95+
res, err := http.Get(url) //#nosec G107
96+
require.NoError(t, err)
97+
defer res.Body.Close()
98+
_, _ = io.Copy(io.Discard, res.Body)
99+
return res.StatusCode
100+
}

api/admin/apilogs/api_logs.go

Lines changed: 0 additions & 68 deletions
This file was deleted.

api/admin/apilogs/api_logs_test.go

Lines changed: 0 additions & 93 deletions
This file was deleted.

0 commit comments

Comments
 (0)