clonepack/internal/core/snapshot.go
GnomeZworc c4776d81dd
add apt gestion
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-25 19:03:26 +02:00

334 lines
8.6 KiB
Go

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"
}
}