| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761 |
- package storage
- /*
- ArozOS Cluster Storage (ACS)
- The storage service turns the metadata index into a usable file system:
- - volumes: folders on local drives that this node contributes
- - transfer: signed, chunked (4 MiB, SHA-256 verified) moves of whole files
- between nodes, sized for Cloudflare's request limits
- - placement: which volume receives a new file
- - reads and writes for the cluster:/ drive (mod/filesystem/abstractions/clusterfs)
- - reconcile: rescans of local volumes so real files stay authoritative
- Files are never split across nodes; a file is one ordinary file on one or
- more volumes.
- */
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "sync"
- "time"
- uuid "github.com/satori/go.uuid"
- "imuslab.com/arozos/mod/cluster/membership"
- "imuslab.com/arozos/mod/cluster/metadata"
- "imuslab.com/arozos/mod/cluster/scheduling"
- "imuslab.com/arozos/mod/info/logger"
- )
- const (
- // ChunkSize is the transfer unit between nodes.
- ChunkSize = 4 << 20
- // MaxChunkSize bounds a single read request.
- MaxChunkSize = 8 << 20
- // SessionTTL is how long an unfinished upload session is kept.
- SessionTTL = 30 * time.Minute
- // placementHeadroom is kept free on a volume beyond the file size.
- placementHeadroom = 64 << 20
- )
- var (
- ErrNoVolume = errors.New("no cluster volume available; add one in System Settings > Cluster Settings > Storage")
- ErrNoHealthyCopy = errors.New("no healthy copy of this file is reachable right now")
- ErrNotLocalVolume = errors.New("volume is not on this node")
- ErrPathEscape = errors.New("path escapes the volume")
- ErrIsDirectory = errors.New("is a directory")
- ErrNotDirectory = errors.New("not a directory")
- ErrNotEmpty = errors.New("directory not empty")
- ErrChecksum = errors.New("checksum mismatch")
- )
- // Option configures the storage service.
- type Option struct {
- Membership *membership.Manager
- Metadata *metadata.Manager
- //Scheduler scores the nodes when choosing where a new file goes.
- Scheduler *scheduling.Manager
- TmpDir string
- // LocalRoots maps local (non network, non buffered) file system handler
- // UUIDs to their real root paths. Provided by the core.
- LocalRoots func() map[string]string
- // Thumbnailer renders the thumbnail of a file on one of this node's
- // volumes, given its OS path (thumbnail.go). Nil: this node renders none.
- Thumbnailer func(osPath string) ([]byte, error)
- RefreshInterval time.Duration
- ReconcileInterval time.Duration
- }
- // Service is the storage layer of this node.
- type Service struct {
- m *membership.Manager
- meta *metadata.Manager
- client *Client
- sched *scheduling.Manager
- opt Option
- sessMu sync.Mutex
- sessions map[string]*session
- //thumbSem bounds the thumbnails rendered at once
- thumbSem chan struct{}
- stop chan struct{}
- wg sync.WaitGroup
- once sync.Once
- //Hooks for the event bus (Phase 6); may be nil
- OnFileWritten func(rec metadata.FileRecord)
- OnFileRemoved func(rec metadata.FileRecord)
- OnFileRenamed func(oldPath string, rec metadata.FileRecord)
- //Replica hooks fired by PullCopy / VerifyLocalCopies
- OnReplicaVerified func(rec metadata.FileRecord, volumeID string)
- OnReplicaStale func(rec metadata.FileRecord, volumeID string)
- //OnDiskFull fires when a volume drops under the low water mark.
- OnDiskFull func(vol metadata.Volume)
- }
- // New creates the service and registers its node endpoints.
- func New(opt Option) (*Service, error) {
- if opt.Membership == nil || opt.Metadata == nil || opt.LocalRoots == nil {
- return nil, errors.New("membership, metadata and local roots are required")
- }
- if opt.TmpDir == "" {
- opt.TmpDir = os.TempDir()
- }
- if opt.RefreshInterval <= 0 {
- opt.RefreshInterval = 60 * time.Second
- }
- if opt.ReconcileInterval <= 0 {
- opt.ReconcileInterval = 30 * time.Minute
- }
- os.MkdirAll(filepath.Join(opt.TmpDir, "cluster"), 0755)
- if opt.Scheduler == nil {
- sc, err := scheduling.New(opt.Membership, opt.Metadata)
- if err != nil {
- return nil, err
- }
- opt.Scheduler = sc
- }
- s := &Service{
- m: opt.Membership,
- meta: opt.Metadata,
- sched: opt.Scheduler,
- client: &Client{Transport: opt.Membership.Transport()},
- opt: opt,
- sessions: map[string]*session{},
- thumbSem: make(chan struct{}, ThumbnailConcurrency),
- stop: make(chan struct{}),
- }
- s.registerACNHandlers()
- s.wg.Add(1)
- go s.maintenanceLoop()
- return s, nil
- }
- // Close stops background work.
- func (s *Service) Close() {
- s.once.Do(func() { close(s.stop) })
- s.wg.Wait()
- }
- // Ready reports whether the cluster:/ drive can serve requests.
- func (s *Service) Ready() bool {
- return s.m.InCluster()
- }
- func (s *Service) maintenanceLoop() {
- defer s.wg.Done()
- refresh := time.NewTicker(s.opt.RefreshInterval)
- reconcile := time.NewTicker(s.opt.ReconcileInterval)
- sweep := time.NewTicker(time.Minute)
- defer refresh.Stop()
- defer reconcile.Stop()
- defer sweep.Stop()
- //First reconcile shortly after boot picks up files already in contributed folders
- first := time.NewTimer(10 * time.Second)
- defer first.Stop()
- for {
- select {
- case <-s.stop:
- return
- case <-refresh.C:
- s.refreshVolumes()
- case <-first.C:
- s.ReconcileAll()
- case <-reconcile.C:
- s.ReconcileAll()
- case <-sweep.C:
- s.sweepSessions()
- }
- }
- }
- /*
- Placement
- */
- func (s *Service) nodeState(nodeID string) membership.NodeState {
- for _, n := range s.m.NodeViews() {
- if n.ID == nodeID {
- return n.State
- }
- }
- return membership.StateOffline
- }
- func usable(state membership.NodeState) bool {
- return state == membership.StateOnline || state == membership.StateDegraded
- }
- // pickVolume chooses the volume that should receive a new copy of size
- // bytes. Nodes are ranked by the cluster scorer (so a busy, distant or
- // nearly full node loses), preferNode and preferVolume win ties, and the
- // roomiest writable volume of the winning node is used.
- func (s *Service) pickVolume(size int64, preferNode string, preferVolume string, exclude []string) (*metadata.Volume, error) {
- need := size + size/10 + placementHeadroom
- excluded := map[string]bool{}
- for _, id := range exclude {
- excluded[id] = true
- }
- //Group the usable volumes by node
- byNode := map[string][]metadata.Volume{}
- for _, v := range s.meta.Volumes() {
- if v.Removed || v.ReadOnly || v.Evacuating || v.Free < need || excluded[v.ID] {
- continue
- }
- byNode[v.NodeID] = append(byNode[v.NodeID], v)
- }
- if len(byNode) == 0 {
- return nil, ErrNoVolume
- }
- candidates := []scheduling.Candidate{}
- for _, n := range s.m.NodeViews() {
- vols, ok := byNode[n.ID]
- if !ok {
- continue
- }
- best := 0.0
- for _, v := range vols {
- if v.Capacity > 0 {
- if f := float64(v.Free) / float64(v.Capacity); f > best {
- best = f
- }
- }
- }
- c := scheduling.Candidate{Node: n, FreeDisk: best, LatencyToData: -1, Diversity: -1}
- if n.ID == preferNode {
- c.Locality = 1 //writing where the bytes already are costs nothing
- }
- candidates = append(candidates, c)
- }
- ranked := s.sched.Rank(candidates, func(c scheduling.Candidate) (bool, string) {
- if !usable(c.Node.State) {
- return false, "node is " + strings.ToLower(string(c.Node.State))
- }
- return true, ""
- })
- nodeID, why := scheduling.Best(ranked)
- if nodeID == "" {
- if why != "" {
- //Keep the sentinel so callers can still test for it, but say
- //why the only candidate was turned down
- return nil, fmt.Errorf("%w (%s)", ErrNoVolume, why)
- }
- return nil, ErrNoVolume
- }
- vols := byNode[nodeID]
- sort.Slice(vols, func(i, j int) bool {
- if (vols[i].ID == preferVolume) != (vols[j].ID == preferVolume) {
- return vols[i].ID == preferVolume
- }
- if vols[i].Free != vols[j].Free {
- return vols[i].Free > vols[j].Free
- }
- return vols[i].ID < vols[j].ID
- })
- v := vols[0]
- return &v, nil
- }
- // PlaceRequest asks the leader where a new file should go.
- type PlaceRequest struct {
- Size int64 `json:"size"`
- Path string `json:"path"`
- PreferNode string `json:"preferNode"`
- PreferVolume string `json:"preferVolume"`
- ExcludeVolume string `json:"excludeVolume,omitempty"`
- }
- type PlaceResponse struct {
- VolumeID string `json:"volumeId"`
- }
- // place decides the volume for a write, consulting the leader when this
- // node is not the leader and the leader is reachable.
- func (s *Service) place(size int64, logical string, preferVolume string) (*metadata.Volume, error) {
- me := s.m.NodeID()
- if leader := s.meta.Leader(); leader != "" && leader != me {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- var resp PlaceResponse
- err := s.m.Transport().DoJSON(ctx, leader, http.MethodPost, pathPlace, PlaceRequest{Size: size, Path: logical, PreferNode: me, PreferVolume: preferVolume}, &resp)
- if err == nil && resp.VolumeID != "" {
- if v, ok := s.meta.Volume(resp.VolumeID); ok && !v.Removed {
- return v, nil
- }
- }
- }
- return s.pickVolume(size, me, preferVolume, nil)
- }
- /*
- Backend for the cluster:/ drive
- */
- // Info is the file-system view of a record.
- type Info struct {
- Path string
- IsDir bool
- Size int64
- ModTime int64
- }
- func infoOf(rec *metadata.FileRecord) Info {
- return Info{Path: rec.Path, IsDir: rec.IsDir, Size: rec.Size, ModTime: rec.ModTime}
- }
- // Stat returns the entry at a logical path.
- func (s *Service) Stat(logical string) (Info, error) {
- logical = metadata.NormalizePath(logical)
- if logical == "/" {
- return Info{Path: "/", IsDir: true, ModTime: time.Now().Unix()}, nil
- }
- rec, err := s.meta.Stat(logical)
- if err != nil {
- return Info{}, os.ErrNotExist
- }
- return infoOf(rec), nil
- }
- // List returns the children of a directory.
- func (s *Service) List(logical string) ([]Info, error) {
- logical = metadata.NormalizePath(logical)
- if logical != "/" {
- rec, err := s.meta.Stat(logical)
- if err != nil {
- return nil, os.ErrNotExist
- }
- if !rec.IsDir {
- return nil, ErrNotDirectory
- }
- }
- recs, err := s.meta.ListDir(logical)
- if err != nil {
- return nil, err
- }
- out := make([]Info, 0, len(recs))
- for i := range recs {
- out = append(out, infoOf(&recs[i]))
- }
- return out, nil
- }
- // ensureDirs creates directory records for every missing parent of logical.
- func (s *Service) ensureDirs(logical string, owner string) error {
- logical = metadata.NormalizePath(logical)
- if logical == "/" {
- return nil
- }
- parent := metadata.ParentPath(logical)
- if err := s.ensureDirs(parent, owner); err != nil {
- return err
- }
- if rec, err := s.meta.Stat(logical); err == nil {
- if !rec.IsDir {
- return ErrNotDirectory
- }
- return nil
- }
- return s.meta.Submit(metadata.KindFile, &metadata.FileRecord{
- ID: uuid.NewV4().String(),
- Path: logical,
- IsDir: true,
- ModTime: time.Now().Unix(),
- Owner: owner,
- })
- }
- // Mkdir creates a directory (and its parents) in the namespace.
- func (s *Service) Mkdir(logical string, owner string) error {
- if !s.Ready() {
- return metadata.ErrNotInCluster
- }
- logical = metadata.NormalizePath(logical)
- if rec, err := s.meta.Stat(logical); err == nil {
- if rec.IsDir {
- return nil
- }
- return metadata.ErrExists
- }
- return s.ensureDirs(logical, owner)
- }
- // Write stores a new version of a file: spool + hash locally, place, copy,
- // then publish the record.
- func (s *Service) Write(logical string, r io.Reader, owner string) error {
- if !s.Ready() {
- return metadata.ErrNotInCluster
- }
- logical = metadata.NormalizePath(logical)
- if logical == "/" {
- return ErrIsDirectory
- }
- if rec, err := s.meta.Stat(logical); err == nil && rec.IsDir {
- return ErrIsDirectory
- }
- if err := s.ensureDirs(metadata.ParentPath(logical), owner); err != nil {
- return err
- }
- //1. spool and hash
- spool, err := os.CreateTemp(filepath.Join(s.opt.TmpDir, "cluster"), "spool-*")
- if err != nil {
- return err
- }
- spoolPath := spool.Name()
- defer os.Remove(spoolPath)
- h := sha256.New()
- size, err := io.Copy(io.MultiWriter(spool, h), r)
- spool.Close()
- if err != nil {
- return err
- }
- checksum := hex.EncodeToString(h.Sum(nil))
- //2. placement (overwrite prefers the existing primary)
- preferVolume := ""
- existing, _ := s.meta.Stat(logical)
- if existing != nil {
- preferVolume = existing.Primary
- }
- vol, err := s.place(size, logical, preferVolume)
- if err != nil {
- return err
- }
- //3. copy bytes first: the namespace only ever shows committed files
- fileID := uuid.NewV4().String()
- if existing != nil {
- fileID = existing.ID
- }
- if vol.NodeID == s.m.NodeID() {
- err = s.writeLocalFile(vol, logical, spoolPath, size, checksum)
- } else {
- err = s.uploadRemote(vol, logical, fileID, spoolPath, size, checksum)
- }
- if err != nil {
- return err
- }
- //4. publish the record with the new copy as its only location
- rec := &metadata.FileRecord{ID: fileID, Path: logical, Owner: owner}
- if existing != nil {
- rec = existing.Clone()
- rec.Locations = nil
- }
- rec.IsDir = false
- rec.Size = size
- rec.Checksum = checksum
- rec.ModTime = time.Now().Unix()
- rec.Primary = vol.ID
- rec.SetLocation(metadata.Location{VolumeID: vol.ID, NodeID: vol.NodeID, State: metadata.LocCommitted, Checksum: checksum})
- if err := s.meta.Submit(metadata.KindFile, rec); err != nil {
- return err
- }
- if existing != nil {
- //Physically remove superseded copies on other volumes (best effort)
- for _, old := range existing.Locations {
- if old.VolumeID != vol.ID {
- s.deletePhysical(old, logical)
- }
- }
- }
- if s.OnFileWritten != nil {
- s.OnFileWritten(*rec)
- }
- return nil
- }
- func (s *Service) writeLocalFile(vol *metadata.Volume, logical string, spoolPath string, size int64, checksum string) error {
- real, err := s.realPath(vol, logical)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(real), 0755); err != nil {
- return err
- }
- part := real + ".part-" + uuid.NewV4().String()
- if err := copyFile(spoolPath, part); err != nil {
- os.Remove(part)
- return err
- }
- if err := os.Rename(part, real); err != nil {
- os.Remove(part)
- return err
- }
- return nil
- }
- func (s *Service) uploadRemote(vol *metadata.Volume, logical string, fileID string, spoolPath string, size int64, checksum string) error {
- f, err := os.Open(spoolPath)
- if err != nil {
- return err
- }
- defer f.Close()
- ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
- defer cancel()
- return s.client.Upload(ctx, vol.NodeID, vol.ID, logical, fileID, f, size, checksum, nil)
- }
- // OpenRead returns a stream of the file from the best available copy.
- func (s *Service) OpenRead(logical string) (io.ReadCloser, error) {
- if !s.Ready() {
- return nil, metadata.ErrNotInCluster
- }
- rec, err := s.meta.Stat(logical)
- if err != nil {
- return nil, os.ErrNotExist
- }
- if rec.IsDir {
- return nil, ErrIsDirectory
- }
- locs := s.orderedLocations(rec)
- if len(locs) == 0 {
- return nil, ErrNoHealthyCopy
- }
- var lastErr error = ErrNoHealthyCopy
- for _, loc := range locs {
- vol, ok := s.meta.Volume(loc.VolumeID)
- if !ok || vol.Removed {
- continue
- }
- if vol.NodeID == s.m.NodeID() {
- real, err := s.realPath(vol, rec.Path)
- if err != nil {
- lastErr = err
- continue
- }
- f, err := os.Open(real)
- if err != nil {
- lastErr = err
- continue
- }
- return f, nil
- }
- //Remote copy: stream through a pipe while verifying the checksum
- pr, pw := io.Pipe()
- go func(v metadata.Volume) {
- ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
- defer cancel()
- err := s.client.Download(ctx, v.NodeID, v.ID, rec.Path, pw, rec.Checksum, nil)
- pw.CloseWithError(err)
- }(*vol)
- return pr, nil
- }
- return nil, lastErr
- }
- // orderedLocations lists readable copies, local first, then online nodes in
- // membership order.
- func (s *Service) orderedLocations(rec *metadata.FileRecord) []metadata.Location {
- me := s.m.NodeID()
- out := []metadata.Location{}
- for _, l := range rec.HealthyLocations() {
- if l.NodeID == me {
- out = append(out, l)
- }
- }
- for _, l := range rec.HealthyLocations() {
- if l.NodeID != me && usable(s.nodeState(l.NodeID)) {
- out = append(out, l)
- }
- }
- return out
- }
- // Remove deletes a file or directory from the namespace and its copies.
- func (s *Service) Remove(logical string, recursive bool) error {
- if !s.Ready() {
- return metadata.ErrNotInCluster
- }
- logical = metadata.NormalizePath(logical)
- if logical == "/" {
- return errors.New("cannot remove the namespace root")
- }
- rec, err := s.meta.Stat(logical)
- if err != nil {
- return os.ErrNotExist
- }
- targets := []metadata.FileRecord{*rec}
- if rec.IsDir {
- children := s.meta.ListSubtree(logical)
- if len(children) > 0 && !recursive {
- return ErrNotEmpty
- }
- targets = append(targets, children...)
- }
- for i := range targets {
- t := targets[i].Clone()
- t.Removed = true
- if err := s.meta.Submit(metadata.KindFile, t); err != nil {
- return err
- }
- if !t.IsDir {
- for _, loc := range t.Locations {
- s.deletePhysical(loc, t.Path)
- }
- } else {
- s.removePhysicalDir(t.Path)
- }
- if s.OnFileRemoved != nil {
- s.OnFileRemoved(*t)
- }
- }
- return nil
- }
- // Rename moves a file or directory (and everything beneath it).
- func (s *Service) Rename(oldPath string, newPath string) error {
- if !s.Ready() {
- return metadata.ErrNotInCluster
- }
- oldPath = metadata.NormalizePath(oldPath)
- newPath = metadata.NormalizePath(newPath)
- if oldPath == "/" || newPath == "/" || oldPath == newPath {
- return errors.New("invalid rename")
- }
- rec, err := s.meta.Stat(oldPath)
- if err != nil {
- return os.ErrNotExist
- }
- if _, err := s.meta.Stat(newPath); err == nil {
- return metadata.ErrExists
- }
- if err := s.ensureDirs(metadata.ParentPath(newPath), rec.Owner); err != nil {
- return err
- }
- targets := []metadata.FileRecord{*rec}
- if rec.IsDir {
- targets = append(targets, s.meta.ListSubtree(oldPath)...)
- }
- for i := range targets {
- t := targets[i].Clone()
- from := t.Path
- t.Path = newPath + t.Path[len(oldPath):]
- if !t.IsDir {
- for _, loc := range t.Locations {
- if err := s.renamePhysical(loc, from, t.Path); err != nil {
- loc.State = metadata.LocStale
- t.SetLocation(loc)
- }
- }
- }
- if err := s.meta.Submit(metadata.KindFile, t); err != nil {
- return err
- }
- if s.OnFileRenamed != nil {
- s.OnFileRenamed(from, *t)
- }
- }
- s.removePhysicalDir(oldPath)
- return nil
- }
- /*
- Physical helpers (local or through the transfer client)
- */
- func (s *Service) deletePhysical(loc metadata.Location, logical string) {
- vol, ok := s.meta.Volume(loc.VolumeID)
- if !ok {
- return
- }
- if vol.NodeID == s.m.NodeID() {
- if real, err := s.realPath(vol, logical); err == nil {
- os.Remove(real)
- }
- return
- }
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- if err := s.client.Delete(ctx, vol.NodeID, vol.ID, logical, false); err != nil {
- logger.PrintAndLog("Cluster", "Unable to delete copy of "+logical+" on "+s.m.NodeName(vol.NodeID)+": "+err.Error(), nil)
- }
- }
- // removePhysicalDir removes now-empty directories on every volume (best effort).
- func (s *Service) removePhysicalDir(logical string) {
- for _, vol := range s.meta.Volumes() {
- if vol.Removed {
- continue
- }
- if vol.NodeID == s.m.NodeID() {
- if real, err := s.realPath(&vol, logical); err == nil {
- os.Remove(real) //fails when not empty, which is fine
- }
- continue
- }
- if usable(s.nodeState(vol.NodeID)) {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- s.client.Delete(ctx, vol.NodeID, vol.ID, logical, false)
- cancel()
- }
- }
- }
- func (s *Service) renamePhysical(loc metadata.Location, from string, to string) error {
- vol, ok := s.meta.Volume(loc.VolumeID)
- if !ok {
- return ErrNotLocalVolume
- }
- if vol.NodeID == s.m.NodeID() {
- src, err := s.realPath(vol, from)
- if err != nil {
- return err
- }
- dst, err := s.realPath(vol, to)
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
- return err
- }
- return os.Rename(src, dst)
- }
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- return s.client.Rename(ctx, vol.NodeID, vol.ID, from, to)
- }
- func copyFile(src string, dst string) error {
- in, err := os.Open(src)
- if err != nil {
- return err
- }
- defer in.Close()
- out, err := os.Create(dst)
- if err != nil {
- return err
- }
- if _, err := io.Copy(out, in); err != nil {
- out.Close()
- return err
- }
- return out.Close()
- }
- func fileChecksum(path string) (string, int64, error) {
- f, err := os.Open(path)
- if err != nil {
- return "", 0, err
- }
- defer f.Close()
- h := sha256.New()
- n, err := io.Copy(h, f)
- if err != nil {
- return "", 0, err
- }
- return hex.EncodeToString(h.Sum(nil)), n, nil
- }
|