An AUR (Arch User Repository) mirror service written in Go

initial commit

hailey.at df1e9f01

Changed files
+976
cmd
myaur
myaur
+5
.gitignore
··· 1 + # default path for aur clone 2 + aur-mirror/ 3 + # default path for db 4 + myaur.db 5 + myaur.db-journal
+111
cmd/myaur/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "os" 8 + 9 + "github.com/haileyok/myaur/myaur/populate" 10 + "github.com/haileyok/myaur/myaur/server" 11 + "github.com/urfave/cli/v2" 12 + ) 13 + 14 + func main() { 15 + app := cli.App{ 16 + Name: "myaur", 17 + Usage: "a AUR mirror service", 18 + Commands: cli.Commands{ 19 + &cli.Command{ 20 + Name: "populate", 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "database-path", 24 + Usage: "path to database file", 25 + Value: "./myaur.db", 26 + }, 27 + &cli.StringFlag{ 28 + Name: "repo-path", 29 + Usage: "path to store/update the AUR git mirror", 30 + Value: "./aur-mirror", 31 + }, 32 + &cli.BoolFlag{ 33 + Name: "debug", 34 + Usage: "flag to enable debug logs", 35 + }, 36 + &cli.IntFlag{ 37 + Name: "concurrency", 38 + Usage: "worker concurrency for parsing and adding packages to database", 39 + Value: 10, // TODO: is this a good default 40 + }, 41 + }, 42 + Action: func(cmd *cli.Context) error { 43 + ctx := context.Background() 44 + 45 + p, err := populate.New(&populate.Args{ 46 + DatabasePath: cmd.String("database-path"), 47 + RepoPath: cmd.String("repo-path"), 48 + Debug: cmd.Bool("debug"), 49 + Concurrency: cmd.Int("concurrency"), 50 + }) 51 + if err != nil { 52 + return fmt.Errorf("failed to create populate client: %w", err) 53 + } 54 + 55 + if err := p.Run(ctx); err != nil { 56 + return fmt.Errorf("failed to populate database: %w", err) 57 + } 58 + 59 + return nil 60 + }, 61 + }, 62 + &cli.Command{ 63 + Name: "serve", 64 + Flags: []cli.Flag{ 65 + &cli.StringFlag{ 66 + Name: "listen-addr", 67 + Usage: "address to listen on for the web service", 68 + Value: ":8080", 69 + }, 70 + &cli.StringFlag{ 71 + Name: "metrics-listen-addr", 72 + Usage: "metrics listen address", 73 + Value: ":8081", 74 + }, 75 + &cli.StringFlag{ 76 + Name: "database-path", 77 + Usage: "path to database file", 78 + Value: "./myaur.db", 79 + }, 80 + &cli.BoolFlag{ 81 + Name: "debug", 82 + Usage: "flag to enable debug logs", 83 + }, 84 + }, 85 + Action: func(cmd *cli.Context) error { 86 + ctx := context.Background() 87 + 88 + s, err := server.New(&server.Args{ 89 + Addr: cmd.String("listen-addr"), 90 + MetricsAddr: cmd.String("metrics-listen-addr"), 91 + DatabasePath: cmd.String("database-path"), 92 + Debug: cmd.Bool("debug"), 93 + }) 94 + if err != nil { 95 + return fmt.Errorf("failed to create new myaur server: %w", err) 96 + } 97 + 98 + if err := s.Serve(ctx); err != nil { 99 + return fmt.Errorf("failed to serve myaur server: %w", err) 100 + } 101 + 102 + return nil 103 + }, 104 + }, 105 + }, 106 + } 107 + 108 + if err := app.Run(os.Args); err != nil { 109 + log.Fatal(err) 110 + } 111 + }
+29
go.mod
··· 1 + module github.com/haileyok/myaur 2 + 3 + go 1.25.3 4 + 5 + require ( 6 + github.com/labstack/echo/v4 v4.13.4 7 + github.com/labstack/gommon v0.4.2 8 + github.com/urfave/cli/v2 v2.27.7 9 + golang.org/x/sync v0.14.0 10 + gorm.io/driver/sqlite v1.6.0 11 + gorm.io/gorm v1.31.0 12 + ) 13 + 14 + require ( 15 + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 16 + github.com/jinzhu/inflection v1.0.0 // indirect 17 + github.com/jinzhu/now v1.1.5 // indirect 18 + github.com/mattn/go-colorable v0.1.14 // indirect 19 + github.com/mattn/go-isatty v0.0.20 // indirect 20 + github.com/mattn/go-sqlite3 v1.14.22 // indirect 21 + github.com/russross/blackfriday/v2 v2.1.0 // indirect 22 + github.com/valyala/bytebufferpool v1.0.0 // indirect 23 + github.com/valyala/fasttemplate v1.2.2 // indirect 24 + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 25 + golang.org/x/crypto v0.38.0 // indirect 26 + golang.org/x/net v0.40.0 // indirect 27 + golang.org/x/sys v0.33.0 // indirect 28 + golang.org/x/text v0.25.0 // indirect 29 + )
+49
go.sum
··· 1 + github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 2 + github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 3 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 + github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 6 + github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 7 + github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 8 + github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 9 + github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= 10 + github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= 11 + github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 12 + github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 13 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 14 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 15 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 16 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 17 + github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 18 + github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 19 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 + github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 22 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 23 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 24 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 25 + github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= 26 + github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= 27 + github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 28 + github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 29 + github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 30 + github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 31 + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 32 + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 33 + golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 34 + golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 35 + golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 36 + golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 37 + golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 38 + golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 39 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 41 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 42 + golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 43 + golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 44 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 45 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 46 + gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= 47 + gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= 48 + gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= 49 + gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+100
myaur/database/database.go
··· 1 + package database 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "os" 7 + 8 + "gorm.io/driver/sqlite" 9 + "gorm.io/gorm" 10 + ) 11 + 12 + type Database struct { 13 + logger *slog.Logger 14 + db *gorm.DB 15 + } 16 + 17 + type Args struct { 18 + DatabasePath string 19 + Debug bool 20 + } 21 + 22 + func New(args *Args) (*Database, error) { 23 + level := slog.LevelInfo 24 + if args.Debug { 25 + level = slog.LevelDebug 26 + } 27 + 28 + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 29 + Level: level, 30 + })) 31 + 32 + logger = logger.With("component", "database") 33 + 34 + gormDb, err := gorm.Open(sqlite.Open(args.DatabasePath)) 35 + if err != nil { 36 + return nil, fmt.Errorf("failed to open database: %w", err) 37 + } 38 + 39 + if err := gormDb.AutoMigrate( 40 + &PackageInfo{}, 41 + ); err != nil { 42 + return nil, fmt.Errorf("failed to migrate db: %w", err) 43 + } 44 + 45 + db := Database{ 46 + logger: logger, 47 + db: gormDb, 48 + } 49 + 50 + return &db, nil 51 + } 52 + 53 + func (db *Database) UpsertPackage(pkg *PackageInfo) error { 54 + result := db.db.Where("name = ?", pkg.Name).FirstOrCreate(pkg) 55 + if result.Error != nil { 56 + return result.Error 57 + } 58 + 59 + if result.RowsAffected == 0 { 60 + if err := db.db.Model(pkg).Where("name = ?", pkg.Name).Updates(pkg).Error; err != nil { 61 + return err 62 + } 63 + } 64 + 65 + return nil 66 + } 67 + 68 + func (db *Database) GetPackageByName(name string) (*PackageInfo, error) { 69 + var pkg PackageInfo 70 + if err := db.db.Where("name = ?", name).First(&pkg).Error; err != nil { 71 + return nil, err 72 + } 73 + return &pkg, nil 74 + } 75 + 76 + func (db *Database) GetPackagesByName(name string) ([]PackageInfo, error) { 77 + var pkgs []PackageInfo 78 + searchTerm := "%" + name + "%" 79 + if err := db.db.Where("name LIKE ?", searchTerm).Find(&pkgs).Error; err != nil { 80 + return nil, err 81 + } 82 + return pkgs, nil 83 + } 84 + 85 + func (db *Database) GetPackageByDescriptionOrName(query string) (*PackageInfo, error) { 86 + var pkg PackageInfo 87 + if err := db.db.Where("name = ? OR description = ?", query, query).First(&pkg).Error; err != nil { 88 + return nil, err 89 + } 90 + return &pkg, nil 91 + } 92 + 93 + func (db *Database) GetPackagesByDescriptionOrName(query string) ([]PackageInfo, error) { 94 + var pkgs []PackageInfo 95 + searchTerm := "%" + query + "%" 96 + if err := db.db.Where("name LIKE ? OR description LIKE ?", searchTerm, searchTerm).Find(&pkgs).Error; err != nil { 97 + return nil, err 98 + } 99 + return pkgs, nil 100 + }
+56
myaur/database/models.go
··· 1 + package database 2 + 3 + import ( 4 + "database/sql/driver" 5 + "encoding/json" 6 + "fmt" 7 + ) 8 + 9 + type StringSlice []string 10 + 11 + func (s StringSlice) Value() (driver.Value, error) { 12 + if len(s) == 0 { 13 + return "[]", nil 14 + } 15 + return json.Marshal(s) 16 + } 17 + 18 + func (s *StringSlice) Scan(value any) error { 19 + if value == nil { 20 + *s = []string{} 21 + return nil 22 + } 23 + 24 + bytes, ok := value.([]byte) 25 + if !ok { 26 + return fmt.Errorf("failed to unmarshal StringSlice value: %v", value) 27 + } 28 + 29 + return json.Unmarshal(bytes, s) 30 + } 31 + 32 + type PackageInfo struct { 33 + Id int64 `gorm:"primaryKey;autoIncrement" json:"ID"` 34 + Name string `gorm:"uniqueIndex;not null" json:"Name"` 35 + PackageBaseID string `json:"PackageBaseID"` 36 + PackageBase string `gorm:"index" json:"PackageBase"` 37 + Version string `json:"Version"` 38 + Description string `gorm:"index:idx_description" json:"Description"` 39 + Url string `json:"URL"` 40 + NumVotes int64 `json:"NumVotes"` 41 + Popularity float64 `json:"Popularity"` 42 + OutOfDate *int64 `json:"OutOfDate"` 43 + Maintainer string `gorm:"index" json:"Maintainer"` 44 + FirstSubmitted int64 `json:"FirstSubmitted"` 45 + LastModified int64 `json:"LastModified"` 46 + UrlPath string `json:"URLPath"` 47 + Depends StringSlice `gorm:"type:text" json:"Depends"` 48 + MakeDepends StringSlice `gorm:"type:text" json:"MakeDepends"` 49 + License StringSlice `gorm:"type:text" json:"License"` 50 + Keywords StringSlice `gorm:"type:text" json:"Keywords"` 51 + } 52 + 53 + // set the tablename so gorm doesn't mess it up 54 + func (PackageInfo) TableName() string { 55 + return "package_info" 56 + }
+132
myaur/gitrepo/repo.go
··· 1 + package gitrepo 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "log/slog" 7 + "net/url" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + "strings" 12 + ) 13 + 14 + const ( 15 + DefaultAurRepoUrl = "https://github.com/archlinux/aur.git" 16 + ) 17 + 18 + type Repo struct { 19 + logger *slog.Logger 20 + repoPath string 21 + aurRepoUrl string 22 + } 23 + 24 + type Args struct { 25 + RepoPath string 26 + AurRepoUrl string 27 + Debug bool 28 + } 29 + 30 + func New(args *Args) (*Repo, error) { 31 + level := slog.LevelInfo 32 + if args.Debug { 33 + level = slog.LevelDebug 34 + } 35 + 36 + if args.RepoPath == "" { 37 + return nil, fmt.Errorf("must supply a valid `RepoPath`") 38 + } 39 + 40 + if args.AurRepoUrl == "" { 41 + args.AurRepoUrl = DefaultAurRepoUrl 42 + } 43 + 44 + if _, err := url.Parse(args.AurRepoUrl); err != nil { 45 + return nil, fmt.Errorf("failed to parse AUR repo url: %w", err) 46 + } 47 + 48 + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 49 + Level: level, 50 + })) 51 + 52 + logger = logger.With("component", "gitrepo", "aururl", args.AurRepoUrl) 53 + 54 + return &Repo{ 55 + logger: logger, 56 + repoPath: args.RepoPath, 57 + aurRepoUrl: args.AurRepoUrl, 58 + }, nil 59 + } 60 + 61 + func (r *Repo) EnsureRepo() error { 62 + if _, err := os.Stat(r.repoPath); os.IsNotExist(err) { 63 + r.logger.Info("aur repo does not exist, cloning...", "path", r.repoPath) 64 + return r.clone() 65 + } 66 + 67 + r.logger.Info("aur repo exists, fetching updates...", "path", r.repoPath) 68 + return r.fetch() 69 + } 70 + 71 + func (r *Repo) clone() error { 72 + cmd := exec.Command("git", "clone", "--mirror", r.aurRepoUrl, r.repoPath) 73 + cmd.Stdout = os.Stdout 74 + cmd.Stderr = os.Stderr 75 + 76 + if err := cmd.Run(); err != nil { 77 + return fmt.Errorf("failed to clone repo: %w", err) 78 + } 79 + 80 + r.logger.Info("repo cloned successfully") 81 + return nil 82 + } 83 + 84 + func (r *Repo) fetch() error { 85 + cmd := exec.Command("git", "-C", r.repoPath, "fetch", "--all", "--prune") 86 + cmd.Stdout = os.Stdout 87 + cmd.Stderr = os.Stderr 88 + 89 + if err := cmd.Run(); err != nil { 90 + return fmt.Errorf("failed to fetch updates: %w", err) 91 + } 92 + 93 + r.logger.Info("repo updated successfully") 94 + return nil 95 + } 96 + 97 + func (r *Repo) ListBranches() ([]string, error) { 98 + cmd := exec.Command("git", "-C", r.repoPath, "for-each-ref", "--format=%(refname:short)", "refs/heads/") 99 + output, err := cmd.Output() 100 + if err != nil { 101 + return nil, fmt.Errorf("failed to list branches: %w", err) 102 + } 103 + 104 + var branches []string 105 + scanner := bufio.NewScanner(strings.NewReader(string(output))) 106 + for scanner.Scan() { 107 + branch := strings.TrimSpace(scanner.Text()) 108 + if branch != "" { 109 + branches = append(branches, branch) 110 + } 111 + } 112 + 113 + if err := scanner.Err(); err != nil { 114 + return nil, fmt.Errorf("error scanning branch list: %w", err) 115 + } 116 + 117 + r.logger.Info("found branches", "count", len(branches)) 118 + return branches, nil 119 + } 120 + 121 + func (r *Repo) GetFileContent(branch, filePath string) (string, error) { 122 + ref := filepath.Join("refs/heads", branch) 123 + gitPath := fmt.Sprintf("%s:%s", ref, filePath) 124 + 125 + cmd := exec.Command("git", "-C", r.repoPath, "show", gitPath) 126 + output, err := cmd.Output() 127 + if err != nil { 128 + return "", fmt.Errorf("failed to get file content for branch %s: %w", branch, err) 129 + } 130 + 131 + return string(output), nil 132 + }
+154
myaur/populate/populate.go
··· 1 + package populate 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "os" 8 + "sync" 9 + "sync/atomic" 10 + 11 + "github.com/haileyok/myaur/myaur/database" 12 + "github.com/haileyok/myaur/myaur/gitrepo" 13 + "github.com/haileyok/myaur/myaur/srcinfo" 14 + "golang.org/x/sync/semaphore" 15 + ) 16 + 17 + type Populate struct { 18 + logger *slog.Logger 19 + repo *gitrepo.Repo 20 + db *database.Database 21 + sem *semaphore.Weighted 22 + } 23 + 24 + type Args struct { 25 + DatabasePath string 26 + RepoPath string 27 + Debug bool 28 + Concurrency int 29 + } 30 + 31 + func New(args *Args) (*Populate, error) { 32 + level := slog.LevelInfo 33 + if args.Debug { 34 + level = slog.LevelDebug 35 + } 36 + 37 + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 38 + Level: level, 39 + })) 40 + 41 + logger = logger.With("component", "populate") 42 + 43 + if args.Concurrency == 0 { 44 + // TODO: good default? idk 45 + args.Concurrency = 10 46 + } 47 + 48 + repo, err := gitrepo.New(&gitrepo.Args{ 49 + RepoPath: args.RepoPath, 50 + Debug: args.Debug, 51 + }) 52 + if err != nil { 53 + return nil, fmt.Errorf("failed to create repo client: %w", err) 54 + } 55 + 56 + db, err := database.New(&database.Args{ 57 + DatabasePath: args.DatabasePath, 58 + Debug: args.Debug, 59 + }) 60 + if err != nil { 61 + return nil, fmt.Errorf("failed to create database client: %w", err) 62 + } 63 + 64 + sem := semaphore.NewWeighted(int64(args.Concurrency)) 65 + 66 + return &Populate{ 67 + logger: logger, 68 + repo: repo, 69 + db: db, 70 + sem: sem, 71 + }, nil 72 + } 73 + 74 + func (p *Populate) Run(ctx context.Context) error { 75 + p.logger.Info("starting populate process") 76 + 77 + // get the repo if we need to 78 + if err := p.repo.EnsureRepo(); err != nil { 79 + return fmt.Errorf("failed to ensure repository: %w", err) 80 + } 81 + 82 + // get all the branches that exist 83 + branches, err := p.repo.ListBranches() 84 + if err != nil { 85 + return fmt.Errorf("failed to list branches: %w", err) 86 + } 87 + 88 + p.logger.Info("processing branches", "total", len(branches)) 89 + 90 + return p.processBranches(ctx, branches) 91 + } 92 + 93 + func (p *Populate) processBranches(ctx context.Context, branches []string) error { 94 + var wg sync.WaitGroup 95 + 96 + var processed, succeeded, failed atomic.Int64 97 + 98 + logger := p.logger.With("component", "branch-processor") 99 + 100 + for _, b := range branches { 101 + if err := p.sem.Acquire(ctx, 1); err != nil { 102 + logger.Error("failed to acuiqre semaphore", "err", err) 103 + continue 104 + } 105 + 106 + wg.Add(1) 107 + go func() { 108 + defer func() { 109 + wg.Done() 110 + p.sem.Release(1) 111 + }() 112 + 113 + if err := p.processBranch(b); err != nil { 114 + logger.Error("failed to process branch", "branch", b, "err", err) 115 + failed.Add(1) 116 + } else { 117 + succeeded.Add(1) 118 + } 119 + processed.Add(1) 120 + 121 + logger.Info("progress", "processed", processed.Load(), "succeeded", succeeded.Load(), "failed", failed.Load(), "total", len(branches)) 122 + }() 123 + } 124 + 125 + wg.Wait() 126 + 127 + logger.Info("database populated successfully", "processed", processed.Load(), "succeeded", succeeded.Load(), "failed", failed.Load()) 128 + 129 + return nil 130 + } 131 + 132 + func (p *Populate) processBranch(branch string) error { 133 + content, err := p.repo.GetFileContent(branch, ".SRCINFO") 134 + if err != nil { 135 + return fmt.Errorf("failed to get .SRCINFO: %w", err) 136 + } 137 + 138 + pkg, err := srcinfo.Parse(content) 139 + if err != nil { 140 + return fmt.Errorf("failed to parse .SRCINFO: %w", err) 141 + } 142 + 143 + if pkg.PackageBase == "" { 144 + pkg.PackageBase = branch 145 + } 146 + 147 + if err := p.db.UpsertPackage(pkg); err != nil { 148 + return fmt.Errorf("failed to upsert package: %w", err) 149 + } 150 + 151 + p.logger.Debug("processed package", "name", pkg.Name, "version", pkg.Version) 152 + 153 + return nil 154 + }
+7
myaur/server/handle_get_info.go
··· 1 + package server 2 + 3 + import "github.com/labstack/echo/v4" 4 + 5 + func (s *Server) handleGetInfo(e echo.Context) error { 6 + return nil 7 + }
+100
myaur/server/handle_get_search.go
··· 1 + package server 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/haileyok/myaur/myaur/database" 7 + "github.com/labstack/echo/v4" 8 + ) 9 + 10 + var ( 11 + GetSearchInputByAllowedValues = map[string]struct{}{ 12 + "name": {}, 13 + "name-desc": {}, 14 + "maintainer": {}, 15 + "depends": {}, 16 + "makedepends": {}, 17 + "optdepends": {}, 18 + "checkdepends": {}, 19 + } 20 + ) 21 + 22 + type GetSearchInput struct { 23 + By string `query:"by"` 24 + } 25 + 26 + type GetSearchOutput struct { 27 + Version int `json:"version"` 28 + Type string `json:"type"` 29 + ResultCount int `json:"resultcount"` 30 + Results []database.PackageInfo `json:"results"` 31 + Error *string `json:"error,omitempty"` 32 + } 33 + 34 + // Depending on what the `by` parameter is, should receive one of the following as path: 35 + // `name`: search by package name 36 + // `name-desc`: search by package name and description 37 + // `maintainer`: search by maintainer name 38 + // `depends`: search for packages that depend on a keyword 39 + // `makedepends`: search for packages that makedepend on a keyword 40 + // `optdepends`: search for packages that optdepends on a keyword 41 + // `checkdepends`: search for packages that checkdepends on a keyword 42 + func (s *Server) handleGetSearch(e echo.Context) error { 43 + makeErrJson := func(error string) GetSearchOutput { 44 + return GetSearchOutput{ 45 + Version: 5, 46 + Type: "error", 47 + ResultCount: 0, 48 + Results: []database.PackageInfo{}, 49 + Error: &error, 50 + } 51 + } 52 + logger := s.logger.With("handler", "getSearch") 53 + 54 + var input GetSearchInput 55 + if err := e.Bind(&input); err != nil { 56 + logger.Error("failed to bind request", "err", err) 57 + return e.JSON(400, makeErrJson("Failed to bind request")) 58 + } 59 + 60 + logger = logger.With("input", input) 61 + 62 + if input.By != "" { 63 + if _, ok := GetSearchInputByAllowedValues[input.By]; !ok { 64 + logger.Error("invalid by supplied", "by", input.By) 65 + return e.JSON(400, makeErrJson("Invalid `by` supplied. Valid values are name, name-desc, maintainer, depends, optdepends, checkdepends")) 66 + } 67 + } else { 68 + input.By = "name" 69 + } 70 + 71 + termPts := strings.Split(e.Request().URL.Path, "/") 72 + term := termPts[len(termPts)-1] 73 + 74 + var pkgs []database.PackageInfo 75 + var err error 76 + switch input.By { 77 + case "name": 78 + pkgs, err = s.db.GetPackagesByName(term) 79 + case "name-desc": 80 + pkgs, err = s.db.GetPackagesByDescriptionOrName(term) 81 + default: 82 + return e.JSON(500, makeErrJson("Search method not implemented")) 83 + // case "maintainer": 84 + // case "depends": 85 + // case "makedepends": 86 + // case "optdepends": 87 + // case "checkdepends": 88 + } 89 + 90 + if err != nil { 91 + return e.JSON(500, makeErrJson("Error searching for packages")) 92 + } 93 + 94 + return e.JSON(200, GetSearchOutput{ 95 + Version: 5, 96 + Type: "search", 97 + ResultCount: len(pkgs), 98 + Results: pkgs, 99 + }) 100 + }
+163
myaur/server/server.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "os/signal" 10 + "syscall" 11 + "time" 12 + 13 + "github.com/haileyok/myaur/myaur/database" 14 + "github.com/labstack/echo/v4" 15 + "github.com/labstack/gommon/log" 16 + ) 17 + 18 + type Server struct { 19 + logger *slog.Logger 20 + echo *echo.Echo 21 + httpd *http.Server 22 + metricsHttpd *http.Server 23 + db *database.Database 24 + } 25 + 26 + type Args struct { 27 + Addr string 28 + MetricsAddr string 29 + DatabasePath string 30 + Debug bool 31 + } 32 + 33 + func New(args *Args) (*Server, error) { 34 + level := slog.LevelInfo 35 + if args.Debug { 36 + level = slog.LevelDebug 37 + } 38 + 39 + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 40 + Level: level, 41 + })) 42 + 43 + e := echo.New() 44 + 45 + httpd := http.Server{ 46 + Addr: args.Addr, 47 + Handler: e, 48 + } 49 + 50 + metricsHttpd := http.Server{ 51 + Addr: args.MetricsAddr, 52 + } 53 + 54 + db, err := database.New(&database.Args{ 55 + DatabasePath: args.DatabasePath, 56 + Debug: args.Debug, 57 + }) 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to create new database client: %w", err) 60 + } 61 + 62 + s := Server{ 63 + echo: e, 64 + httpd: &httpd, 65 + metricsHttpd: &metricsHttpd, 66 + db: db, 67 + logger: logger, 68 + } 69 + 70 + return &s, nil 71 + } 72 + 73 + func (s *Server) Serve(ctx context.Context) error { 74 + go func() { 75 + logger := s.logger.With("component", "metrics-httpd") 76 + 77 + go func() { 78 + if err := s.metricsHttpd.ListenAndServe(); err != http.ErrServerClosed { 79 + logger.Error("error listening", "err", err) 80 + } 81 + }() 82 + 83 + logger.Info("myaur metrics server listening", "addr", s.metricsHttpd.Addr) 84 + }() 85 + 86 + shutdownEcho := make(chan struct{}) 87 + echoShutdown := make(chan struct{}) 88 + go func() { 89 + logger := s.logger.With("component", "echo") 90 + 91 + logger.Info("adding routes...") 92 + s.addRoutes() 93 + logger.Info("routes added") 94 + 95 + go func() { 96 + if err := s.httpd.ListenAndServe(); err != http.ErrServerClosed { 97 + logger.Error("error listning", "err", err) 98 + close(shutdownEcho) 99 + } 100 + }() 101 + 102 + logger.Info("myaur api server listening", "addr", s.httpd.Addr) 103 + 104 + <-shutdownEcho 105 + 106 + logger.Info("shutting down myaur api server") 107 + 108 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 109 + defer func() { 110 + cancel() 111 + close(echoShutdown) 112 + }() 113 + 114 + if err := s.httpd.Shutdown(ctx); err != nil { 115 + logger.Error("failed to shutdown myaur api server", "err", err) 116 + return 117 + } 118 + 119 + log.Info("myaur api server shutdown") 120 + }() 121 + 122 + signals := make(chan os.Signal, 1) 123 + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) 124 + 125 + select { 126 + // if we receive a signal to shutdown, do so gracefully 127 + case sig := <-signals: 128 + s.logger.Info("shutting down on signal", "signal", sig) 129 + 130 + // close echo here since it shouldn't have been closed yet 131 + close(shutdownEcho) 132 + // if echo shutdowns unexepectdly, cleanup 133 + case <-echoShutdown: 134 + s.logger.Warn("echo shutdown unexpectedly") 135 + // echo should have already been closed 136 + } 137 + 138 + select { 139 + case <-echoShutdown: 140 + s.logger.Info("echo shutdown gracefully") 141 + case <-time.After(5 * time.Second): 142 + s.logger.Warn("echo did not shut down after five seconds. forcefully exiting.") 143 + } 144 + 145 + s.logger.Info("myaur shutdown") 146 + 147 + return nil 148 + } 149 + 150 + func (s *Server) addRoutes() { 151 + s.echo.GET("/rpc/v5/info", s.handleGetInfo) 152 + s.echo.GET("/rpc/v5/search", s.handleGetSearch) 153 + } 154 + 155 + func makeErrorJson(error string, message string) map[string]string { 156 + jsonMap := map[string]string{ 157 + "error": error, 158 + } 159 + if message != "" { 160 + jsonMap["message"] = message 161 + } 162 + return jsonMap 163 + }
+70
myaur/srcinfo/parser.go
··· 1 + package srcinfo 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/haileyok/myaur/myaur/database" 9 + ) 10 + 11 + func Parse(content string) (*database.PackageInfo, error) { 12 + pkg := &database.PackageInfo{} 13 + scanner := bufio.NewScanner(strings.NewReader(content)) 14 + 15 + // each looks like `key = val`. most of the lines will have whitespace infront of 16 + // them, so we remove that 17 + for scanner.Scan() { 18 + // grab the line, removing any whitespace 19 + line := strings.TrimSpace(scanner.Text()) 20 + 21 + // skip any empty lines or ones that are commented out 22 + if line == "" || strings.HasPrefix(line, "#") { 23 + continue 24 + } 25 + 26 + // we could probably split on ` = `, but i suppose its possible for these to have 27 + // any number of spaces infront of/after the `=`, so we'll be safe 28 + pts := strings.SplitN(line, "=", 2) 29 + if len(pts) != 2 { 30 + continue 31 + } 32 + 33 + // remove the extra whitespace 34 + key := strings.TrimSpace(pts[0]) 35 + value := strings.TrimSpace(pts[1]) 36 + 37 + switch key { 38 + case "pkgname": 39 + pkg.Name = value 40 + case "pkgbase": 41 + pkg.PackageBase = value 42 + case "pkgver": 43 + pkg.Version = value 44 + case "pkgrel": 45 + if pkg.Version != "" { 46 + pkg.Version = pkg.Version + "-" + value 47 + } 48 + case "pkgdesc": 49 + pkg.Description = value 50 + case "url": 51 + pkg.Url = value 52 + case "depends": 53 + pkg.Depends = append(pkg.Depends, value) 54 + case "makedepends": 55 + pkg.MakeDepends = append(pkg.MakeDepends, value) 56 + case "license": 57 + pkg.License = append(pkg.License, value) 58 + } 59 + } 60 + 61 + if err := scanner.Err(); err != nil { 62 + return nil, fmt.Errorf("error scanning srcinfo: %w", err) 63 + } 64 + 65 + if pkg.Name == "" { 66 + return nil, fmt.Errorf("missing required field: pkgname") 67 + } 68 + 69 + return pkg, nil 70 + }