- admin-protected client config (flag > AGENT_LIB_CONFIG > platform default), token used for basic auth only, redacted from all errors and files - work-repo pull via go-git (bare cache clone + fetch, remote re-pointed on config change), clean failure keeps previous state - inventory: own items + external areas from lockfile; MCP inventoried but never deployed - manifest v1 records name/type/origin/revision/content-hash per item, deterministic bytes; folder hashes over sorted relpath+filehash lines so tree and disk hashes agree - shared deploy package (atomic temp+rename, exec-bit preserving) now backs both curator materialization and client deployment - sync validates and hashes everything before the first mutation; second run is a byte-level no-op
140 lines
4.0 KiB
Go
140 lines
4.0 KiB
Go
package client
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/config"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
gittransport "github.com/go-git/go-git/v5/plumbing/transport"
|
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
|
|
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
|
)
|
|
|
|
// PullResult is one successful work-repo pull: the resolved revision plus
|
|
// read access to its commit tree.
|
|
type PullResult struct {
|
|
Rev string
|
|
Tree *gitsource.GitTree
|
|
}
|
|
|
|
// Pull fetches the work repository into the cache dir (bare clone, then
|
|
// fetch) and resolves ref — the default branch when empty. An unreachable
|
|
// repository yields a clean error before any deployment state is touched.
|
|
func Pull(cacheDir, url, ref, token, tokenUser string) (*PullResult, error) {
|
|
auth := basicAuth(token, tokenUser)
|
|
repo, err := openOrCreate(cacheDir, url, ref, auth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hash, err := resolveRemoteRef(repo, ref)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving %q in %s: %w", refLabel(ref), gitsource.RedactURL(url), err)
|
|
}
|
|
commit, err := repo.CommitObject(hash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading commit %s: %w", hash, err)
|
|
}
|
|
tree, err := commit.Tree()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading tree of %s: %w", hash, err)
|
|
}
|
|
return &PullResult{Rev: hash.String(), Tree: gitsource.NewGitTree(tree)}, nil
|
|
}
|
|
|
|
func basicAuth(token, tokenUser string) gittransport.AuthMethod {
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
user := tokenUser
|
|
if user == "" {
|
|
user = "agent-lib"
|
|
}
|
|
return &http.BasicAuth{Username: user, Password: token}
|
|
}
|
|
|
|
func openOrCreate(cacheDir, url, ref string, auth gittransport.AuthMethod) (*git.Repository, error) {
|
|
if _, err := os.Stat(filepath.Join(cacheDir, "HEAD")); err == nil {
|
|
repo, err := git.PlainOpen(cacheDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening cached work repo: %w", err)
|
|
}
|
|
if err := repointRemote(repo, url); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := repo.Fetch(&git.FetchOptions{Auth: auth}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
return nil, fmt.Errorf("fetching %s: %w", gitsource.RedactURL(url), err)
|
|
}
|
|
return repo, nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cacheDir), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
opts := &git.CloneOptions{URL: url, Auth: auth, NoCheckout: true}
|
|
if ref != "" {
|
|
opts.ReferenceName = plumbing.NewBranchReferenceName(ref)
|
|
}
|
|
repo, err := git.PlainClone(cacheDir, true, opts)
|
|
if err != nil {
|
|
os.RemoveAll(cacheDir)
|
|
return nil, fmt.Errorf("cloning %s: %w", gitsource.RedactURL(url), err)
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
// repointRemote aligns the cached origin with the configured URL so a fleet
|
|
// re-point takes effect on the next sync.
|
|
func repointRemote(repo *git.Repository, url string) error {
|
|
remote, err := repo.Remote(git.DefaultRemoteName)
|
|
if err != nil {
|
|
return fmt.Errorf("reading cached remote: %w", err)
|
|
}
|
|
if len(remote.Config().URLs) == 0 || remote.Config().URLs[0] == url {
|
|
return nil
|
|
}
|
|
if err := repo.DeleteRemote(git.DefaultRemoteName); err != nil {
|
|
return fmt.Errorf("replacing cached remote: %w", err)
|
|
}
|
|
_, err = repo.CreateRemote(&config.RemoteConfig{
|
|
Name: git.DefaultRemoteName,
|
|
URLs: []string{url},
|
|
Fetch: []config.RefSpec{
|
|
config.RefSpec("+refs/heads/*:refs/remotes/origin/*"),
|
|
},
|
|
})
|
|
return err
|
|
}
|
|
|
|
func resolveRemoteRef(repo *git.Repository, ref string) (plumbing.Hash, error) {
|
|
candidates := remoteRefCandidates(ref)
|
|
for _, name := range candidates {
|
|
if h, err := repo.ResolveRevision(plumbing.Revision(name)); err == nil {
|
|
return *h, nil
|
|
}
|
|
}
|
|
return plumbing.ZeroHash, fmt.Errorf("reference not found (tried %v)", candidates)
|
|
}
|
|
|
|
func remoteRefCandidates(ref string) []string {
|
|
if ref == "" {
|
|
return []string{"refs/remotes/origin/HEAD", "HEAD"}
|
|
}
|
|
return []string{
|
|
"refs/remotes/origin/" + ref,
|
|
"refs/tags/" + ref,
|
|
"refs/heads/" + ref,
|
|
ref,
|
|
}
|
|
}
|
|
|
|
func refLabel(ref string) string {
|
|
if ref == "" {
|
|
return "default branch"
|
|
}
|
|
return ref
|
|
}
|