-
Notifications
You must be signed in to change notification settings - Fork 5.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
watch: add tar sync implementation #10853
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,344 @@ | ||
/* | ||
Copyright 2018 The Tilt Dev Authors | ||
Copyright 2023 Docker Compose CLI authors | ||
|
||
Licensed under the Apache License, Version 2.0 (the "License"); | ||
you may not use this file except in compliance with the License. | ||
You may obtain a copy of the License at | ||
|
||
http://www.apache.org/licenses/LICENSE-2.0 | ||
|
||
Unless required by applicable law or agreed to in writing, software | ||
distributed under the License is distributed on an "AS IS" BASIS, | ||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
See the License for the specific language governing permissions and | ||
limitations under the License. | ||
*/ | ||
|
||
package sync | ||
|
||
import ( | ||
"archive/tar" | ||
"bytes" | ||
"context" | ||
"fmt" | ||
"io" | ||
"io/fs" | ||
"os" | ||
"path" | ||
"path/filepath" | ||
"strings" | ||
|
||
"github.com/hashicorp/go-multierror" | ||
"github.com/pkg/errors" | ||
|
||
"github.com/compose-spec/compose-go/types" | ||
moby "github.com/docker/docker/api/types" | ||
"github.com/docker/docker/pkg/archive" | ||
) | ||
|
||
type archiveEntry struct { | ||
path string | ||
info os.FileInfo | ||
header *tar.Header | ||
} | ||
|
||
type LowLevelClient interface { | ||
ContainersForService(ctx context.Context, projectName string, serviceName string) ([]moby.Container, error) | ||
|
||
Exec(ctx context.Context, containerID string, cmd []string, in io.Reader) error | ||
} | ||
|
||
type Tar struct { | ||
client LowLevelClient | ||
|
||
projectName string | ||
} | ||
|
||
var _ Syncer = &Tar{} | ||
|
||
func NewTar(projectName string, client LowLevelClient) *Tar { | ||
return &Tar{ | ||
projectName: projectName, | ||
client: client, | ||
} | ||
} | ||
|
||
func (t *Tar) Sync(ctx context.Context, service types.ServiceConfig, paths []PathMapping) error { | ||
containers, err := t.client.ContainersForService(ctx, t.projectName, service.Name) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
var pathsToCopy []PathMapping | ||
var pathsToDelete []string | ||
for _, p := range paths { | ||
if _, err := os.Stat(p.HostPath); err != nil && errors.Is(err, fs.ErrNotExist) { | ||
pathsToDelete = append(pathsToDelete, p.ContainerPath) | ||
} else { | ||
pathsToCopy = append(pathsToCopy, p) | ||
} | ||
} | ||
|
||
// TODO: this can't be read from multiple times | ||
tarReader := tarArchive(pathsToCopy) | ||
|
||
var deleteCmd []string | ||
if len(pathsToDelete) != 0 { | ||
deleteCmd = append([]string{"rm", "-rf"}, pathsToDelete...) | ||
} | ||
copyCmd := []string{"tar", "-v", "-C", "/", "-x", "-f", "-"} | ||
|
||
var eg multierror.Group | ||
for i := range containers { | ||
containerID := containers[i].ID | ||
eg.Go(func() error { | ||
if len(deleteCmd) != 0 { | ||
if err := t.client.Exec(ctx, containerID, deleteCmd, nil); err != nil { | ||
return fmt.Errorf("deleting paths in %s: %w", containerID, err) | ||
} | ||
} | ||
if err := t.client.Exec(ctx, containerID, copyCmd, tarReader); err != nil { | ||
return fmt.Errorf("copying files to %s: %w", containerID, err) | ||
} | ||
return nil | ||
}) | ||
} | ||
return eg.Wait().ErrorOrNil() | ||
} | ||
|
||
type ArchiveBuilder struct { | ||
tw *tar.Writer | ||
paths []string // local paths archived | ||
|
||
// A shared I/O buffer to help with file copying. | ||
copyBuf *bytes.Buffer | ||
} | ||
|
||
func NewArchiveBuilder(writer io.Writer) *ArchiveBuilder { | ||
tw := tar.NewWriter(writer) | ||
return &ArchiveBuilder{ | ||
tw: tw, | ||
copyBuf: &bytes.Buffer{}, | ||
} | ||
} | ||
|
||
func (a *ArchiveBuilder) Close() error { | ||
return a.tw.Close() | ||
} | ||
|
||
// ArchivePathsIfExist creates a tar archive of all local files in `paths`. It quietly skips any paths that don't exist. | ||
func (a *ArchiveBuilder) ArchivePathsIfExist(paths []PathMapping) error { | ||
// In order to handle overlapping syncs, we | ||
// 1) collect all the entries, | ||
// 2) de-dupe them, with last-one-wins semantics | ||
// 3) write all the entries | ||
// | ||
// It's not obvious that this is the correct behavior. A better approach | ||
// (that's more in-line with how syncs work) might ignore files in earlier | ||
// path mappings when we know they're going to be "synced" over. | ||
// There's a bunch of subtle product decisions about how overlapping path | ||
// mappings work that we're not sure about. | ||
var entries []archiveEntry | ||
for _, p := range paths { | ||
newEntries, err := a.entriesForPath(p.HostPath, p.ContainerPath) | ||
if err != nil { | ||
return fmt.Errorf("inspecting %q: %w", p.HostPath, err) | ||
} | ||
|
||
entries = append(entries, newEntries...) | ||
} | ||
|
||
entries = dedupeEntries(entries) | ||
for _, entry := range entries { | ||
err := a.writeEntry(entry) | ||
if err != nil { | ||
return fmt.Errorf("archiving %q: %w", entry.path, err) | ||
} | ||
a.paths = append(a.paths, entry.path) | ||
} | ||
return nil | ||
} | ||
|
||
func (a *ArchiveBuilder) writeEntry(entry archiveEntry) error { | ||
pathInTar := entry.path | ||
header := entry.header | ||
|
||
if header.Typeflag != tar.TypeReg { | ||
// anything other than a regular file (e.g. dir, symlink) just needs the header | ||
if err := a.tw.WriteHeader(header); err != nil { | ||
return fmt.Errorf("writing %q header: %w", pathInTar, err) | ||
} | ||
return nil | ||
} | ||
|
||
file, err := os.Open(pathInTar) | ||
if err != nil { | ||
// In case the file has been deleted since we last looked at it. | ||
if os.IsNotExist(err) { | ||
return nil | ||
} | ||
return err | ||
} | ||
|
||
defer func() { | ||
_ = file.Close() | ||
}() | ||
|
||
// The size header must match the number of contents bytes. | ||
// | ||
// There is room for a race condition here if something writes to the file | ||
// after we've read the file size. | ||
// | ||
// For small files, we avoid this by first copying the file into a buffer, | ||
// and using the size of the buffer to populate the header. | ||
// | ||
// For larger files, we don't want to copy the whole thing into a buffer, | ||
// because that would blow up heap size. There is some danger that this | ||
// will lead to a spurious error when the tar writer validates the sizes. | ||
// That error will be disruptive but will be handled as best as we | ||
// can downstream. | ||
useBuf := header.Size < 5000000 | ||
if useBuf { | ||
a.copyBuf.Reset() | ||
_, err = io.Copy(a.copyBuf, file) | ||
if err != nil && err != io.EOF { | ||
return fmt.Errorf("copying %q: %w", pathInTar, err) | ||
} | ||
header.Size = int64(len(a.copyBuf.Bytes())) | ||
} | ||
|
||
// wait to write the header until _after_ the file is successfully opened | ||
// to avoid generating an invalid tar entry that has a header but no contents | ||
// in the case the file has been deleted | ||
err = a.tw.WriteHeader(header) | ||
if err != nil { | ||
return fmt.Errorf("writing %q header: %w", pathInTar, err) | ||
} | ||
|
||
if useBuf { | ||
_, err = io.Copy(a.tw, a.copyBuf) | ||
} else { | ||
_, err = io.Copy(a.tw, file) | ||
} | ||
|
||
if err != nil && err != io.EOF { | ||
return fmt.Errorf("copying %q: %w", pathInTar, err) | ||
} | ||
|
||
// explicitly flush so that if the entry is invalid we will detect it now and | ||
// provide a more meaningful error | ||
if err := a.tw.Flush(); err != nil { | ||
return fmt.Errorf("finalizing %q: %w", pathInTar, err) | ||
} | ||
return nil | ||
} | ||
|
||
// tarPath writes the given source path into tarWriter at the given dest (recursively for directories). | ||
// e.g. tarring my_dir --> dest d: d/file_a, d/file_b | ||
// If source path does not exist, quietly skips it and returns no err | ||
func (a *ArchiveBuilder) entriesForPath(localPath, containerPath string) ([]archiveEntry, error) { | ||
localInfo, err := os.Stat(localPath) | ||
if err != nil { | ||
if os.IsNotExist(err) { | ||
return nil, nil | ||
} | ||
return nil, err | ||
} | ||
|
||
localPathIsDir := localInfo.IsDir() | ||
if localPathIsDir { | ||
// Make sure we can trim this off filenames to get valid relative filepaths | ||
if !strings.HasSuffix(localPath, string(filepath.Separator)) { | ||
localPath += string(filepath.Separator) | ||
} | ||
} | ||
|
||
containerPath = strings.TrimPrefix(containerPath, "/") | ||
|
||
result := make([]archiveEntry, 0) | ||
err = filepath.Walk(localPath, func(curLocalPath string, info os.FileInfo, err error) error { | ||
if err != nil { | ||
return fmt.Errorf("walking %q: %w", curLocalPath, err) | ||
} | ||
|
||
linkname := "" | ||
if info.Mode()&os.ModeSymlink != 0 { | ||
var err error | ||
linkname, err = os.Readlink(curLocalPath) | ||
if err != nil { | ||
return err | ||
} | ||
} | ||
|
||
var name string | ||
//nolint:gocritic | ||
if localPathIsDir { | ||
// Name of file in tar should be relative to source directory... | ||
tmp, err := filepath.Rel(localPath, curLocalPath) | ||
if err != nil { | ||
return fmt.Errorf("making %q relative to %q: %w", curLocalPath, localPath, err) | ||
} | ||
// ...and live inside `dest` | ||
name = path.Join(containerPath, filepath.ToSlash(tmp)) | ||
} else if strings.HasSuffix(containerPath, "/") { | ||
name = containerPath + filepath.Base(curLocalPath) | ||
} else { | ||
name = containerPath | ||
} | ||
|
||
header, err := archive.FileInfoHeader(name, info, linkname) | ||
if err != nil { | ||
// Not all types of files are allowed in a tarball. That's OK. | ||
// Mimic the Docker behavior and just skip the file. | ||
return nil | ||
} | ||
|
||
result = append(result, archiveEntry{ | ||
path: curLocalPath, | ||
info: info, | ||
header: header, | ||
}) | ||
|
||
return nil | ||
}) | ||
if err != nil { | ||
return nil, err | ||
} | ||
return result, nil | ||
} | ||
|
||
func tarArchive(ops []PathMapping) io.ReadCloser { | ||
pr, pw := io.Pipe() | ||
go func() { | ||
ab := NewArchiveBuilder(pw) | ||
err := ab.ArchivePathsIfExist(ops) | ||
if err != nil { | ||
_ = pw.CloseWithError(fmt.Errorf("adding files to tar: %w", err)) | ||
} else { | ||
// propagate errors from the TarWriter::Close() because it performs a final | ||
// Flush() and any errors mean the tar is invalid | ||
if err := ab.Close(); err != nil { | ||
_ = pw.CloseWithError(fmt.Errorf("closing tar: %w", err)) | ||
} else { | ||
_ = pw.Close() | ||
} | ||
} | ||
}() | ||
return pr | ||
} | ||
|
||
// Dedupe the entries with last-entry-wins semantics. | ||
func dedupeEntries(entries []archiveEntry) []archiveEntry { | ||
seenIndex := make(map[string]int, len(entries)) | ||
result := make([]archiveEntry, 0, len(entries)) | ||
for i, entry := range entries { | ||
seenIndex[entry.header.Name] = i | ||
} | ||
for i, entry := range entries { | ||
if seenIndex[entry.header.Name] == i { | ||
result = append(result, entry) | ||
} | ||
} | ||
return result | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm surprised the validation headers task doesn't complain about the Tilt line 🤔
Anyway this fine to have it 😉