diff --git a/.gitignore b/.gitignore index 0ac34b80b4848a123053d66e614d3c393d5c9e5e..52d743ae80bf51b424bb5e1668b15b530e2d46f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /secrets /horcrux* -/.horcrux diff --git a/.horcrux.jsonnet b/.horcrux.jsonnet index 2b77fd10e77ac5dbac37b9f121a4e9e1f3551b99..f8e8e3da552ed9ce4c5a2d197e72f21a7209a68a 100644 --- a/.horcrux.jsonnet +++ b/.horcrux.jsonnet @@ -1,14 +1,19 @@ +local hc = import 'horcrux.libsonnet'; local repo(name) = { - name: name, source: 'https://git.jolheiser.com/' + name + '.git', dest: [ - 'git@github.com:jolheiser/' + name, - 'git@tangled.sh:jolheiser.com/' + name, + { + forge: hc.GitHub('jolheiser', 'secrets/gh', name), + url: 'https://github.com/jolheiser/' + name + '.git', + }, + //{ + // forge: hc.Gitea('jolheiser', 'secrets/gt', name), + // url: 'https://gitea.com/jolheiser/' + name + '.git', + //}, ], }; { - key: '~/.ssh/horcrux', - interval: '15m', + interval: '1h', storage: '.horcrux', repos: [ repo('horcrux'), diff --git a/config.example.jsonnet b/config.example.jsonnet new file mode 100644 index 0000000000000000000000000000000000000000..be116a7901d079c6d8219dc8001ac761185315bc --- /dev/null +++ b/config.example.jsonnet @@ -0,0 +1,29 @@ +// Optionally import the jsonnet lib +local hc = import 'horcrux.libsonnet'; + +// Optional example of using jsonnet to remove some boilerplate +local repo(name) = { + source: 'https://git.jolheiser.com/' + name + '.git', + dest: [ + { + forge: hc.GitHub('jolheiser', 'secret', name), + url: 'https://github.com/jolheiser/' + name + '.git', + }, + { + forge: hc.Gitea('jolheiser', 'moreSecret', name), + url: 'https://gitea.com/jolheiser/' + name + '.git', + }, + ], +}; + +// Actual output config +{ + // https://pkg.go.dev/time#ParseDuration + interval: '1h', + storage: '.horcrux', + repos: [ + repo('horcrux'), + repo('ugit'), + repo('helix.drv'), + ], +} diff --git a/config.go b/config.go index e437dd2ff4c1819d253694b2b6d08d654b57beac..e9b61d01c4f9de69f7ba563050e5a4ecb40f8d86 100644 --- a/config.go +++ b/config.go @@ -6,16 +6,25 @@ "time" ) type Config struct { - Key string Interval Duration Storage string Repos []RepoConfig } type RepoConfig struct { - Name string Source string - Dest []string + Dest []DestConfig +} + +type DestConfig struct { + Forge DestForgeConfig + URL string +} + +type DestForgeConfig struct { + ForgeConfig + Name string + TokenFile string } type Duration time.Duration diff --git a/forge.go b/forge.go new file mode 100644 index 0000000000000000000000000000000000000000..6c4f40b96d7ff7e4857ab59b76ec37bbbd88aaed --- /dev/null +++ b/forge.go @@ -0,0 +1,361 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +var httpClient = &http.Client{ + Timeout: time.Second * 5, +} + +// Configuration for forge clients +type ForgeConfig struct { + Username string + Token string + RepoName string + Description string + Private bool + APIURL string +} + +// Common interface for all forge clients +type ForgeClient interface { + CheckRepoExists() (bool, error) + CreateRepo() error +} + +// GitHub implementation +type GitHubClient struct { + config ForgeConfig +} + +func NewGitHubClient(config ForgeConfig) *GitHubClient { + if config.APIURL == "" { + config.APIURL = "https://api.github.com" + } + return &GitHubClient{config: config} +} + +func (c *GitHubClient) CheckRepoExists() (bool, error) { + url := fmt.Sprintf("%s/repos/%s/%s", c.config.APIURL, c.config.Username, c.config.RepoName) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + req.Header.Set("Authorization", "token "+c.config.Token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return true, nil + } else if resp.StatusCode == 404 { + return false, nil + } + + body, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(body)) +} + +func (c *GitHubClient) CreateRepo() error { + url := fmt.Sprintf("%s/user/repos", c.config.APIURL) + + payload := map[string]interface{}{ + "name": c.config.RepoName, + "description": c.config.Description, + "private": c.config.Private, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+c.config.Token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == 201 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(body)) +} + +// GitLab implementation +type GitLabClient struct { + config ForgeConfig +} + +func NewGitLabClient(config ForgeConfig) *GitLabClient { + if config.APIURL == "" { + config.APIURL = "https://gitlab.com/api/v4" + } + return &GitLabClient{config: config} +} + +func (c *GitLabClient) CheckRepoExists() (bool, error) { + // URL encode the repo path for GitLab + encodedPath := strings.Replace(c.config.Username+"/"+c.config.RepoName, "/", "%2F", -1) + url := fmt.Sprintf("%s/projects/%s", c.config.APIURL, encodedPath) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + req.Header.Set("PRIVATE-TOKEN", c.config.Token) + + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return true, nil + } else if resp.StatusCode == 404 { + return false, nil + } + + body, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("GitLab API error: %d - %s", resp.StatusCode, string(body)) +} + +func (c *GitLabClient) CreateRepo() error { + url := fmt.Sprintf("%s/projects", c.config.APIURL) + + visibility := "public" + if c.config.Private { + visibility = "private" + } + + payload := map[string]interface{}{ + "name": c.config.RepoName, + "description": c.config.Description, + "path": c.config.RepoName, + "visibility": visibility, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return err + } + + req.Header.Set("PRIVATE-TOKEN", c.config.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == 201 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GitLab API error: %d - %s", resp.StatusCode, string(body)) +} + +// Gitea/Codeberg/Forgejo implementation +type GiteaClient struct { + config ForgeConfig +} + +func NewGiteaClient(config ForgeConfig) *GiteaClient { + // Default to Codeberg if no base URL provided + if config.APIURL == "" { + config.APIURL = "https://codeberg.org/api/v1" + } + return &GiteaClient{config: config} +} + +func (c *GiteaClient) CheckRepoExists() (bool, error) { + url := fmt.Sprintf("%s/repos/%s/%s", c.config.APIURL, c.config.Username, c.config.RepoName) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + req.Header.Set("Authorization", "token "+c.config.Token) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return true, nil + } else if resp.StatusCode == 404 { + return false, nil + } + + body, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("Gitea API error: %d - %s", resp.StatusCode, string(body)) +} + +func (c *GiteaClient) CreateRepo() error { + url := fmt.Sprintf("%s/user/repos", c.config.APIURL) + + payload := map[string]interface{}{ + "name": c.config.RepoName, + "description": c.config.Description, + "private": c.config.Private, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+c.config.Token) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == 201 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Gitea API error: %d - %s", resp.StatusCode, string(body)) +} + +// SourceHut implementation +type SourceHutClient struct { + config ForgeConfig +} + +func NewSourceHutClient(config ForgeConfig) *SourceHutClient { + if config.APIURL == "" { + config.APIURL = "https://git.sr.ht/api" + } + return &SourceHutClient{config: config} +} + +func (c *SourceHutClient) CheckRepoExists() (bool, error) { + url := fmt.Sprintf("%s/repos/%s/%s", c.config.APIURL, c.config.Username, c.config.RepoName) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + req.Header.Set("Authorization", "token "+c.config.Token) + + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return true, nil + } else if resp.StatusCode == 404 { + return false, nil + } + + body, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("SourceHut API error: %d - %s", resp.StatusCode, string(body)) +} + +func (c *SourceHutClient) CreateRepo() error { + url := fmt.Sprintf("%s/repos", c.config.APIURL) + + visibility := "public" + if c.config.Private { + visibility = "private" + } + + payload := map[string]interface{}{ + "name": c.config.RepoName, + "description": c.config.Description, + "visibility": visibility, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+c.config.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == 201 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("SourceHut API error: %d - %s", resp.StatusCode, string(body)) +} + +// Factory function to create the appropriate forge client +func NewForgeClient(forgeType string, config ForgeConfig) (ForgeClient, error) { + switch strings.ToLower(forgeType) { + case "github": + return NewGitHubClient(config), nil + case "gitlab": + return NewGitLabClient(config), nil + case "gitea", "codeberg", "forgejo": + return NewGiteaClient(config), nil + case "sourcehut", "srht": + return NewSourceHutClient(config), nil + default: + return nil, fmt.Errorf("unsupported forge type: %s", forgeType) + } +} diff --git a/git.go b/git.go new file mode 100644 index 0000000000000000000000000000000000000000..bb19bc2d99702f6108c3a8d1a3d44e31d9b63738 --- /dev/null +++ b/git.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "log/slog" + "net/url" + "os" + "os/exec" + "strings" +) + +func git(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + // cmd.Stdout = os.Stdout + // cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (r RepoConfig) Sync() error { + tmp, err := os.MkdirTemp(os.TempDir(), "horcrux-*") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + if err := git(os.TempDir(), "clone", "--mirror", r.Source, tmp); err != nil { + return err + } + for _, dest := range r.Dest { + token := dest.Forge.Token + if dest.Forge.TokenFile != "" { + tokenBytes, err := os.ReadFile(dest.Forge.TokenFile) + if err != nil { + slog.Error("could not read token file", slog.Any("err", err)) + continue + } + token = strings.TrimSpace(string(tokenBytes)) + } + client, err := NewForgeClient(dest.Forge.Name, ForgeConfig{ + Username: dest.Forge.Username, + Token: token, + RepoName: dest.Forge.RepoName, + Description: fmt.Sprintf("MIRROR of %s", r.Source), + APIURL: dest.Forge.APIURL, + }) + if err != nil { + return err + } + ok, err := client.CheckRepoExists() + if err != nil { + return err + } + if !ok { + if err := client.CreateRepo(); err != nil { + return err + } + } + u, err := url.Parse(dest.URL) + if err != nil { + return err + } + u.User = url.UserPassword(dest.Forge.Username, token) + if err := git(tmp, "remote", "add", "--mirror=push", dest.Forge.Name, u.String()); err != nil { + return err + } + if err := git(tmp, "push", "--mirror", "--force-with-lease", dest.Forge.Name); err != nil { + return err + } + } + return nil +} diff --git a/go.mod b/go.mod index 0d2afe6804077bf2b23fb3f2bcb26940daec56cd..7903471b8906299466e59e261d07995ea3e644c1 100644 --- a/go.mod +++ b/go.mod @@ -1,42 +1,32 @@ module horcrux -go 1.24.3 +go 1.23.3 require ( github.com/go-git/go-git/v5 v5.14.0 - github.com/google/go-jsonnet v0.21.0 + github.com/google/go-jsonnet v0.20.0 ) require ( - cuelang.org/go v0.14.1 // indirect dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.5 // indirect github.com/cloudflare/circl v1.6.0 // indirect - github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect - github.com/emicklei/proto v1.14.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect - github.com/protocolbuffers/txtpbfmt v0.0.0-20250627152318-f293424e46b5 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - go.jolheiser.com/cuesonnet v0.0.0-20250903112544-0914371465d2 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/yaml v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index 5fe173d62353c56c2abf3e00662fa584bd005f53..b34ccf0d13fecb4fc12ab9e1e4f91ce11b6fa989 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -cuelang.org/go v0.14.1 h1:kxFAHr7bvrCikbtVps2chPIARazVdnRmlz65dAzKyWg= -cuelang.org/go v0.14.1/go.mod h1:aSP9UZUM5m2izHAHUvqtq0wTlWn5oLjuv2iBMQZBLLs= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -13,8 +11,6 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= -github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -22,8 +18,6 @@ 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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= -github.com/emicklei/proto v1.14.2 h1:wJPxPy2Xifja9cEMrcA/g08art5+7CGJNFNk35iXC1I= -github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -38,15 +32,10 @@ github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-jsonnet v0.20.0 h1:WG4TTSARuV7bSm4PMB4ohjxe33IHT5WVTrJSU33uT4g= github.com/google/go-jsonnet v0.20.0/go.mod h1:VbgWF9JX7ztlv770x/TolZNGGFfiHEVx9G6ca2eUmeA= -github.com/google/go-jsonnet v0.21.0 h1:43Bk3K4zMRP/aAZm9Po2uSEjY6ALCkYUVIcz9HLGMvA= -github.com/google/go-jsonnet v0.21.0/go.mod h1:tCGAu8cpUpEZcdGMmdOu37nh8bGgqubhI5v2iSk3KJQ= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -58,20 +47,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/protocolbuffers/txtpbfmt v0.0.0-20250627152318-f293424e46b5 h1:WWs1ZFnGobK5ZXNu+N9If+8PDNVB9xAqrib/stUXsV4= -github.com/protocolbuffers/txtpbfmt v0.0.0-20250627152318-f293424e46b5/go.mod h1:BnHogPTyzYAReeQLZrOxyxzS739DaTNtTvohVdbENmA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -86,20 +69,14 @@ 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/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -go.jolheiser.com/cuesonnet v0.0.0-20250903112544-0914371465d2 h1:VEcbjVi23CbqvJQeRSTpWSmxJYplffXAy9THufKLJTA= -go.jolheiser.com/cuesonnet v0.0.0-20250903112544-0914371465d2/go.mod h1:zjccrkDZF5sm3QsBBPde0NTYCSOxLDCm7xwiE1Iturw= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -108,16 +85,12 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -132,5 +105,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/horcrux.libsonnet b/horcrux.libsonnet new file mode 100644 index 0000000000000000000000000000000000000000..324fcb42a2f3d3c98c0f9c5eccca8b30ada88407 --- /dev/null +++ b/horcrux.libsonnet @@ -0,0 +1,14 @@ +{ + Forge(name, username, tokenFile, repo, apiURL=''):: { + name: name, + username: username, + tokenFile: tokenFile, + repoName: repo, + apiURL: apiURL, + }, + GitHub(username, tokenFile, repo, apiURL='https://api.github.com'):: self.Forge('github', username, tokenFile, repo, apiURL), + GitLab(username, tokenFile, repo, apiURL='https://gitlab.com/api/v4'):: self.Forge('gitlab', username, tokenFile, repo, apiURL), + Gitea(username, tokenFile, repo, apiURL='https://gitea.com/api/v1'):: self.Forge('gitea', username, tokenFile, repo, apiURL), + SourceHut(username, tokenFile, repo, apiURL='https://git.sr.ht/api'):: self.Forge('sourcehut', username, tokenFile, repo, apiURL), + Codeberg(username, tokenFile, repo, apiURL='https://codeberg.org/api/v1'):: self.Forge('gitea', username, tokenFile, repo, apiURL), +} diff --git a/main.go b/main.go index 8bd5def46b511ba0cab9a77d0c73e52f3a2c853d..efb6409b847b7f459169bc23626932557e6d5750 100644 --- a/main.go +++ b/main.go @@ -1,23 +1,16 @@ package main import ( - "bytes" - _ "embed" - "errors" + "encoding/json" "flag" "fmt" "log/slog" "os" - "os/exec" "os/signal" - "path/filepath" "time" - "go.jolheiser.com/cuesonnet" + "github.com/google/go-jsonnet" ) - -//go:embed schema.cue -var schema cuesonnet.Schema func maine() error { fs := flag.NewFlagSet("horcrux", flag.ExitOnError) @@ -46,45 +39,24 @@ if err != nil { return fmt.Errorf("could not read config file: %w", err) } - var config Config - if err := schema.Decode(bytes.NewReader(cfg), &config); err != nil { - return fmt.Errorf("could not decode config file: %w", err) + vm := jsonnet.MakeVM() + cfgJSON, err := vm.EvaluateAnonymousSnippet(*configFlag, string(cfg)) + if err != nil { + return fmt.Errorf("could not evaluate jsonnet: %w", err) } - if err := os.MkdirAll(config.Storage, os.ModePerm); err != nil { - return fmt.Errorf("could not create storage repo at %q: %w", config.Storage, err) + var config Config + if err := json.Unmarshal([]byte(cfgJSON), &config); err != nil { + return fmt.Errorf("could not unmarshal JSON from config: %w", err) } - git := sshGit(config.Key) ticker := time.NewTicker(time.Duration(config.Interval)) go func() { for { slog.Debug("running sync...") for _, r := range config.Repos { - - // Check if we need to clone first - repoPath := filepath.Join(config.Storage, r.Name) - _, err := os.Stat(repoPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - if err := git(config.Storage, "clone", "--mirror", r.Source, r.Name); err != nil { - slog.Error("could not clone repo", slog.String("repo", r.Source), slog.Any("err", err)) - } - } else { - slog.Error("could not stat repo path", slog.Any("err", err)) - } - } - - // Update from remote - if err := git(repoPath, "remote", "update", "--prune"); err != nil { - slog.Error("could not update repo", slog.String("repo", r.Source), slog.Any("err", err)) - } - - // Push - for _, dest := range r.Dest { - if err := git(repoPath, "push", "--mirror", "--force", dest); err != nil { - slog.Error("could not push repo", slog.String("repo", r.Source), slog.String("dest", dest), slog.Any("err", err)) - } + if err := r.Sync(); err != nil { + slog.Error("could not sync repo", slog.String("repo", r.Source), slog.Any("err", err)) } } <-ticker.C @@ -96,17 +68,6 @@ signal.Notify(ch, os.Interrupt, os.Kill) <-ch return nil -} - -func sshGit(key string) func(string, ...string) error { - return func(dir string, args ...string) error { - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND=ssh -i %s`, key)) - // cmd.Stdout = os.Stdout - // cmd.Stderr = os.Stderr - return cmd.Run() - } } func main() { diff --git a/schema.cue b/schema.cue deleted file mode 100644 index 9baa6f81b6681ab45bc781925aede285a74d193e..0000000000000000000000000000000000000000 --- a/schema.cue +++ /dev/null @@ -1,16 +0,0 @@ -import "time" - -#Repo: { - name: string - source: string - dest: [...string] -} - -#Schema: { - key: string - interval: time.Duration | *"1h" - storage: string | *".horcrux" - repos: [...#Repo] -} - -#Schema