Skip to content

Commit 8152f1e

Browse files
author
Developer
committed
ws: add mysql-pitr-agent daemon serving binlogs to the server
Agent now runs as a persistent daemon (serve) on the MySQL host: it reads local binlog files, parses them via mysqlbinlog honouring targeted parse parameters (binlog files, table, time range, positions), and executes rollbacks locally. The server no longer accesses binlog files or MySQL credentials — PITR operations are driven over the existing reverse-WS tunnel (hub, dispatcher, internal CA) which is now wired into both binaries: mTLS :9443 listener with file-backed CA, lifecycle hooks for agent online/offline status, pitr_progress pushes, and pitr_cancel. Web wizard drops mysql_dsn and selects connected agents; adds optional targeted parsing fields. docker-compose gains a one-shot provision service (CA extraction, agent registration, cert issuance, encrypted config) and scripts/e2e-test.sh drives a full operation through the agent. deploy/agent.service and install script run serve mode. Also fixes pre-existing test breakage (chi route contexts, mock interface drift, cert SAN/key-usage enforcement) across pitr, agent, audit, ws packages.
1 parent 0f812f7 commit 8152f1e

47 files changed

Lines changed: 3058 additions & 742 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ RUN npm run build
4343
# =============================================================================
4444
FROM alpine:3.20 AS agent
4545

46-
RUN apk add --no-cache ca-certificates tzdata
46+
# mariadb-client provides mysqlbinlog (symlinked for name compatibility),
47+
# which the agent uses to parse local binlog files.
48+
RUN apk add --no-cache ca-certificates tzdata mariadb-client mariadb-server-utils && \
49+
ln -sf /usr/bin/mariadb-binlog /usr/bin/mysqlbinlog && \
50+
test -x /usr/bin/mysqlbinlog
4751

4852
COPY --from=builder /build/mysql-pitr-agent /usr/local/bin/mysql-pitr-agent
4953

@@ -54,14 +58,14 @@ ENTRYPOINT ["mysql-pitr-agent"]
5458
# =============================================================================
5559
FROM alpine:3.20 AS server
5660

57-
RUN apk add --no-cache ca-certificates tzdata mariadb-client && \
58-
ln -sf /usr/bin/mariadb-binlog /usr/bin/mysqlbinlog && \
59-
test -x /usr/bin/mysqlbinlog
61+
# The server no longer parses binlogs itself — the agent does — so no
62+
# mysql/mariadb client is needed.
63+
RUN apk add --no-cache ca-certificates tzdata
6064

6165
COPY --from=builder /build/mysql-pitr-server /usr/local/bin/mysql-pitr-server
6266

6367
COPY --from=frontend /web/dist /usr/share/mysql-pitr/web/
6468

65-
EXPOSE 8080
69+
EXPOSE 8080 9443
6670

6771
ENTRYPOINT ["mysql-pitr-server"]

cmd/agent/config.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/a-shan/mysql-pitr/internal/config"
11+
)
12+
13+
// NewConfigCommand creates the `agent config` cobra command tree for
14+
// encrypting and inspecting the agent config file.
15+
func NewConfigCommand() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "config",
18+
Short: "Encrypt and inspect the agent config file",
19+
}
20+
cmd.AddCommand(NewConfigEncryptCommand())
21+
return cmd
22+
}
23+
24+
// NewConfigEncryptCommand creates the `agent config encrypt` subcommand that
25+
// reads a plaintext JSON config and writes the AES-256-GCM encrypted form
26+
// that `serve` and `flashback --config` load.
27+
func NewConfigEncryptCommand() *cobra.Command {
28+
var (
29+
input string
30+
output string
31+
passphrase string
32+
)
33+
34+
cmd := &cobra.Command{
35+
Use: "encrypt",
36+
Short: "Encrypt a plaintext JSON config into the on-disk format",
37+
Long: `Read a plaintext JSON config (the shape documented in deploy/README.md)
38+
and write the AES-256-GCM encrypted version that the agent loads with
39+
--config. The plaintext file is not modified.`,
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
if input == "" {
42+
return fmt.Errorf("--input is required")
43+
}
44+
if output == "" {
45+
return fmt.Errorf("--output is required")
46+
}
47+
if passphrase == "" {
48+
return fmt.Errorf("--passphrase is required")
49+
}
50+
51+
raw, err := os.ReadFile(input)
52+
if err != nil {
53+
return fmt.Errorf("config encrypt: read input: %w", err)
54+
}
55+
// Validate the shape early so a bad config never gets encrypted.
56+
var cfg config.Config
57+
if err := json.Unmarshal(raw, &cfg); err != nil {
58+
return fmt.Errorf("config encrypt: input is not a valid agent config: %w", err)
59+
}
60+
if err := config.SaveConfig(output, passphrase, &cfg); err != nil {
61+
return fmt.Errorf("config encrypt: %w", err)
62+
}
63+
fmt.Printf("wrote encrypted config to %s\n", output)
64+
return nil
65+
},
66+
SilenceUsage: true,
67+
}
68+
69+
flags := cmd.Flags()
70+
flags.StringVar(&input, "input", "", "Plaintext JSON config path (required)")
71+
flags.StringVar(&output, "output", "", "Encrypted config output path (required)")
72+
flags.StringVar(&passphrase, "passphrase", "", "Passphrase for encryption (required)")
73+
74+
return cmd
75+
}

cmd/agent/flashback.go

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,14 @@ func RunFlashback(ctx context.Context, opts FlashbackOptions) error {
104104
}
105105

106106
// Resolve binlog directory and build file paths.
107-
dataDir, err := resolveDataDir(connCfg)
108-
if err != nil {
109-
return fmt.Errorf("flashback: resolve binlog directory: %w", err)
107+
dataDir, err := conn.GetBinlogDir(ctx)
108+
if err != nil || dataDir == "" {
109+
// Fall back to a fresh discovery connection (e.g. when the injected
110+
// connector cannot resolve the directory).
111+
dataDir, err = resolveDataDir(connCfg)
112+
if err != nil {
113+
return fmt.Errorf("flashback: resolve binlog directory: %w", err)
114+
}
110115
}
111116
log.Printf("MySQL data directory: %s", dataDir)
112117

@@ -116,7 +121,10 @@ func RunFlashback(ctx context.Context, opts FlashbackOptions) error {
116121
}
117122

118123
// ---- Parse binlogs via mysqlbinlog ----
119-
parseRes, err := parseBinlogWithMySQLBinlog(connCfg, paths, opts.TargetTable, recoveryTime)
124+
parseRes, err := mysqlbinlogParse(connCfg, paths, binlogParseOpts{
125+
TargetTable: opts.TargetTable,
126+
EndTime: &recoveryTime,
127+
})
120128
if err != nil {
121129
return fmt.Errorf("flashback: parse binlogs: %w", err)
122130
}
@@ -232,12 +240,27 @@ func resolveDataDir(cfg connector.ConnConfig) (string, error) {
232240
return value, nil
233241
}
234242

243+
// mysqlbinlogParse is the parse entry point used by RunFlashback. It is a
244+
// package-level variable so tests can substitute a fake.
245+
var mysqlbinlogParse = parseBinlogWithMySQLBinlog
246+
247+
// binlogParseOpts controls how a binlog parse is constrained. All fields are
248+
// optional; a nil/zero field means "no constraint".
249+
type binlogParseOpts struct {
250+
TargetTable string
251+
StartTime *time.Time
252+
EndTime *time.Time
253+
StartPos uint32
254+
StopPos uint32
255+
MySQLBinlogPath string
256+
}
257+
235258
// parseBinlogWithMySQLBinlog uses the mysqlbinlog tool to parse binlog files
236259
// and generate reverse SQL statements.
237-
func parseBinlogWithMySQLBinlog(cfg connector.ConnConfig, paths []string, targetTable string, recoveryTime time.Time) (*connector.ParseResult, error) {
238-
parts := strings.SplitN(targetTable, ".", 2)
260+
func parseBinlogWithMySQLBinlog(cfg connector.ConnConfig, paths []string, opts binlogParseOpts) (*connector.ParseResult, error) {
261+
parts := strings.SplitN(opts.TargetTable, ".", 2)
239262
if len(parts) != 2 {
240-
return nil, fmt.Errorf("invalid target table %q: expected schema.table format", targetTable)
263+
return nil, fmt.Errorf("invalid target table %q: expected schema.table format", opts.TargetTable)
241264
}
242265

243266
// Query real column names from MySQL.
@@ -247,16 +270,30 @@ func parseBinlogWithMySQLBinlog(cfg connector.ConnConfig, paths []string, target
247270
}
248271

249272
// Build mysqlbinlog args.
250-
recoveryStr := recoveryTime.Format("2006-01-02 15:04:05")
251273
args := []string{
252274
"--no-defaults",
253275
"--base64-output=DECODE-ROWS",
254276
"--verbose",
255-
"--stop-datetime=" + recoveryStr,
277+
}
278+
if opts.StartTime != nil {
279+
args = append(args, "--start-datetime="+opts.StartTime.Format("2006-01-02 15:04:05"))
280+
}
281+
if opts.EndTime != nil {
282+
args = append(args, "--stop-datetime="+opts.EndTime.Format("2006-01-02 15:04:05"))
283+
}
284+
if opts.StartPos > 0 {
285+
args = append(args, fmt.Sprintf("--start-position=%d", opts.StartPos))
286+
}
287+
if opts.StopPos > 0 {
288+
args = append(args, fmt.Sprintf("--stop-position=%d", opts.StopPos))
256289
}
257290
args = append(args, paths...)
258291

259-
cmd := exec.Command("mysqlbinlog", args...)
292+
binlogBinary := opts.MySQLBinlogPath
293+
if binlogBinary == "" {
294+
binlogBinary = "mysqlbinlog"
295+
}
296+
cmd := exec.Command(binlogBinary, args...)
260297
stdout, err := cmd.StdoutPipe()
261298
if err != nil {
262299
return nil, fmt.Errorf("mysqlbinlog pipe: %w", err)
@@ -336,7 +373,7 @@ func parseBinlogWithMySQLBinlog(cfg connector.ConnConfig, paths []string, target
336373
}
337374
cmd.Wait()
338375

339-
log.Printf("mysqlbinlog parsed %d row event(s) for %s", len(events), targetTable)
376+
log.Printf("mysqlbinlog parsed %d row event(s) for %s", len(events), opts.TargetTable)
340377
return &connector.ParseResult{Events: events, TotalRows: int64(len(events))}, nil
341378
}
342379

cmd/agent/flashback_test.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ func (m *mockConnector) GetBinlogFiles(ctx context.Context) ([]connector.BinlogF
4343
return m.binlogFiles, m.binlogErr
4444
}
4545

46+
func (m *mockConnector) GetBinlogDir(ctx context.Context) (string, error) {
47+
return "/var/lib/mysql/", nil
48+
}
49+
4650
func (m *mockConnector) ParseBinlog(ctx context.Context, req connector.ParseRequest) (*connector.ParseResult, error) {
4751
return m.parseResult, m.parseErr
4852
}
@@ -60,9 +64,20 @@ func (m *mockConnector) Close() error {
6064
return nil
6165
}
6266

67+
// withFakeParse substitutes the mysqlbinlog parse seam for the duration of a
68+
// test, returning a fake result so flashback orchestration can be tested
69+
// without a live MySQL server.
70+
func withFakeParse(t *testing.T, result *connector.ParseResult, parseErr error) {
71+
t.Helper()
72+
orig := mysqlbinlogParse
73+
mysqlbinlogParse = func(cfg connector.ConnConfig, paths []string, opts binlogParseOpts) (*connector.ParseResult, error) {
74+
return result, parseErr
75+
}
76+
t.Cleanup(func() { mysqlbinlogParse = orig })
77+
}
78+
6379
// defaultMock creates a mock connector configured for a successful flashback.
64-
func defaultMock() *mockConnector {
65-
return &mockConnector{
80+
func defaultMock() *mockConnector { return &mockConnector{
6681
preflightResult: &connector.PreflightResult{
6782
Status: connector.PreflightPass,
6883
Version: "8.0.32",
@@ -171,6 +186,7 @@ func TestFlashbackCommand_ValidatesRequiredFlags(t *testing.T) {
171186

172187
func TestRunFlashback_DryRun(t *testing.T) {
173188
mock := defaultMock()
189+
withFakeParse(t, mock.parseResult, nil)
174190
opts := FlashbackOptions{
175191
Connector: mock,
176192
DSN: "root:pass@tcp(127.0.0.1:3306)/mydb",
@@ -190,6 +206,7 @@ func TestRunFlashback_OutputFile(t *testing.T) {
190206
outputPath := filepath.Join(dir, "rollback.sql")
191207

192208
mock := defaultMock()
209+
withFakeParse(t, mock.parseResult, nil)
193210
opts := FlashbackOptions{
194211
Connector: mock,
195212
DSN: "root:pass@tcp(127.0.0.1:3306)/mydb",
@@ -212,6 +229,7 @@ func TestRunFlashback_OutputFile(t *testing.T) {
212229

213230
func TestRunFlashback_Execute(t *testing.T) {
214231
mock := defaultMock()
232+
withFakeParse(t, mock.parseResult, nil)
215233
opts := FlashbackOptions{
216234
Connector: mock,
217235
DSN: "root:pass@tcp(127.0.0.1:3306)/mydb",
@@ -248,10 +266,10 @@ func TestRunFlashback_PreflightFail(t *testing.T) {
248266

249267
func TestRunFlashback_NoEvents(t *testing.T) {
250268
mock := defaultMock()
251-
mock.parseResult = &connector.ParseResult{
269+
withFakeParse(t, &connector.ParseResult{
252270
TotalRows: 0,
253271
Events: []connector.RowEvent{},
254-
}
272+
}, nil)
255273

256274
opts := FlashbackOptions{
257275
Connector: mock,
@@ -284,6 +302,7 @@ func TestRunFlashback_NoBinlogs(t *testing.T) {
284302
func TestRunFlashback_ConnectorProvided(t *testing.T) {
285303
// When using a pre-injected connector, DSN is not required.
286304
mock := defaultMock()
305+
withFakeParse(t, mock.parseResult, nil)
287306
opts := FlashbackOptions{
288307
Connector: mock,
289308
TargetTable: "mydb.orders",
@@ -425,7 +444,7 @@ func TestNewRootCommand_HasFlashbackSubcommand(t *testing.T) {
425444

426445
func TestRunFlashback_ParseError(t *testing.T) {
427446
mock := defaultMock()
428-
mock.parseErr = errors.New("binlog file corrupted")
447+
withFakeParse(t, nil, errors.New("binlog file corrupted"))
429448

430449
opts := FlashbackOptions{
431450
Connector: mock,

cmd/agent/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,15 @@ before a specified recovery time.
2323
2424
Sub-commands:
2525
flashback Perform offline binlog flashback (local-only, no WebSocket)
26+
serve Run as a persistent daemon serving the mysql-pitr-server
27+
config Encrypt the agent config file
2628
`,
2729
SilenceUsage: true,
2830
}
2931

3032
cmd.AddCommand(NewFlashbackCommand())
33+
cmd.AddCommand(NewServeCommand())
34+
cmd.AddCommand(NewConfigCommand())
3135

3236
return cmd
3337
}

0 commit comments

Comments
 (0)