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

initial commit

hailey.at df1e9f01

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