package core import ( "bytes" "compress/gzip" "context" "encoding/json" "encoding/xml" "errors" "fmt" "net/http" "os" "path/filepath" "strings" "time" aptclone "github.com/syonad/clonepack/internal/clone/apt" rpmclone "github.com/syonad/clonepack/internal/clone/rpm" "github.com/syonad/clonepack/internal/store" ) type SnapshotService struct { snapshotStore store.SnapshotStore repoStore store.RepoStore dataDir string httpClient *http.Client } func NewSnapshotService( snapshotStore store.SnapshotStore, repoStore store.RepoStore, dataDir string, ) *SnapshotService { return &SnapshotService{ snapshotStore: snapshotStore, repoStore: repoStore, dataDir: dataDir, httpClient: &http.Client{Timeout: 30 * time.Minute}, } } type SnapshotDiff struct { From *store.Snapshot To *store.Snapshot Added []store.SnapshotPackage Removed []store.SnapshotPackage Unchanged int } func (s *SnapshotService) TakeSnapshot(ctx context.Context, repoID int64, label string) (int64, error) { repo, err := s.repoStore.GetRepo(ctx, repoID) if err != nil { return 0, err } localDir := filepath.Join(s.dataDir, "repos", fmt.Sprintf("%d", repoID), repo.Type) var pkgs []store.SnapshotPackage switch repo.Type { case "rpm": pkgs, err = s.snapshotPackagesRPM(localDir) case "apt": pkgs, err = s.snapshotPackagesAPT(localDir, repo.Config) default: return 0, fmt.Errorf("snapshots not supported for repo type %q", repo.Type) } if err != nil { return 0, err } snapID, err := s.snapshotStore.CreateSnapshot(ctx, repoID, label) if err != nil { return 0, err } if len(pkgs) > 0 { if err := s.snapshotStore.AddSnapshotPackages(ctx, snapID, pkgs); err != nil { return 0, err } } return snapID, nil } func (s *SnapshotService) snapshotPackagesRPM(localDir string) ([]store.SnapshotPackage, error) { repomdData, err := os.ReadFile(filepath.Join(localDir, "repodata", "repomd.xml")) if err != nil { return nil, fmt.Errorf("read repomd.xml: %w", err) } var repomd rpmclone.RepoMD if err := xml.Unmarshal(repomdData, &repomd); err != nil { return nil, fmt.Errorf("parse repomd.xml: %w", err) } var primaryHref string for _, entry := range repomd.Data { if entry.Type == "primary" { primaryHref = entry.Location.Href break } } if primaryHref == "" { return nil, fmt.Errorf("no primary entry in repomd.xml") } gzData, err := os.ReadFile(filepath.Join(localDir, filepath.FromSlash(primaryHref))) if err != nil { return nil, fmt.Errorf("read primary.xml.gz: %w", err) } gz, err := gzip.NewReader(bytes.NewReader(gzData)) if err != nil { return nil, fmt.Errorf("open gzip: %w", err) } defer gz.Close() var primary rpmclone.PrimaryMetadata if err := xml.NewDecoder(gz).Decode(&primary); err != nil { return nil, fmt.Errorf("parse primary.xml: %w", err) } pkgs := make([]store.SnapshotPackage, len(primary.Packages)) for i, p := range primary.Packages { pkgs[i] = store.SnapshotPackage{ Name: p.Name, Version: p.Version.Ver + "-" + p.Version.Rel, Arch: p.Arch, Location: p.Location.Href, Checksum: p.Checksum.Value, ChecksumType: p.Checksum.Type, Size: p.Size.Package, } } return pkgs, nil } func (s *SnapshotService) snapshotPackagesAPT(localDir, configJSON string) ([]store.SnapshotPackage, error) { var cfg aptclone.Config if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { return nil, fmt.Errorf("parse apt config: %w", err) } seen := make(map[string]struct{}) var pkgs []store.SnapshotPackage for _, component := range cfg.Components { for _, arch := range cfg.Architectures { pkgsPath := filepath.Join(localDir, "dists", cfg.Suite, component, "binary-"+arch, "Packages") data, err := os.ReadFile(pkgsPath) if err != nil { // Not yet populated — skip silently. continue } debs, err := aptclone.ParsePackages(data) if err != nil { return nil, fmt.Errorf("parse Packages for %s/%s: %w", component, arch, err) } for _, d := range debs { if _, dup := seen[d.Filename]; dup { continue } seen[d.Filename] = struct{}{} pkgs = append(pkgs, store.SnapshotPackage{ Name: d.Package, Version: d.Version, Arch: d.Architecture, Location: d.Filename, Checksum: d.SHA256, ChecksumType: "sha256", Size: d.Size, }) } } } return pkgs, nil } func (s *SnapshotService) List(ctx context.Context, repoID int64) ([]store.Snapshot, error) { if _, err := s.repoStore.GetRepo(ctx, repoID); err != nil { return nil, err } return s.snapshotStore.ListSnapshots(ctx, repoID) } func (s *SnapshotService) Get(ctx context.Context, repoID, id int64) (*store.Snapshot, []store.SnapshotPackage, error) { snap, err := s.snapshotStore.GetSnapshot(ctx, id) if err != nil { return nil, nil, err } if snap.RepoID != repoID { return nil, nil, store.ErrNotFound } pkgs, err := s.snapshotStore.GetSnapshotPackages(ctx, id) if err != nil { return nil, nil, err } return snap, pkgs, nil } func (s *SnapshotService) Delete(ctx context.Context, repoID, id int64) error { snap, err := s.snapshotStore.GetSnapshot(ctx, id) if err != nil { return err } if snap.RepoID != repoID { return store.ErrNotFound } return s.snapshotStore.DeleteSnapshot(ctx, id) } func (s *SnapshotService) Diff(ctx context.Context, repoID, fromID, toID int64) (*SnapshotDiff, error) { from, err := s.snapshotStore.GetSnapshot(ctx, fromID) if err != nil { return nil, err } to, err := s.snapshotStore.GetSnapshot(ctx, toID) if err != nil { return nil, err } if from.RepoID != repoID || to.RepoID != repoID { return nil, fmt.Errorf("snapshots do not belong to repo %d", repoID) } fromPkgs, err := s.snapshotStore.GetSnapshotPackages(ctx, fromID) if err != nil { return nil, err } toPkgs, err := s.snapshotStore.GetSnapshotPackages(ctx, toID) if err != nil { return nil, err } fromSet := make(map[string]struct{}, len(fromPkgs)) for _, p := range fromPkgs { fromSet[p.Name+"|"+p.Version+"|"+p.Arch] = struct{}{} } toSet := make(map[string]struct{}, len(toPkgs)) for _, p := range toPkgs { toSet[p.Name+"|"+p.Version+"|"+p.Arch] = struct{}{} } diff := &SnapshotDiff{From: from, To: to} for _, p := range toPkgs { if _, ok := fromSet[p.Name+"|"+p.Version+"|"+p.Arch]; ok { diff.Unchanged++ } else { diff.Added = append(diff.Added, p) } } for _, p := range fromPkgs { if _, ok := toSet[p.Name+"|"+p.Version+"|"+p.Arch]; !ok { diff.Removed = append(diff.Removed, p) } } return diff, nil } func (s *SnapshotService) Rollback(ctx context.Context, repoID, snapshotID int64) error { repo, err := s.repoStore.GetRepo(ctx, repoID) if err != nil { return err } snap, err := s.snapshotStore.GetSnapshot(ctx, snapshotID) if err != nil { return err } if snap.RepoID != repoID { return store.ErrNotFound } pkgs, err := s.snapshotStore.GetSnapshotPackages(ctx, snapshotID) if err != nil { return err } localDir := filepath.Join(s.dataDir, "repos", fmt.Sprintf("%d", repoID), repo.Type) snapSet := make(map[string]store.SnapshotPackage, len(pkgs)) for _, p := range pkgs { snapSet[p.Location] = p } ext := packageExt(repo.Type) var errs []error if err := filepath.WalkDir(localDir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() || filepath.Ext(path) != ext { return err } rel, _ := filepath.Rel(localDir, path) rel = filepath.ToSlash(rel) if _, ok := snapSet[rel]; !ok { if rmErr := os.Remove(path); rmErr != nil { errs = append(errs, rmErr) } } return nil }); err != nil { errs = append(errs, err) } for _, p := range pkgs { dest := filepath.Join(localDir, filepath.FromSlash(p.Location)) if _, err := os.Stat(dest); err == nil { continue } url := strings.TrimRight(repo.SourceURL, "/") + "/" + p.Location if _, dlErr := rpmclone.DownloadAndVerify(ctx, s.httpClient, url, dest, p.ChecksumType, p.Checksum); dlErr != nil { errs = append(errs, fmt.Errorf("re-download %s: %w", p.Name, dlErr)) } } if err := errors.Join(errs...); err != nil { return err } switch repo.Type { case "rpm": if err := rpmclone.RegenerateMetadata(localDir); err != nil { return fmt.Errorf("regenerate metadata: %w", err) } case "apt": var cfg aptclone.Config if err := json.Unmarshal([]byte(repo.Config), &cfg); err != nil { return fmt.Errorf("parse apt config: %w", err) } if err := aptclone.RegenerateMetadata(localDir, cfg); err != nil { return fmt.Errorf("regenerate metadata: %w", err) } } return nil } func packageExt(repoType string) string { switch repoType { case "apt": return ".deb" default: return ".rpm" } }