From 9899387fe771a36ce2c3f630dc44fa5fe86ccbab Mon Sep 17 00:00:00 2001 From: Leopere Date: Thu, 20 Mar 2025 13:06:29 -0400 Subject: [PATCH] Initial implementation of tarballer utility with FreeBSD/macOS/Linux support and symlink handling --- .gitignore | 8 ++++ Dockerfile | 40 ++++++++++++++++ README.md | 64 +++++++++++++++++++++++++ docker-compose.yml | 75 +++++++++++++++++++++++++++++ go.mod | 3 ++ main.go | 116 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 306 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fde74a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/bin/ +*.tar.gz +/test/extracted/ +/test/example/ +/test/complex/ +/test/complex-extracted/ +/test/local/ +/test/local-extracted/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..586d2c2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM golang:1.20-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git + +# Set up the working directory +WORKDIR /app + +# Copy Go module files +COPY go.mod ./ + +# Copy the source code +COPY main.go ./ + +# Build FreeBSD binary +RUN GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go build -o tarballer-freebsd -ldflags="-s -w" + +# Build macOS ARM64 binary for local testing +RUN GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o tarballer-darwin -ldflags="-s -w" + +# Build Linux binary for testing in container +RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o tarballer-linux -ldflags="-s -w" + +# Second stage for testing +FROM alpine:latest AS tester + +# Install required tools for testing +RUN apk add --no-cache tar gzip + +WORKDIR /workdir + +# Copy Linux binary for use in the container +COPY --from=builder /app/tarballer-linux /bin/tarballer + +# Also copy the FreeBSD and Darwin binaries for extraction +COPY --from=builder /app/tarballer-freebsd /bin/ +COPY --from=builder /app/tarballer-darwin /bin/ + +# Make binaries executable +RUN chmod +x /bin/tarballer /bin/tarballer-freebsd /bin/tarballer-darwin \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a39704 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Tarballer + +A simple utility to create tarballs with a specific directory structure. + +## Features + +- Creates compressed tar archives (.tar.gz) from a source directory +- Places all files under a specified prefix directory in the tarball +- Preserves file permissions and directory structure +- Handles symbolic links correctly +- Cross-platform compatibility (FreeBSD, macOS, Linux) + +## Building + +This project includes Docker support to build binaries for different platforms: + +```bash +docker compose up --build build +``` + +This will create these binaries in the `./bin` directory: +- `tarballer-freebsd`: FreeBSD AMD64 compatible binary +- `tarballer-darwin`: macOS ARM64 compatible binary +- `tarballer-linux`: Linux AMD64 compatible binary + +## Testing + +You can run the included test to verify functionality: + +```bash +docker compose up --build test +``` + +This will: +1. Create a test directory structure with nested directories and symlinks +2. Create a tarball from it +3. Extract the tarball +4. Verify the contents and file structure, including symlinks + +## Usage + +The usage is the same for all binaries: + +```bash +./bin/tarballer- -source /path/to/directory -output myarchive.tar.gz -prefix myprefix +``` + +### Options + +- `-source`: The directory you want to compress (required) +- `-output`: The name of the output tarball (defaults to "output.tar.gz") +- `-prefix`: The directory name that will contain all files in the tarball (defaults to "myapp") + +### Example + +```bash +# On macOS: +./bin/tarballer-darwin -source ./myproject -output release.tar.gz -prefix app + +# On FreeBSD: +./bin/tarballer-freebsd -source ./myproject -output release.tar.gz -prefix app +``` + +When extracted, all files will be under the `app/` directory in the tarball. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2bee676 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +version: '3' + +services: + build: + build: + context: . + target: builder + volumes: + - ./bin:/output + command: sh -c "cp /app/tarballer-freebsd /output/ && cp /app/tarballer-darwin /output/ && cp /app/tarballer-linux /output/ && chmod +x /output/tarballer-freebsd /output/tarballer-darwin /output/tarballer-linux" + + test: + build: + context: . + target: tester + volumes: + - ./bin:/output + - ./test:/test + - .:/workdir + working_dir: /workdir + depends_on: + - build + command: | + sh -c "# Clean up existing test directories + rm -rf /test/complex /test/complex-extracted && + + # Create complex directory structure + mkdir -p /test/complex/dir1/subdir1/subsubdir1 && + mkdir -p /test/complex/dir1/subdir2 && + mkdir -p /test/complex/dir2/subdir1 && + + # Create files at different levels + echo 'root level file' > /test/complex/rootfile.txt && + echo 'level 1 file in dir1' > /test/complex/dir1/file1.txt && + echo 'level 1 file in dir2' > /test/complex/dir2/file2.txt && + echo 'level 2 file in subdir1' > /test/complex/dir1/subdir1/file3.txt && + echo 'level 2 file in subdir2' > /test/complex/dir1/subdir2/file4.txt && + echo 'level 3 file in subsubdir1' > /test/complex/dir1/subdir1/subsubdir1/file5.txt && + + # Create a symbolic link with a relative path instead of absolute + cd /test/complex/dir2 && ln -s ../rootfile.txt symlink.txt && cd /workdir && + + # Print the original structure for reference + echo '=== ORIGINAL DIRECTORY STRUCTURE ===' && + find /test/complex -type f -o -type l | sort && + + # Create the tarball + /bin/tarballer -source /test/complex -output /workdir/complex.tar.gz -prefix complex-app && + + # Extract the tarball + mkdir -p /test/complex-extracted && + tar -xzf /workdir/complex.tar.gz -C /test/complex-extracted && + + # Verify the extracted structure + echo '=== EXTRACTED DIRECTORY STRUCTURE ===' && + find /test/complex-extracted -type f -o -type l | sort && + + # Verify file content matches + echo '=== VERIFYING FILE CONTENTS ===' && + cat /test/complex/rootfile.txt && + echo ' <-- Original: rootfile.txt' && + cat /test/complex-extracted/complex-app/rootfile.txt && + echo ' <-- Extracted: rootfile.txt' && + + cat /test/complex/dir1/subdir1/subsubdir1/file5.txt && + echo ' <-- Original: deep nested file5.txt' && + cat /test/complex-extracted/complex-app/dir1/subdir1/subsubdir1/file5.txt && + echo ' <-- Extracted: deep nested file5.txt' && + + # Test symlink + echo '=== TESTING SYMLINK ===' && + ls -la /test/complex/dir2/symlink.txt && + ls -la /test/complex-extracted/complex-app/dir2/symlink.txt && + + echo 'All tests completed successfully!'" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..75a8925 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/aedev/tarballer + +go 1.20 \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..864cf87 --- /dev/null +++ b/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "flag" + "fmt" + "io" + "os" + "path/filepath" +) + +func main() { + // Define command line flags + sourceDir := flag.String("source", "", "Source directory to compress") + outputFile := flag.String("output", "output.tar.gz", "Output tarball filename") + prefixDir := flag.String("prefix", "myapp", "Directory prefix in tarball") + flag.Parse() + + if *sourceDir == "" { + fmt.Println("Please specify a source directory using -source") + flag.Usage() + os.Exit(1) + } + + err := createTarball(*sourceDir, *outputFile, *prefixDir) + if err != nil { + fmt.Printf("Error creating tarball: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Successfully created %s with prefix %s\n", *outputFile, *prefixDir) +} + +func createTarball(sourceDir, outputFile, prefix string) error { + // Create output file + out, err := os.Create(outputFile) + if err != nil { + return err + } + defer out.Close() + + // Create gzip writer + gw := gzip.NewWriter(out) + defer gw.Close() + + // Create tar writer + tw := tar.NewWriter(gw) + defer tw.Close() + + // Resolve absolute source path to handle relative symlinks correctly + sourceDir, err = filepath.Abs(sourceDir) + if err != nil { + return err + } + + // Walk through source directory + err = filepath.Walk(sourceDir, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path to use in the tarball + relPath, err := filepath.Rel(sourceDir, filePath) + if err != nil { + return err + } + + // Create tar header using original file info + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + + // Update the name with the prefix and relative path + header.Name = filepath.Join(prefix, relPath) + + // Special handling for symbolic links + if info.Mode()&os.ModeSymlink != 0 { + // Read link target + linkTarget, err := os.Readlink(filePath) + if err != nil { + return err + } + + // Store the link target in the header + header.Linkname = linkTarget + + // Make sure the link type is set correctly + header.Typeflag = tar.TypeSymlink + } + + // Write header + if err := tw.WriteHeader(header); err != nil { + return err + } + + // If it's a file (not a directory or symlink), copy contents + if !info.IsDir() && info.Mode()&os.ModeSymlink == 0 { + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(tw, file) + if err != nil { + return err + } + } + + return nil + }) + + return err +}