Initial implementation of tarballer utility with FreeBSD/macOS/Linux support and symlink handling

This commit is contained in:
Leopere 2025-03-20 13:06:29 -04:00
commit 9899387fe7
6 changed files with 306 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/bin/
*.tar.gz
/test/extracted/
/test/example/
/test/complex/
/test/complex-extracted/
/test/local/
/test/local-extracted/

40
Dockerfile Normal file
View File

@ -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

64
README.md Normal file
View File

@ -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-<platform> -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.

75
docker-compose.yml Normal file
View File

@ -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!'"

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/aedev/tarballer
go 1.20

116
main.go Normal file
View File

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