diff --git a/Makefile b/Makefile index 3a8ba21..79c1597 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build push test +.PHONY: build push test deps TAG ?= $(shell git log -n 1 --pretty=format:"%H") IMAGE ?= databack/mysql-backup @@ -11,6 +11,10 @@ GOOS?=$(shell uname -s | tr '[:upper:]' '[:lower:]') GOARCH?=$(shell uname -m) BIN ?= $(DIST)/mysql-backup-$(GOOS)-$(GOARCH) +API ?= github.com/databacker/api/go/api +APITAG ?= latest + + build-docker: docker buildx build -t $(BUILDIMAGE) --platform $(OCIPLATFORMS) . @@ -62,3 +66,6 @@ clean-test-remove: @echo clean-test: clean-test-stop clean-test-remove + +deps: + go get -u $(API)@$(APITAG) diff --git a/cmd/dump.go b/cmd/dump.go index 63e95d5..41d1c8b 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -255,6 +255,18 @@ func dumpCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, er // done with the startup startupSpan.End() + // Fetch the MySQL server UUID once before the timer loop for use in + // protected-target identity. This is best-effort: if unavailable the + // identity will still be deterministic, just without the server UUID. + var serverUUID string + if cmdConfig.dbconn != nil { + if id, err := cmdConfig.dbconn.ServerUUID(); err == nil { + serverUUID = id + } else { + cmdConfig.logger.WithError(err).Debug("could not retrieve MySQL server_uuid; protected target identity will exclude it") + } + } + if err := executor.Timer(timerOpts, func() error { // start a new span for the dump, should not be a child of the startup one tracerCtx, dumpSpan := tracer.Start(ctx, string(api.BackupSpanRun)) @@ -280,6 +292,18 @@ func dumpCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, er } } dumpSpan.SetAttributes(attrs...) + // Emit configuration-based protected target attributes on the run span. + // selection_mode and configured_databases reflect how the engine was + // configured; actual databases are emitted later on the dump spans. + ptMode := core.BuildSelectionMode(include, exclude) + var ptConfiguredDBs []string + switch ptMode { + case api.BackupProtectedTargetSelectionModeInclude: + ptConfiguredDBs = include + case api.BackupProtectedTargetSelectionModeExclude: + ptConfiguredDBs = exclude + } + core.SetRunSpanProtectedTargetAttrs(dumpSpan, ptMode, serverUUID, ptConfiguredDBs) defer func() { attrs := []attribute.KeyValue{ attribute.String(string(api.BackupAttrStatus), backupStatus), @@ -311,6 +335,7 @@ func dumpCmd(passedExecs execs, cmdConfig *cmdConfiguration) (*cobra.Command, er FilenamePattern: filenamePattern, Parallelism: parallel, IgnoreTables: ignoreTables, + ServerUUID: serverUUID, } results, err := executor.Dump(tracerCtx, dumpOpts) if err != nil { diff --git a/go.mod b/go.mod index d14971e..299ee5f 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( filippo.io/age v1.2.1 github.com/InfiniteLoopSpace/go_S-MIME v0.0.0-20181221134359-3f58f9a4b2b6 github.com/bramvdbogaerde/go-scp v1.5.0 - github.com/databacker/api/go/api v0.0.0-20260511132103-4edbce2341a3 + github.com/databacker/api/go/api v0.0.0-20260603135428-161b2ee82911 github.com/gliderlabs/ssh v0.3.8 github.com/google/go-cmp v0.7.0 github.com/kevinburke/ssh_config v1.2.0 @@ -55,7 +55,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-chi/chi/v5 v5.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -64,7 +64,7 @@ require ( github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect - github.com/oapi-codegen/runtime v1.4.0 // indirect + github.com/oapi-codegen/runtime v1.4.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect diff --git a/go.sum b/go.sum index ecfeb63..1163678 100644 --- a/go.sum +++ b/go.sum @@ -88,14 +88,10 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/databacker/api/go/api v0.0.0-20260203120843-ea139d59c2ed h1:PKzk3NUbJGSOpINtzGNSJR8jLqrTIy0enpaUma5RFgk= -github.com/databacker/api/go/api v0.0.0-20260203120843-ea139d59c2ed/go.mod h1:vXp1gX/diWXW659asaBZ7K3Wgs2OG3igz0dF0UB4yIY= -github.com/databacker/api/go/api v0.0.0-20260511123027-25b8d0ecddb7 h1:wAl574i+9CD/SJLh7ng8NuGMGa04VCFPeoxqk9wFRFE= -github.com/databacker/api/go/api v0.0.0-20260511123027-25b8d0ecddb7/go.mod h1:vXp1gX/diWXW659asaBZ7K3Wgs2OG3igz0dF0UB4yIY= -github.com/databacker/api/go/api v0.0.0-20260511131506-50108473a7ba h1:vMkvDnSGi3QzJPSs9zEowfUHSt2U2nXgWmSZafLzRyw= -github.com/databacker/api/go/api v0.0.0-20260511131506-50108473a7ba/go.mod h1:vXp1gX/diWXW659asaBZ7K3Wgs2OG3igz0dF0UB4yIY= -github.com/databacker/api/go/api v0.0.0-20260511132103-4edbce2341a3 h1:SVRt6Uqajczc09iEGcax2AhL16dYrvcjanX81TcWROM= -github.com/databacker/api/go/api v0.0.0-20260511132103-4edbce2341a3/go.mod h1:vXp1gX/diWXW659asaBZ7K3Wgs2OG3igz0dF0UB4yIY= +github.com/databacker/api/go/api v0.0.0-20260603133243-b6e190c1ffbc h1:l+AysIwoI7nT0K7WDDa5bHRhtaHgL00Gs11D6yWEd1E= +github.com/databacker/api/go/api v0.0.0-20260603133243-b6e190c1ffbc/go.mod h1:vXp1gX/diWXW659asaBZ7K3Wgs2OG3igz0dF0UB4yIY= +github.com/databacker/api/go/api v0.0.0-20260603135428-161b2ee82911 h1:MN97c3fLpKuVjLo4wal9KY4uHKHm4cSZnlz7YiNuDwU= +github.com/databacker/api/go/api v0.0.0-20260603135428-161b2ee82911/go.mod h1:vXp1gX/diWXW659asaBZ7K3Wgs2OG3igz0dF0UB4yIY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -122,10 +118,8 @@ github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNe github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= -github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -226,10 +220,10 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= -github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= -github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= +github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -442,9 +436,8 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/core/dump.go b/pkg/core/dump.go index af227f3..d511521 100644 --- a/pkg/core/dump.go +++ b/pkg/core/dump.go @@ -97,6 +97,17 @@ func (e *Executor) Dump(ctx context.Context, opts DumpOptions) (DumpResults, err // filter out excluded databases dbnames = filterExcludedDatabases(dbnames, opts.Exclude) span.SetAttributes(attribute.StringSlice(string(api.BackupAttrActualSchemas), dbnames)) + + // emit protected target attributes on the dump span now that the actual + // database list is known. The identity is built from the configured include/ + // exclude lists so it is stable regardless of runtime schema discovery. + ptMode := BuildSelectionMode(opts.DBNames, opts.Exclude) + ptConfiguredDBs := opts.DBNames // include list for include mode + if ptMode == api.BackupProtectedTargetSelectionModeExclude { + ptConfiguredDBs = opts.Exclude + } + SetDumpSpanProtectedTargetAttrs(span, ptMode, opts.ServerUUID, ptConfiguredDBs, dbnames) + for _, s := range dbnames { outFile := path.Join(workdir, fmt.Sprintf("%s_%s.sql", s, timepart)) f, err := os.Create(outFile) @@ -110,6 +121,7 @@ func (e *Executor) Dump(ctx context.Context, opts DumpOptions) (DumpResults, err } results.DumpStart = time.Now() dbDumpCtx, dbDumpSpan := tracer.Start(ctx, string(api.BackupSpanDatabaseDump)) + SetDumpSpanProtectedTargetAttrs(dbDumpSpan, ptMode, opts.ServerUUID, ptConfiguredDBs, dbnames) if err := database.Dump(dbDumpCtx, dbconn, database.DumpOpts{ Compact: compact, Triggers: triggers, diff --git a/pkg/core/dumpoptions.go b/pkg/core/dumpoptions.go index 0e65e3c..c63d66b 100644 --- a/pkg/core/dumpoptions.go +++ b/pkg/core/dumpoptions.go @@ -33,4 +33,7 @@ type DumpOptions struct { // Parallelism how many databases to back up at once, consuming that number of threads Parallelism int IgnoreTables []string + // ServerUUID is the MySQL server's @@global.server_uuid, used to build the + // protected_target.identity span attribute. May be empty if unavailable. + ServerUUID string } diff --git a/pkg/core/protectedtarget.go b/pkg/core/protectedtarget.go new file mode 100644 index 0000000..9b5b310 --- /dev/null +++ b/pkg/core/protectedtarget.go @@ -0,0 +1,102 @@ +package core + +import ( + "encoding/json" + "sort" + "strings" + + "github.com/databacker/api/go/api" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// BuildSelectionMode derives the selection mode from the configured include and +// exclude lists: +// +// include — one or more databases are explicitly named for inclusion. +// exclude — all databases except a named subset (include is empty, exclude non-empty). +// all — entire server/cluster (both lists are empty). +func BuildSelectionMode(include, exclude []string) api.BackupProtectedTargetSelectionMode { + if len(include) > 0 { + return api.BackupProtectedTargetSelectionModeInclude + } + if len(exclude) > 0 { + return api.BackupProtectedTargetSelectionModeExclude + } + return api.BackupProtectedTargetSelectionModeAll +} + +// BuildProtectedTargetIdentity produces a stable, deterministic identity string +// for a MySQL protected target. +// +// Format: mysql:{mode}:{native_id}[:{sorted_db1}:{sorted_db2}:...] +// +// For include and exclude modes, the sorted configured database names are +// appended as additional colon-separated components. nativeID may be empty +// if the server UUID could not be retrieved. +// +// The same configured target backed up by different engine instances will +// produce the same identity when connected to the same MySQL server with the +// same selection mode and configured database list. +func BuildProtectedTargetIdentity(mode api.BackupProtectedTargetSelectionMode, nativeID string, configuredDBNames []string) string { + parts := []string{"mysql", string(mode), nativeID} + if (mode == api.BackupProtectedTargetSelectionModeInclude || mode == api.BackupProtectedTargetSelectionModeExclude) && len(configuredDBNames) > 0 { + sorted := make([]string, len(configuredDBNames)) + copy(sorted, configuredDBNames) + sort.Strings(sorted) + parts = append(parts, sorted...) + } + return strings.Join(parts, ":") +} + +// SetRunSpanProtectedTargetAttrs sets the configuration-based protected-target +// attributes on the root run span: +// +// backup.protected_target.selection_mode — how databases are selected +// backup.protected_target.configured_databases — the include/exclude list (omitted for all mode) +// backup.protected_target.identity — stable deterministic identity +// db.server.native_id — MySQL server_uuid (when available) +func SetRunSpanProtectedTargetAttrs(span trace.Span, mode api.BackupProtectedTargetSelectionMode, nativeID string, configuredDBNames []string) { + identity := BuildProtectedTargetIdentity(mode, nativeID, configuredDBNames) + attrs := []attribute.KeyValue{ + attribute.String(string(api.BackupAttrProtectedTargetSelectionMode), string(mode)), + attribute.String(string(api.BackupAttrProtectedTargetIdentity), identity), + } + if nativeID != "" { + attrs = append(attrs, attribute.String(string(api.BackupAttrDBServerNativeID), nativeID)) + } + if (mode == api.BackupProtectedTargetSelectionModeInclude || mode == api.BackupProtectedTargetSelectionModeExclude) && len(configuredDBNames) > 0 { + sorted := make([]string, len(configuredDBNames)) + copy(sorted, configuredDBNames) + sort.Strings(sorted) + dbJSON, _ := json.Marshal(sorted) + attrs = append(attrs, attribute.String(string(api.BackupAttrProtectedTargetConfiguredDatabases), string(dbJSON))) + } + span.SetAttributes(attrs...) +} + +// SetDumpSpanProtectedTargetAttrs sets protected-target attributes on dump and +// database_dump spans. It emits selection_mode, identity, and native_id +// (derived from the configuration) plus the actual database_count and databases +// resolved at runtime after schema discovery and exclude-list filtering. +func SetDumpSpanProtectedTargetAttrs(span trace.Span, mode api.BackupProtectedTargetSelectionMode, nativeID string, configuredDBNames []string, actualDBNames []string) { + identity := BuildProtectedTargetIdentity(mode, nativeID, configuredDBNames) + attrs := []attribute.KeyValue{ + attribute.String(string(api.BackupAttrProtectedTargetSelectionMode), string(mode)), + attribute.String(string(api.BackupAttrProtectedTargetIdentity), identity), + } + if nativeID != "" { + attrs = append(attrs, attribute.String(string(api.BackupAttrDBServerNativeID), nativeID)) + } + if len(actualDBNames) > 0 { + sorted := make([]string, len(actualDBNames)) + copy(sorted, actualDBNames) + sort.Strings(sorted) + dbJSON, _ := json.Marshal(sorted) + attrs = append(attrs, + attribute.Int(string(api.BackupAttrProtectedTargetDatabaseCount), len(sorted)), + attribute.String(string(api.BackupAttrProtectedTargetDatabases), string(dbJSON)), + ) + } + span.SetAttributes(attrs...) +} diff --git a/pkg/database/serverinfo.go b/pkg/database/serverinfo.go new file mode 100644 index 0000000..30671ab --- /dev/null +++ b/pkg/database/serverinfo.go @@ -0,0 +1,18 @@ +package database + +import "fmt" + +// ServerUUID queries MySQL for the server's global server_uuid variable. +// This is a stable identifier for the MySQL server instance across restarts +// (@@global.server_uuid). +func (c *Connection) ServerUUID() (string, error) { + db, err := c.MySQL() + if err != nil { + return "", fmt.Errorf("failed to open connection to database: %v", err) + } + var serverUUID string + if err := db.QueryRow("SELECT @@global.server_uuid").Scan(&serverUUID); err != nil { + return "", fmt.Errorf("failed to query server_uuid: %v", err) + } + return serverUUID, nil +}