diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 9fcc3cd..54f5e47 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -86,6 +86,39 @@ jobs: cd frontend npm run build + lint-agent: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.9" + cache-dependency-path: agent/go.sum + + - name: Download Go modules + run: | + cd agent + go mod download + + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.7.2 + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Run golangci-lint + run: | + cd agent + golangci-lint run + + - name: Verify go mod tidy + run: | + cd agent + go mod tidy + git diff --exit-code go.mod go.sum || (echo "go mod tidy made changes, please run 'go mod tidy' and commit the changes" && exit 1) + test-frontend: runs-on: ubuntu-latest needs: [lint-frontend] @@ -108,6 +141,29 @@ jobs: cd frontend npm run test + test-agent: + runs-on: ubuntu-latest + needs: [lint-agent] + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.9" + cache-dependency-path: agent/go.sum + + - name: Download Go modules + run: | + cd agent + go mod download + + - name: Run Go tests + run: | + cd agent + go test -count=1 -failfast ./internal/... + test-backend: runs-on: self-hosted needs: [lint-backend] @@ -441,7 +497,7 @@ jobs: runs-on: self-hosted container: image: node:20 - needs: [test-backend, test-frontend] + needs: [test-backend, test-frontend, test-agent] if: ${{ github.ref == 'refs/heads/main' && !contains(github.event.head_commit.message, '[skip-release]') }} outputs: should_release: ${{ steps.version_bump.outputs.should_release }} @@ -534,7 +590,7 @@ jobs: build-only: runs-on: self-hosted - needs: [test-backend, test-frontend] + needs: [test-backend, test-frontend, test-agent] if: ${{ github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[skip-release]') }} steps: - name: Clean workspace diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96b5f73..dafbf90 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,3 +41,20 @@ repos: language: system files: ^backend/.*\.go$ pass_filenames: false + + # Agent checks + - repo: local + hooks: + - id: agent-format-and-lint + name: Agent Format & Lint (golangci-lint) + entry: bash -c "cd agent && golangci-lint fmt ./internal/... ./cmd/... && golangci-lint run ./internal/... ./cmd/..." + language: system + files: ^agent/.*\.go$ + pass_filenames: false + + - id: agent-go-mod-tidy + name: Agent Go Mod Tidy + entry: bash -c "cd agent && go mod tidy" + language: system + files: ^agent/.*\.go$ + pass_filenames: false diff --git a/Dockerfile b/Dockerfile index 91ed847..4c64d2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,43 @@ RUN CGO_ENABLED=0 \ go build -o /app/main ./cmd/main.go +# ========= BUILD AGENT ========= +# Builds the databasus-agent CLI binary for BOTH x86_64 and ARM64. +# Both architectures are always built because: +# - Databasus server runs on one arch (e.g. amd64) +# - The agent runs on remote PostgreSQL servers that may be on a +# different arch (e.g. arm64) +# - The backend serves the correct binary based on the agent's +# ?arch= query parameter +# +# We cross-compile from the build platform (no QEMU needed) because the +# agent is pure Go with zero C dependencies. +# CGO_ENABLED=0 produces fully static binaries — no glibc/musl dependency, +# so the agent runs on any Linux distro (Alpine, Debian, Ubuntu, RHEL, etc.). +# APP_VERSION is baked into the binary via -ldflags so the agent can +# compare its version against the server and auto-update when needed. +FROM --platform=$BUILDPLATFORM golang:1.24.9 AS agent-build + +ARG APP_VERSION=dev + +WORKDIR /agent + +COPY agent/go.mod ./ +RUN go mod download + +COPY agent/ ./ + +# Build for x86_64 (amd64) — static binary, no glibc dependency +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -ldflags "-X main.Version=${APP_VERSION}" \ + -o /agent-binaries/databasus-agent-linux-amd64 ./cmd/main.go + +# Build for ARM64 (arm64) — static binary, no glibc dependency +RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ + go build -ldflags "-X main.Version=${APP_VERSION}" \ + -o /agent-binaries/databasus-agent-linux-arm64 ./cmd/main.go + + # ========= RUNTIME ========= FROM debian:bookworm-slim @@ -220,6 +257,10 @@ COPY backend/migrations ./migrations # Copy UI files COPY --from=backend-build /app/ui/build ./ui/build +# Copy agent binaries (both architectures) — served by the backend +# at GET /api/v1/system/agent?arch=amd64|arm64 +COPY --from=agent-build /agent-binaries ./agent-binaries + # Copy .env file (with fallback to .env.production.example) COPY backend/.env* /app/ RUN if [ ! -f /app/.env ]; then \ @@ -397,6 +438,8 @@ fi # Create database and set password for postgres user echo "Setting up database and user..." gosu postgres \$PG_BIN/psql -p 5437 -h localhost -d postgres << 'SQL' + +# We use stub password, because internal DB is not exposed outside container ALTER USER postgres WITH PASSWORD 'Q1234567'; SELECT 'CREATE DATABASE databasus OWNER postgres' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'databasus') diff --git a/agent/.env.example b/agent/.env.example new file mode 100644 index 0000000..f655030 --- /dev/null +++ b/agent/.env.example @@ -0,0 +1 @@ +ENV_MODE=development diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000..074fbf7 --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,23 @@ +main +.env +docker-compose.yml +pgdata +pgdata_test/ +mysqldata/ +mariadbdata/ +main.exe +swagger/ +swagger/* +swagger/docs.go +swagger/swagger.json +swagger/swagger.yaml +postgresus-backend.exe +databasus-backend.exe +ui/build/* +pgdata-for-restore/ +temp/ +cmd.exe +temp/ +valkey-data/ +victoria-logs-data/ +databasus.json \ No newline at end of file diff --git a/agent/.golangci.yml b/agent/.golangci.yml new file mode 100644 index 0000000..b339d97 --- /dev/null +++ b/agent/.golangci.yml @@ -0,0 +1,19 @@ +version: "2" + +run: + timeout: 5m + tests: false + concurrency: 4 + +linters: + default: standard + + settings: + errcheck: + check-type-assertions: true + +formatters: + enable: + - gofmt + - golines + - goimports diff --git a/agent/Makefile b/agent/Makefile new file mode 100644 index 0000000..297aff8 --- /dev/null +++ b/agent/Makefile @@ -0,0 +1,12 @@ +# Usage: make run ARGS="start --pg-host localhost" +run: + go run cmd/main.go $(ARGS) + +build: + CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o databasus-agent ./cmd/main.go + +test: + go test -count=1 -failfast ./internal/... + +lint: + golangci-lint fmt ./cmd/... ./internal/... && golangci-lint run ./cmd/... ./internal/... diff --git a/agent/cmd/main.go b/agent/cmd/main.go new file mode 100644 index 0000000..0855d5c --- /dev/null +++ b/agent/cmd/main.go @@ -0,0 +1,173 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "databasus-agent/internal/config" + "databasus-agent/internal/features/start" + "databasus-agent/internal/features/upgrade" + "databasus-agent/internal/logger" +) + +var Version = "dev" + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + switch os.Args[1] { + case "start": + runStart(os.Args[2:]) + case "stop": + runStop() + case "status": + runStatus() + case "restore": + runRestore(os.Args[2:]) + case "version": + fmt.Println(Version) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + printUsage() + os.Exit(1) + } +} + +func runStart(args []string) { + fs := flag.NewFlagSet("start", flag.ExitOnError) + + isDebug := fs.Bool("debug", false, "Enable debug logging") + isSkipUpdate := fs.Bool("skip-update", false, "Skip auto-update check") + + cfg := &config.Config{} + cfg.LoadFromJSONAndArgs(fs, args) + + if err := cfg.SaveToJSON(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to save config: %v\n", err) + } + + logger.Init(*isDebug) + log := logger.GetLogger() + + isDev := checkIsDevelopment() + runUpdateCheck(cfg.DatabasusHost, *isSkipUpdate, isDev, log) + + if err := start.Run(cfg, log); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func runStop() { + logger.Init(false) + logger.GetLogger().Info("stop: stub — not yet implemented") +} + +func runStatus() { + logger.Init(false) + logger.GetLogger().Info("status: stub — not yet implemented") +} + +func runRestore(args []string) { + fs := flag.NewFlagSet("restore", flag.ExitOnError) + + targetDir := fs.String("target-dir", "", "Target pgdata directory") + backupID := fs.String("backup-id", "", "Full backup UUID (optional)") + targetTime := fs.String("target-time", "", "PITR target time in RFC3339 (optional)") + isYes := fs.Bool("yes", false, "Skip confirmation prompt") + isDebug := fs.Bool("debug", false, "Enable debug logging") + isSkipUpdate := fs.Bool("skip-update", false, "Skip auto-update check") + + cfg := &config.Config{} + cfg.LoadFromJSONAndArgs(fs, args) + + if err := cfg.SaveToJSON(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to save config: %v\n", err) + } + + logger.Init(*isDebug) + log := logger.GetLogger() + + isDev := checkIsDevelopment() + runUpdateCheck(cfg.DatabasusHost, *isSkipUpdate, isDev, log) + + log.Info("restore: stub — not yet implemented", + "targetDir", *targetDir, + "backupId", *backupID, + "targetTime", *targetTime, + "yes", *isYes, + ) +} + +func printUsage() { + fmt.Fprintln(os.Stderr, "Usage: databasus-agent [flags]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Commands:") + fmt.Fprintln(os.Stderr, " start Start the agent (WAL archiving + basebackups)") + fmt.Fprintln(os.Stderr, " stop Stop a running agent") + fmt.Fprintln(os.Stderr, " status Show agent status") + fmt.Fprintln(os.Stderr, " restore Restore a database from backup") + fmt.Fprintln(os.Stderr, " version Print agent version") +} + +func runUpdateCheck(host string, isSkipUpdate bool, isDev bool, log interface { + Info(string, ...any) + Warn(string, ...any) + Error(string, ...any) +}) { + if isSkipUpdate { + return + } + + if host == "" { + return + } + + if err := upgrade.CheckAndUpdate(host, Version, isDev, log); err != nil { + log.Error("Auto-update failed", "error", err) + os.Exit(1) + } +} + +func checkIsDevelopment() bool { + dir, err := os.Getwd() + if err != nil { + return false + } + + for range 3 { + if data, err := os.ReadFile(filepath.Join(dir, ".env")); err == nil { + return parseEnvMode(data) + } + + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return false + } + + dir = filepath.Dir(dir) + } + + return false +} + +func parseEnvMode(data []byte) bool { + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 && strings.TrimSpace(parts[0]) == "ENV_MODE" { + return strings.TrimSpace(parts[1]) == "development" + } + } + + return false +} diff --git a/agent/go.mod b/agent/go.mod new file mode 100644 index 0000000..5dbe5bf --- /dev/null +++ b/agent/go.mod @@ -0,0 +1,11 @@ +module databasus-agent + +go 1.24.9 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/agent/go.sum b/agent/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/agent/go.sum @@ -0,0 +1,10 @@ +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= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go new file mode 100644 index 0000000..3e37d77 --- /dev/null +++ b/agent/internal/config/config.go @@ -0,0 +1,136 @@ +package config + +import ( + "encoding/json" + "flag" + "os" + + "databasus-agent/internal/logger" +) + +var log = logger.GetLogger() + +const configFileName = "databasus.json" + +type Config struct { + DatabasusHost string `json:"databasusHost"` + DbID string `json:"dbId"` + Token string `json:"token"` + + flags parsedFlags +} + +// LoadFromJSONAndArgs reads databasus.json into the struct +// and overrides JSON values with any explicitly provided CLI flags. +func (c *Config) LoadFromJSONAndArgs(fs *flag.FlagSet, args []string) { + c.loadFromJSON() + c.initSources() + + c.flags.host = fs.String( + "databasus-host", + "", + "Databasus server URL (e.g. http://your-server:4005)", + ) + c.flags.dbID = fs.String("db-id", "", "Database ID") + c.flags.token = fs.String("token", "", "Agent token") + + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + + c.applyFlags() + log.Info("========= Loading config ============") + c.logConfigSources() + log.Info("========= Config has been loaded ====") +} + +// SaveToJSON writes the current struct to databasus.json. +func (c *Config) SaveToJSON() error { + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configFileName, data, 0644) +} + +func (c *Config) loadFromJSON() { + data, err := os.ReadFile(configFileName) + if err != nil { + if os.IsNotExist(err) { + log.Info("No databasus.json found, will create on save") + return + } + + log.Warn("Failed to read databasus.json", "error", err) + + return + } + + if err := json.Unmarshal(data, c); err != nil { + log.Warn("Failed to parse databasus.json", "error", err) + + return + } + + log.Info("Configuration loaded from " + configFileName) +} + +func (c *Config) initSources() { + c.flags.sources = map[string]string{ + "databasus-host": "not configured", + "db-id": "not configured", + "token": "not configured", + } + + if c.DatabasusHost != "" { + c.flags.sources["databasus-host"] = configFileName + } + + if c.DbID != "" { + c.flags.sources["db-id"] = configFileName + } + + if c.Token != "" { + c.flags.sources["token"] = configFileName + } +} + +func (c *Config) applyFlags() { + if c.flags.host != nil && *c.flags.host != "" { + c.DatabasusHost = *c.flags.host + c.flags.sources["databasus-host"] = "command line args" + } + + if c.flags.dbID != nil && *c.flags.dbID != "" { + c.DbID = *c.flags.dbID + c.flags.sources["db-id"] = "command line args" + } + + if c.flags.token != nil && *c.flags.token != "" { + c.Token = *c.flags.token + c.flags.sources["token"] = "command line args" + } +} + +func (c *Config) logConfigSources() { + log.Info( + "databasus-host", + "value", + c.DatabasusHost, + "source", + c.flags.sources["databasus-host"], + ) + log.Info("db-id", "value", c.DbID, "source", c.flags.sources["db-id"]) + log.Info("token", "value", maskSensitive(c.Token), "source", c.flags.sources["token"]) +} + +func maskSensitive(value string) string { + if value == "" { + return "(not set)" + } + + visibleLen := max(len(value)/4, 1) + + return value[:visibleLen] + "***" +} diff --git a/agent/internal/config/config_test.go b/agent/internal/config/config_test.go new file mode 100644 index 0000000..cb5f14b --- /dev/null +++ b/agent/internal/config/config_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "encoding/json" + "flag" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_LoadFromJSONAndArgs_ValuesLoadedFromJSON(t *testing.T) { + dir := setupTempDir(t) + writeConfigJSON(t, dir, Config{ + DatabasusHost: "http://json-host:4005", + DbID: "json-db-id", + Token: "json-token", + }) + + cfg := &Config{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + cfg.LoadFromJSONAndArgs(fs, []string{}) + + assert.Equal(t, "http://json-host:4005", cfg.DatabasusHost) + assert.Equal(t, "json-db-id", cfg.DbID) + assert.Equal(t, "json-token", cfg.Token) +} + +func Test_LoadFromJSONAndArgs_ValuesLoadedFromArgs_WhenNoJSON(t *testing.T) { + setupTempDir(t) + + cfg := &Config{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + cfg.LoadFromJSONAndArgs(fs, []string{ + "--databasus-host", "http://arg-host:4005", + "--db-id", "arg-db-id", + "--token", "arg-token", + }) + + assert.Equal(t, "http://arg-host:4005", cfg.DatabasusHost) + assert.Equal(t, "arg-db-id", cfg.DbID) + assert.Equal(t, "arg-token", cfg.Token) +} + +func Test_LoadFromJSONAndArgs_ArgsOverrideJSON(t *testing.T) { + dir := setupTempDir(t) + writeConfigJSON(t, dir, Config{ + DatabasusHost: "http://json-host:4005", + DbID: "json-db-id", + Token: "json-token", + }) + + cfg := &Config{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + cfg.LoadFromJSONAndArgs(fs, []string{ + "--databasus-host", "http://arg-host:9999", + "--db-id", "arg-db-id-override", + "--token", "arg-token-override", + }) + + assert.Equal(t, "http://arg-host:9999", cfg.DatabasusHost) + assert.Equal(t, "arg-db-id-override", cfg.DbID) + assert.Equal(t, "arg-token-override", cfg.Token) +} + +func Test_LoadFromJSONAndArgs_PartialArgsOverrideJSON(t *testing.T) { + dir := setupTempDir(t) + writeConfigJSON(t, dir, Config{ + DatabasusHost: "http://json-host:4005", + DbID: "json-db-id", + Token: "json-token", + }) + + cfg := &Config{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + cfg.LoadFromJSONAndArgs(fs, []string{ + "--databasus-host", "http://arg-host-only:4005", + }) + + assert.Equal(t, "http://arg-host-only:4005", cfg.DatabasusHost) + assert.Equal(t, "json-db-id", cfg.DbID) + assert.Equal(t, "json-token", cfg.Token) +} + +func Test_SaveToJSON_ConfigSavedCorrectly(t *testing.T) { + setupTempDir(t) + + cfg := &Config{ + DatabasusHost: "http://save-host:4005", + DbID: "save-db-id", + Token: "save-token", + } + + err := cfg.SaveToJSON() + require.NoError(t, err) + + saved := readConfigJSON(t) + + assert.Equal(t, "http://save-host:4005", saved.DatabasusHost) + assert.Equal(t, "save-db-id", saved.DbID) + assert.Equal(t, "save-token", saved.Token) +} + +func Test_SaveToJSON_AfterArgsOverrideJSON_SavedFileContainsMergedValues(t *testing.T) { + dir := setupTempDir(t) + writeConfigJSON(t, dir, Config{ + DatabasusHost: "http://json-host:4005", + DbID: "json-db-id", + Token: "json-token", + }) + + cfg := &Config{} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + cfg.LoadFromJSONAndArgs(fs, []string{ + "--databasus-host", "http://override-host:9999", + }) + + err := cfg.SaveToJSON() + require.NoError(t, err) + + saved := readConfigJSON(t) + + assert.Equal(t, "http://override-host:9999", saved.DatabasusHost) + assert.Equal(t, "json-db-id", saved.DbID) + assert.Equal(t, "json-token", saved.Token) +} + +func setupTempDir(t *testing.T) string { + t.Helper() + + origDir, err := os.Getwd() + require.NoError(t, err) + + dir := t.TempDir() + require.NoError(t, os.Chdir(dir)) + + t.Cleanup(func() { os.Chdir(origDir) }) + + return dir +} + +func writeConfigJSON(t *testing.T, dir string, cfg Config) { + t.Helper() + + data, err := json.MarshalIndent(cfg, "", " ") + require.NoError(t, err) + + require.NoError(t, os.WriteFile(dir+"/"+configFileName, data, 0644)) +} + +func readConfigJSON(t *testing.T) Config { + t.Helper() + + data, err := os.ReadFile(configFileName) + require.NoError(t, err) + + var cfg Config + require.NoError(t, json.Unmarshal(data, &cfg)) + + return cfg +} diff --git a/agent/internal/config/dto.go b/agent/internal/config/dto.go new file mode 100644 index 0000000..65a6743 --- /dev/null +++ b/agent/internal/config/dto.go @@ -0,0 +1,9 @@ +package config + +type parsedFlags struct { + host *string + dbID *string + token *string + + sources map[string]string +} diff --git a/agent/internal/features/start/start.go b/agent/internal/features/start/start.go new file mode 100644 index 0000000..d70d3b2 --- /dev/null +++ b/agent/internal/features/start/start.go @@ -0,0 +1,37 @@ +package start + +import ( + "errors" + "log/slog" + + "databasus-agent/internal/config" +) + +func Run(cfg *config.Config, log *slog.Logger) error { + if err := validateConfig(cfg); err != nil { + return err + } + + log.Info("start: stub — not yet implemented", + "dbId", cfg.DbID, + "hasToken", cfg.Token != "", + ) + + return nil +} + +func validateConfig(cfg *config.Config) error { + if cfg.DatabasusHost == "" { + return errors.New("argument databasus-host is required") + } + + if cfg.DbID == "" { + return errors.New("argument db-id is required") + } + + if cfg.Token == "" { + return errors.New("argument token is required") + } + + return nil +} diff --git a/agent/internal/features/upgrade/upgrader.go b/agent/internal/features/upgrade/upgrader.go new file mode 100644 index 0000000..425dbcb --- /dev/null +++ b/agent/internal/features/upgrade/upgrader.go @@ -0,0 +1,142 @@ +package upgrade + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "syscall" + "time" +) + +type Logger interface { + Info(msg string, args ...any) + Warn(msg string, args ...any) + Error(msg string, args ...any) +} + +type versionResponse struct { + Version string `json:"version"` +} + +func CheckAndUpdate(databasusHost, currentVersion string, isDev bool, log Logger) error { + if isDev { + log.Info("Skipping update check (development mode)") + return nil + } + + serverVersion, err := fetchServerVersion(databasusHost, log) + if err != nil { + return nil + } + + if serverVersion == currentVersion { + log.Info("Agent version is up to date", "version", currentVersion) + return nil + } + + log.Info("Updating agent...", "current", currentVersion, "target", serverVersion) + + selfPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine executable path: %w", err) + } + + tempPath := selfPath + ".update" + + defer func() { + _ = os.Remove(tempPath) + }() + + if err := downloadBinary(databasusHost, tempPath); err != nil { + return fmt.Errorf("failed to download update: %w", err) + } + + if err := os.Chmod(tempPath, 0755); err != nil { + return fmt.Errorf("failed to set permissions on update: %w", err) + } + + if err := verifyBinary(tempPath, serverVersion); err != nil { + return fmt.Errorf("update verification failed: %w", err) + } + + if err := os.Rename(tempPath, selfPath); err != nil { + return fmt.Errorf("failed to replace binary (try --skip-update if this persists): %w", err) + } + + log.Info("Update complete, re-executing...") + + return syscall.Exec(selfPath, os.Args, os.Environ()) +} + +func fetchServerVersion(host string, log Logger) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + + resp, err := client.Get(host + "/api/v1/system/version") + if err != nil { + log.Warn("Could not reach server for update check, continuing", "error", err) + return "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + log.Warn( + "Server returned non-OK status for version check, continuing", + "status", + resp.StatusCode, + ) + return "", fmt.Errorf("status %d", resp.StatusCode) + } + + var ver versionResponse + if err := json.NewDecoder(resp.Body).Decode(&ver); err != nil { + log.Warn("Failed to parse server version response, continuing", "error", err) + return "", err + } + + return ver.Version, nil +} + +func downloadBinary(host, destPath string) error { + url := fmt.Sprintf("%s/api/v1/system/agent?arch=%s", host, runtime.GOARCH) + + resp, err := http.Get(url) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned %d for agent download", resp.StatusCode) + } + + f, err := os.Create(destPath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + _, err = io.Copy(f, resp.Body) + + return err +} + +func verifyBinary(binaryPath, expectedVersion string) error { + cmd := exec.Command(binaryPath, "version") + + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("binary failed to execute: %w", err) + } + + got := strings.TrimSpace(string(output)) + if got != expectedVersion { + return fmt.Errorf("version mismatch: expected %q, got %q", expectedVersion, got) + } + + return nil +} diff --git a/agent/internal/logger/logger.go b/agent/internal/logger/logger.go new file mode 100644 index 0000000..078ac75 --- /dev/null +++ b/agent/internal/logger/logger.go @@ -0,0 +1,47 @@ +package logger + +import ( + "log/slog" + "os" + "sync" + "time" +) + +var ( + loggerInstance *slog.Logger + once sync.Once +) + +func Init(isDebug bool) { + level := slog.LevelInfo + if isDebug { + level = slog.LevelDebug + } + + once.Do(func() { + loggerInstance = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: level, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + a.Value = slog.StringValue(time.Now().Format("2006/01/02 15:04:05")) + } + if a.Key == slog.LevelKey { + return slog.Attr{} + } + + return a + }, + })) + + loggerInstance.Info("Text structured logger initialized") + }) +} + +// GetLogger returns a singleton slog.Logger that logs to the console +func GetLogger() *slog.Logger { + if loggerInstance == nil { + Init(false) + } + + return loggerInstance +} diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 6f472dc..1cfc726 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -28,6 +28,7 @@ import ( "databasus-backend/internal/features/restores" "databasus-backend/internal/features/restores/restoring" "databasus-backend/internal/features/storages" + system_agent "databasus-backend/internal/features/system/agent" system_healthcheck "databasus-backend/internal/features/system/healthcheck" system_version "databasus-backend/internal/features/system/version" task_cancellation "databasus-backend/internal/features/tasks/cancellation" @@ -212,6 +213,7 @@ func setUpRoutes(r *gin.Engine) { userController.RegisterRoutes(v1) system_healthcheck.GetHealthcheckController().RegisterRoutes(v1) system_version.GetVersionController().RegisterRoutes(v1) + system_agent.GetAgentController().RegisterRoutes(v1) backups_controllers.GetBackupController().RegisterPublicRoutes(v1) backups_controllers.GetPostgresWalBackupController().RegisterRoutes(v1) databases.GetDatabaseController().RegisterPublicRoutes(v1) diff --git a/backend/internal/features/system/agent/controller.go b/backend/internal/features/system/agent/controller.go new file mode 100644 index 0000000..ad1ebd1 --- /dev/null +++ b/backend/internal/features/system/agent/controller.go @@ -0,0 +1,48 @@ +package system_agent + +import ( + "net/http" + "os" + "path/filepath" + + "github.com/gin-gonic/gin" +) + +type AgentController struct{} + +func (c *AgentController) RegisterRoutes(router *gin.RouterGroup) { + router.GET("/system/agent", c.DownloadAgent) +} + +// DownloadAgent +// @Summary Download agent binary +// @Description Download the databasus-agent binary for the specified architecture +// @Tags system/agent +// @Produce octet-stream +// @Param arch query string true "Target architecture" Enums(amd64, arm64) +// @Success 200 {file} binary +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /system/agent [get] +func (c *AgentController) DownloadAgent(ctx *gin.Context) { + arch := ctx.Query("arch") + if arch != "amd64" && arch != "arm64" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "arch must be amd64 or arm64"}) + return + } + + binaryName := "databasus-agent-linux-" + arch + binaryPath := filepath.Join("agent-binaries", binaryName) + + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + ctx.JSON( + http.StatusNotFound, + gin.H{"error": "agent binary not found for architecture: " + arch}, + ) + return + } + + ctx.Header("Content-Type", "application/octet-stream") + ctx.Header("Content-Disposition", "attachment; filename=databasus-agent") + ctx.File(binaryPath) +} diff --git a/backend/internal/features/system/agent/di.go b/backend/internal/features/system/agent/di.go new file mode 100644 index 0000000..9145f19 --- /dev/null +++ b/backend/internal/features/system/agent/di.go @@ -0,0 +1,7 @@ +package system_agent + +var agentController = &AgentController{} + +func GetAgentController() *AgentController { + return agentController +}