diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f9f2857 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: build test migrate-up migrate-down run + +build: + go build ./... + +test: + go test ./... + +run: + go run ./cmd/reanimator-api + +migrate-up: + go run ./cmd/reanimator-migrate --direction up + +migrate-down: + go run ./cmd/reanimator-migrate --direction down diff --git a/TODO.md b/TODO.md index ea006f3..0aac5f8 100644 --- a/TODO.md +++ b/TODO.md @@ -8,12 +8,12 @@ Status legend: `[ ] todo` `[-] in progress` `[x] done` `[!] blocked` **Goal:** initialize repository structure and baseline tooling. -- [ ] Initialize Go module (`go mod init`) -- [ ] Create folders: `cmd/reanimator-api`, `internal/{domain,repository,api,ingest,events,connectors,jobs}` -- [ ] Add app entrypoint (`cmd/reanimator-api/main.go`) -- [ ] Add config loading (env-based, minimal) -- [ ] Add migration framework and first migration folder -- [ ] Add Makefile targets (`build`, `test`, `migrate-up`, `migrate-down`) +- [x] Initialize Go module (`go mod init`) +- [x] Create folders: `cmd/reanimator-api`, `internal/{domain,repository,api,ingest,events,connectors,jobs}` +- [x] Add app entrypoint (`cmd/reanimator-api/main.go`) +- [x] Add config loading (env-based, minimal) +- [x] Add migration framework and first migration folder +- [x] Add Makefile targets (`build`, `test`, `migrate-up`, `migrate-down`) **Definition of done:** - `go build ./...` passes @@ -134,7 +134,6 @@ Status legend: `[ ] todo` `[-] in progress` `[x] done` `[!] blocked` Update this section each time work is merged. - Last update: 2026-02-04 -- Current milestone: Milestone 0 -- Overall progress: 0/6 milestones completed +- Current milestone: Milestone 1 +- Overall progress: 1/6 milestones completed - Active blockers: none - diff --git a/cmd/reanimator-api/main.go b/cmd/reanimator-api/main.go new file mode 100644 index 0000000..2ad3624 --- /dev/null +++ b/cmd/reanimator-api/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "reanimator/internal/api" + "reanimator/internal/config" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("load config: %v", err) + } + + srv := api.NewServer(cfg.HTTPAddr, cfg.ReadTimeout, cfg.WriteTimeout) + + errCh := make(chan error, 1) + go func() { + log.Printf("starting API server on %s", cfg.HTTPAddr) + errCh <- srv.Start() + }() + + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case sig := <-signalCh: + log.Printf("received signal %s, shutting down", sig) + case err := <-errCh: + if err == nil || errors.Is(err, http.ErrServerClosed) { + return + } + log.Fatalf("server error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("shutdown server: %v", err) + } + + // Ensure in-flight logs flush before exit in short-lived environments. + time.Sleep(25 * time.Millisecond) +} diff --git a/cmd/reanimator-migrate/main.go b/cmd/reanimator-migrate/main.go new file mode 100644 index 0000000..46e2c07 --- /dev/null +++ b/cmd/reanimator-migrate/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "flag" + "fmt" + "log" + + "reanimator/internal/config" + "reanimator/internal/repository/migrate" +) + +func main() { + directionFlag := flag.String("direction", string(migrate.Up), "migration direction: up|down") + dryRun := flag.Bool("dry-run", true, "print discovered migrations only") + flag.Parse() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("load config: %v", err) + } + + direction := migrate.Direction(*directionFlag) + if direction != migrate.Up && direction != migrate.Down { + log.Fatalf("invalid direction %q", *directionFlag) + } + + runner := migrate.Runner{Dir: cfg.MigrationsDir} + migrations, err := runner.Load(direction) + if err != nil { + log.Fatalf("load migrations: %v", err) + } + + for _, migration := range migrations { + fmt.Println(migration.Name) + } + + if *dryRun { + return + } + + log.Fatalf("execution mode is not implemented yet; keep dry-run=true for now") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..24130ad --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module reanimator + +go 1.25.6 diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..ef80135 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,40 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "time" +) + +type Server struct { + httpServer *http.Server +} + +func NewServer(addr string, readTimeout, writeTimeout time.Duration) *Server { + mux := http.NewServeMux() + mux.HandleFunc("/health", healthHandler) + + return &Server{ + httpServer: &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + }, + } +} + +func (s *Server) Start() error { + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +func healthHandler(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6f732e9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// Config holds process configuration loaded from environment variables. +type Config struct { + HTTPAddr string + ReadTimeout time.Duration + WriteTimeout time.Duration + ShutdownGrace time.Duration + DatabaseDSN string + MigrationsDir string +} + +func Load() (Config, error) { + readTimeout, err := envDuration("READ_TIMEOUT", 10*time.Second) + if err != nil { + return Config{}, err + } + + writeTimeout, err := envDuration("WRITE_TIMEOUT", 15*time.Second) + if err != nil { + return Config{}, err + } + + shutdownGrace, err := envDuration("SHUTDOWN_GRACE", 10*time.Second) + if err != nil { + return Config{}, err + } + + return Config{ + HTTPAddr: envOrDefault("HTTP_ADDR", ":9999"), + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + ShutdownGrace: shutdownGrace, + DatabaseDSN: os.Getenv("DATABASE_DSN"), + MigrationsDir: envOrDefault("MIGRATIONS_DIR", "migrations"), + }, nil +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func envDuration(key string, fallback time.Duration) (time.Duration, error) { + value := os.Getenv(key) + if value == "" { + return fallback, nil + } + + seconds, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s must be an integer number of seconds: %w", key, err) + } + if seconds <= 0 { + return 0, fmt.Errorf("%s must be > 0", key) + } + + return time.Duration(seconds) * time.Second, nil +} diff --git a/internal/connectors/.gitkeep b/internal/connectors/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/domain/.gitkeep b/internal/domain/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/events/.gitkeep b/internal/events/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/ingest/.gitkeep b/internal/ingest/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/jobs/.gitkeep b/internal/jobs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/repository/migrate/runner.go b/internal/repository/migrate/runner.go new file mode 100644 index 0000000..6422834 --- /dev/null +++ b/internal/repository/migrate/runner.go @@ -0,0 +1,77 @@ +package migrate + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +type Direction string + +const ( + Up Direction = "up" + Down Direction = "down" +) + +type Migration struct { + Name string + Path string + SQL string +} + +type Runner struct { + Dir string +} + +func (r Runner) Load(direction Direction) ([]Migration, error) { + var files []string + + err := filepath.WalkDir(r.Dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + base := filepath.Base(path) + extMatch := strings.HasSuffix(base, fmt.Sprintf(".%s.sql", direction)) + flatMatch := base == fmt.Sprintf("%s.sql", direction) + if extMatch || flatMatch { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, err + } + + sort.Strings(files) + if direction == Down { + reverse(files) + } + + migrations := make([]Migration, 0, len(files)) + for _, path := range files { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + migrations = append(migrations, Migration{ + Name: strings.TrimPrefix(path, r.Dir+string(filepath.Separator)), + Path: path, + SQL: string(raw), + }) + } + + return migrations, nil +} + +func reverse[T any](values []T) { + for i, j := 0, len(values)-1; i < j; i, j = i+1, j-1 { + values[i], values[j] = values[j], values[i] + } +} diff --git a/migrations/0001_init/down.sql b/migrations/0001_init/down.sql new file mode 100644 index 0000000..c6b4173 --- /dev/null +++ b/migrations/0001_init/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS schema_migrations; diff --git a/migrations/0001_init/up.sql b/migrations/0001_init/up.sql new file mode 100644 index 0000000..00102cd --- /dev/null +++ b/migrations/0001_init/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +);