diff --git a/.cirrus.star b/.cirrus.star
new file mode 100644
index 0000000..0a13153
--- /dev/null
+++ b/.cirrus.star
@@ -0,0 +1,4 @@
+load("github.com/cirrus-modules/golang@main", "lint_task")
+
+def main(ctx):
+ return [lint_task()]
diff --git a/.cirrus.yml b/.cirrus.yml
new file mode 100644
index 0000000..31b9195
--- /dev/null
+++ b/.cirrus.yml
@@ -0,0 +1,52 @@
+task:
+ name: Test (Linux)
+ alias: Tests
+ container:
+ image: golang:latest
+
+ test_script: go test -v ./...
+
+task:
+ name: Test (macOS)
+ alias: Tests
+ macos_instance:
+ image: ghcr.io/cirruslabs/macos-runner:sequoia
+
+ prepare_script: brew install go
+ test_script: go test -v ./...
+
+task:
+ name: Release (Dry Run)
+ only_if: $CIRRUS_TAG == ''
+ container:
+ image: golang:latest
+ memory: 8GB
+ depends_on:
+ - Lint
+ - Tests
+ install_script:
+ - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | tee /etc/apt/sources.list.d/goreleaser.list
+ - apt-get update
+ - apt-get -y install goreleaser-pro
+ release_script: goreleaser build --snapshot
+ goreleaser_artifacts:
+ path: "dist/**"
+
+task:
+ name: Release
+ only_if: $CIRRUS_TAG != ''
+ container:
+ image: golang:latest
+ memory: 8GB
+ depends_on:
+ - Lint
+ - Tests
+ env:
+ GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
+ FURY_TOKEN: ENCRYPTED[!97fe4497d9aca60a3d64904883b81e21f19706c6aedda625c97f62f67ec46b8efa74c55699956158bbf0a23726e7d9f6!]
+ GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
+ install_script:
+ - echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | tee /etc/apt/sources.list.d/goreleaser.list
+ - apt-get update
+ - apt-get -y install goreleaser-pro
+ release_script: goreleaser
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..d69190a
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,67 @@
+version: 2
+
+run:
+ timeout: 5m
+
+linters-settings:
+ # Even in Rust you can get away with partial matching,
+ # so make sure that the linter respects the programmer's
+ # intent expressed in the form of "default" case.
+ exhaustive:
+ default-signifies-exhaustive: true
+
+ gosec:
+ excludes:
+ - G115
+
+linters:
+ enable-all: true
+
+ disable:
+ # We don't have high-performance requirements at this moment, so sacrificing
+ # the code readability for marginal performance gains is not worth it.
+ - prealloc
+
+ # New linters that require a lot of codebase churn and noise, but perhaps we can enable them in the future.
+ - nlreturn
+ - wrapcheck
+ - errorlint
+
+ # Unfortunately, we use globals due to how spf13/cobra works.
+ - gochecknoglobals
+
+ # Style linters that are total nuts.
+ - wsl
+ - funlen
+
+ # Enough parallelism for now.
+ - paralleltest
+
+ # Ill-based assumptions about identifiers like fmt.Println without taking context into account.
+ - forbidigo
+
+ # Advantages of using t.Helper() are too small to waste developer's cognitive stamina on it.
+ - thelper
+
+ # Too restrictive defaults, plus there's already a gocyclo linter in place.
+ - cyclop
+
+ # Gives false positives for textbook examples[1][2]
+ # [1]: https://github.com/charithe/durationcheck/issues/7
+ # [2]: https://golang.org/pkg/time/ (see "To convert an integer number of units to a Duration, multiply:")
+ - durationcheck
+
+ # No way to disable the "exported" check for the whole project[1]
+ # [1]: https://github.com/mgechev/revive/issues/244#issuecomment-560512162
+ - revive
+
+ # Needs package whitelists
+ - depguard
+
+ # Not all errors need to be checked
+ - errcheck
+
+issues:
+ # Don't hide multiple issues that belong to one class since GitHub annotations can handle them all nicely.
+ max-issues-per-linter: 0
+ max-same-issues: 0
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 0000000..cd083e0
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,43 @@
+project_name: tart-guest-agent
+
+before:
+ hooks:
+ - go mod download
+
+builds:
+ - main: cmd/main.go
+ ldflags: >
+ -X github.com/cirruslabs/tart-guest-agent/internal/version.Version={{.Version}}
+ -X github.com/cirruslabs/tart-guest-agent/internal/version.Commit={{.ShortCommit}}
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - darwin
+ - linux
+ goarch:
+ - amd64
+ - arm64
+
+archives:
+ - id: binary
+ format: binary
+ name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
+ - id: regular
+ name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
+
+release:
+ prerelease: auto
+
+nfpms:
+ - package_name: tart-guest-agent
+ vendor: Cirrus Labs, Inc.
+ homepage: https://github.com/cirruslabs/tart-guest-agent
+ maintainer: support@cirruslabs.org
+ description: Guest agent for Tart VMs
+ section: misc
+ formats:
+ - deb
+ - rpm
+
+furies:
+ - account: cirruslabs
diff --git a/README.md b/README.md
index 1781fbd..f278e6b 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
# Guest agent for Tart VMs
A guest agent for Tart VMS is a lightweight background service that runs inside the virtual machine and enables enhanced communication between the host and guest and other useful features, such as automatic disk resizing.
+
+Currently implemented features:
+
+* Automatic disk resizing for macOS VMs with recovery partition removed
diff --git a/cmd/main.go b/cmd/main.go
new file mode 100644
index 0000000..3314bf2
--- /dev/null
+++ b/cmd/main.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "github.com/cirruslabs/tart-guest-agent/internal/command"
+ "github.com/cirruslabs/tart-guest-agent/internal/logginglevel"
+ "go.uber.org/zap"
+ "os"
+ "os/signal"
+ "syscall"
+)
+
+func main() {
+ if !mainImpl() {
+ os.Exit(1)
+ }
+}
+
+func mainImpl() bool {
+ // Set up a signal-interruptible context
+ ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer cancel()
+
+ // Initialize logger
+ loggerConfig := zap.NewProductionConfig()
+ loggerConfig.Level = logginglevel.Level
+ logger, err := loggerConfig.Build()
+ if err != nil {
+ _, _ = fmt.Fprintln(os.Stderr, err)
+
+ return false
+ }
+ defer func() {
+ _ = logger.Sync()
+ }()
+
+ // Replace zap.L() and zap.S() to avoid
+ // propagating the *zap.Logger by hand
+ zap.ReplaceGlobals(logger)
+
+ if err := command.NewRootCommand().ExecuteContext(ctx); err != nil {
+ logger.Sugar().Error(err)
+
+ return false
+ }
+
+ return true
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..eb8c040
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,23 @@
+module github.com/cirruslabs/tart-guest-agent
+
+go 1.23.7
+
+require (
+ github.com/hashicorp/go-version v1.7.0
+ github.com/samber/lo v1.49.1
+ github.com/spf13/cobra v1.9.1
+ github.com/stretchr/testify v1.10.0
+ go.uber.org/zap v1.27.0
+ golang.org/x/sys v0.32.0
+ howett.net/plist v1.0.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/spf13/pflag v1.0.6 // indirect
+ go.uber.org/multierr v1.10.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f2ffc7f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,41 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
+github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
+howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
diff --git a/internal/command/root.go b/internal/command/root.go
new file mode 100644
index 0000000..c534bb2
--- /dev/null
+++ b/internal/command/root.go
@@ -0,0 +1,64 @@
+package command
+
+import (
+ "errors"
+ "github.com/cirruslabs/tart-guest-agent/internal/diskresizer"
+ "github.com/cirruslabs/tart-guest-agent/internal/logginglevel"
+ "github.com/cirruslabs/tart-guest-agent/internal/tart"
+ "github.com/cirruslabs/tart-guest-agent/internal/version"
+ "github.com/spf13/cobra"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+ "golang.org/x/sys/unix"
+ "os"
+ "syscall"
+)
+
+var debug bool
+
+func NewRootCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "tart-guest-agent",
+ Short: "Guest agent for Tart VMs",
+ Version: version.FullVersion,
+ SilenceUsage: true,
+ SilenceErrors: true,
+ PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
+ if debug {
+ logginglevel.Level.SetLevel(zapcore.DebugLevel)
+ }
+
+ return nil
+ },
+ RunE: run,
+ }
+
+ return cmd
+}
+
+func run(cmd *cobra.Command, args []string) error {
+ // Terminate to prevent corruption on systems with disk layouts other than Tart's
+ communicationPoint, ok := tart.LocateCommunicationPoint()
+ if !ok {
+ return unix.Kill(os.Getppid(), syscall.SIGTERM)
+ }
+
+ zap.S().Infof("successfully located host communication point %s, proceeding...",
+ communicationPoint)
+
+ // Perform disk resizing
+ if err := diskresizer.Resize(); err != nil {
+ if errors.Is(err, diskresizer.ErrUnsupported) || errors.Is(err, diskresizer.ErrAlreadyResized) {
+ zap.S().Infof("skipping disk resizing: %v", err)
+ } else {
+ zap.S().Warnf("failed to resize disk: %v", err)
+ }
+ } else {
+ zap.S().Infof("sucessfully resized the disk")
+ }
+
+ // Wait indefinitely
+ <-cmd.Context().Done()
+
+ return cmd.Context().Err()
+}
diff --git a/internal/diskresizer/diskresizer.go b/internal/diskresizer/diskresizer.go
new file mode 100644
index 0000000..a70d946
--- /dev/null
+++ b/internal/diskresizer/diskresizer.go
@@ -0,0 +1,156 @@
+package diskresizer
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "github.com/samber/lo"
+ "howett.net/plist"
+ "os/exec"
+ "runtime"
+ "strings"
+)
+
+type DiskUtilOutput struct {
+ AllDisksAndPartitions []DiskWithPartitions `plist:"AllDisksAndPartitions"`
+}
+
+type DiskWithPartitions struct {
+ DeviceIdentifier string `plist:"DeviceIdentifier"`
+ Size int64 `plist:"Size"`
+ Partitions []Partition `plist:"Partitions"`
+}
+
+type Partition struct {
+ DeviceIdentifier string `plist:"DeviceIdentifier"`
+ Size int64 `plist:"Size"`
+ Content string `plist:"Content"`
+}
+
+const expectedPartitionContent = "Apple_APFS"
+
+var (
+ ErrUnsupported = errors.New("disk resizing is only supported on macOS")
+ ErrAlreadyResized = errors.New("disk already seems to be resized")
+)
+
+func Resize() error {
+ // Only macOS is currently supported because Linux distributions
+ // already have this functionality
+ if runtime.GOOS != "darwin" {
+ return ErrUnsupported
+ }
+
+ // Obtain a list of physical disks with their partitions
+ cmd := exec.Command("diskutil", "list", "-plist", "physical")
+
+ stderrBuf := &bytes.Buffer{}
+ cmd.Stderr = stderrBuf
+
+ diskutilOutput, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("failed to list physical disks: %w: %s", err,
+ firstNonEmptyLine(stderrBuf.String()))
+ }
+
+ // Figure out which disk and partition to resize,
+ // aborts on ambiguity
+ diskName, partitionName, diskSizeUnused, err := diskAndPartitionNameToResize(diskutilOutput)
+ if err != nil {
+ return fmt.Errorf("failed to locate a physical disk to resize: %w", err)
+ }
+
+ if diskSizeUnused <= 4096*10 {
+ return ErrAlreadyResized
+ }
+
+ // Repair the disk whose partition we're going to resize, just in case
+ cmd = exec.Command("diskutil", "repairDisk", diskName)
+
+ cmd.Stdin = bytes.NewReader([]byte("yes"))
+
+ stderrBuf = &bytes.Buffer{}
+ cmd.Stderr = stderrBuf
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to repair disk %s: %w: %s", diskName, err,
+ firstNonEmptyLine(stderrBuf.String()))
+ }
+
+ // Finally, resize the partition
+ cmd = exec.Command("diskutil", "apfs", "resizeContainer", partitionName, "0")
+
+ stderrBuf = &bytes.Buffer{}
+ cmd.Stderr = stderrBuf
+
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to resize partition %s: %w: %s", partitionName, err,
+ firstNonEmptyLine(stderrBuf.String()))
+ }
+
+ return nil
+}
+
+// diskAndPartitionNameToResize parses "diskutil list -plist" output,
+// makes sure there's only one disk on the system and returns its
+// name and the name of a single APFS partition or errors otherwise.
+func diskAndPartitionNameToResize(input []byte) (string, string, int64, error) {
+ var diskUtilOutput DiskUtilOutput
+
+ _, err := plist.Unmarshal(input, &diskUtilOutput)
+ if err != nil {
+ return "", "", 0, fmt.Errorf("failed to parse \"diskutil list -plist\" output: %w", err)
+ }
+
+ type Candidate struct {
+ DiskName string
+ DiskSizeUnused int64
+ PartitionName string
+ }
+
+ var candidates []Candidate
+
+ for _, diskWithPartitions := range diskUtilOutput.AllDisksAndPartitions {
+ unusedSize := diskWithPartitions.Size - lo.SumBy(diskWithPartitions.Partitions, func(partition Partition) int64 {
+ return partition.Size
+ })
+
+ for _, partition := range diskWithPartitions.Partitions {
+ if partition.Content != expectedPartitionContent {
+ continue
+ }
+
+ candidates = append(candidates, Candidate{
+ DiskName: diskWithPartitions.DeviceIdentifier,
+ DiskSizeUnused: unusedSize,
+ PartitionName: partition.DeviceIdentifier,
+ })
+ }
+ }
+
+ if len(candidates) == 0 {
+ return "", "", 0, fmt.Errorf("found no disks on which the partition's \"Content\" "+
+ "is %q, make sure that the macOS is installed", expectedPartitionContent)
+ }
+
+ if len(candidates) > 1 {
+ return "", "", 0, fmt.Errorf("found more than one disk on which the partition's \"Content\" "+
+ "is %q, please only mount a single disk that contains APFS partitions otherwise it's hard "+
+ "to tell on which disk the macOS is installed", expectedPartitionContent)
+ }
+
+ return candidates[0].DiskName, candidates[0].PartitionName,
+ candidates[0].DiskSizeUnused, nil
+}
+
+func firstNonEmptyLine(outputs ...string) string {
+ for _, output := range outputs {
+ for _, line := range strings.Split(output, "\n") {
+ if line != "" {
+ return line
+ }
+ }
+ }
+
+ return ""
+}
diff --git a/internal/diskresizer/diskresizer_test.go b/internal/diskresizer/diskresizer_test.go
new file mode 100644
index 0000000..0cdbcb1
--- /dev/null
+++ b/internal/diskresizer/diskresizer_test.go
@@ -0,0 +1,222 @@
+package diskresizer
+
+import (
+ "github.com/stretchr/testify/require"
+ "testing"
+)
+
+func TestSingleDisk(t *testing.T) {
+ plistBytes := `
+
+
+
+ AllDisks
+
+ disk0
+ disk0s1
+ disk0s2
+ disk0s3
+
+ AllDisksAndPartitions
+
+
+ Content
+ GUID_partition_scheme
+ DeviceIdentifier
+ disk0
+ OSInternal
+
+ Partitions
+
+
+ Content
+ Apple_APFS_ISC
+ DeviceIdentifier
+ disk0s1
+ DiskUUID
+ 024D2AE5-891F-4244-81EC-182B88D1AA0B
+ Size
+ 524288000
+
+
+ Content
+ Apple_APFS
+ DeviceIdentifier
+ disk0s2
+ DiskUUID
+ 430F1409-D91A-47F4-8418-0876B14AA807
+ Size
+ 494384795648
+
+
+ Size
+ 500277792768
+
+
+ VolumesFromDisks
+
+ WholeDisks
+
+ disk0
+
+
+
+`
+
+ diskName, partitionName, diskSizeUnused, err := diskAndPartitionNameToResize([]byte(plistBytes))
+ require.NoError(t, err)
+ require.Equal(t, "disk0", diskName)
+ require.Equal(t, "disk0s2", partitionName)
+ require.EqualValues(t, 5368709120, diskSizeUnused)
+}
+
+func TestSingleDiskWithRecoveryPartition(t *testing.T) {
+ plistBytes := `
+
+
+
+ AllDisks
+
+ disk0
+ disk0s1
+ disk0s2
+ disk0s3
+
+ AllDisksAndPartitions
+
+
+ Content
+ GUID_partition_scheme
+ DeviceIdentifier
+ disk0
+ OSInternal
+
+ Partitions
+
+
+ Content
+ Apple_APFS_ISC
+ DeviceIdentifier
+ disk0s1
+ DiskUUID
+ 22A4F5CC-CB5B-4EF1-B4F5-EBE9F79C0EE6
+ Size
+ 524288000
+
+
+ Content
+ Apple_APFS
+ DeviceIdentifier
+ disk0s2
+ DiskUUID
+ E59D951F-E0B0-42B5-BE26-DF3611AD673C
+ Size
+ 44107001856
+
+
+ Content
+ Apple_APFS_Recovery
+ DeviceIdentifier
+ disk0s3
+ DiskUUID
+ 7684137C-F165-45C4-948B-681012742DAC
+ Size
+ 5368672256
+
+
+ Size
+ 50000000000
+
+
+ VolumesFromDisks
+
+ WholeDisks
+
+ disk0
+
+
+
+`
+
+ diskName, partitionName, diskSizeUnused, err := diskAndPartitionNameToResize([]byte(plistBytes))
+ require.NoError(t, err)
+ require.Equal(t, "disk0", diskName)
+ require.Equal(t, "disk0s2", partitionName)
+ require.EqualValues(t, 37888, diskSizeUnused)
+}
+
+func TestMultipleDisks(t *testing.T) {
+ plistBytes := `
+
+
+
+ AllDisks
+
+ disk0
+ disk0s1
+ disk0s2
+ disk2
+
+ AllDisksAndPartitions
+
+
+ Content
+ GUID_partition_scheme
+ DeviceIdentifier
+ disk0
+ OSInternal
+
+ Partitions
+
+
+ Content
+ Apple_APFS_ISC
+ DeviceIdentifier
+ disk0s1
+ DiskUUID
+ D2B79297-879E-4461-8DA2-EEA50EA7319A
+ Size
+ 524288000
+
+
+ Content
+ Apple_APFS
+ DeviceIdentifier
+ disk0s2
+ DiskUUID
+ D5BA624D-182F-40D0-8248-D08508A8D1B3
+ Size
+ 89475674112
+
+
+ Size
+ 90000000000
+
+
+ Content
+
+ DeviceIdentifier
+ disk2
+ OSInternal
+
+ Size
+ 107164426240
+
+
+ VolumesFromDisks
+
+ WholeDisks
+
+ disk0
+ disk2
+
+
+
+`
+
+ diskName, partitionName, diskSizeUnused, err := diskAndPartitionNameToResize([]byte(plistBytes))
+ require.NoError(t, err)
+ require.Equal(t, "disk0", diskName)
+ require.Equal(t, "disk0s2", partitionName)
+ require.EqualValues(t, 37888, diskSizeUnused)
+}
diff --git a/internal/logginglevel/logginglevel.go b/internal/logginglevel/logginglevel.go
new file mode 100644
index 0000000..709f93e
--- /dev/null
+++ b/internal/logginglevel/logginglevel.go
@@ -0,0 +1,6 @@
+package logginglevel
+
+import "go.uber.org/zap"
+
+//nolint:gochecknoglobals
+var Level = zap.NewAtomicLevel()
diff --git a/internal/tart/tart.go b/internal/tart/tart.go
new file mode 100644
index 0000000..bf231c5
--- /dev/null
+++ b/internal/tart/tart.go
@@ -0,0 +1,24 @@
+package tart
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const devDirectoryPath = "/dev"
+
+func LocateCommunicationPoint() (string, bool) {
+ dirEntries, err := os.ReadDir(devDirectoryPath)
+ if err != nil {
+ return "", false
+ }
+
+ for _, entry := range dirEntries {
+ if strings.HasPrefix(entry.Name(), "cu.tart-version-") {
+ return filepath.Join(devDirectoryPath, entry.Name()), true
+ }
+ }
+
+ return "", false
+}
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 0000000..e576cab
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,32 @@
+package version
+
+import (
+ "fmt"
+ goversion "github.com/hashicorp/go-version"
+ "runtime/debug"
+)
+
+var (
+ Version = "unknown"
+ Commit = "unknown"
+ FullVersion = ""
+)
+
+func init() {
+ if Version == "unknown" {
+ info, ok := debug.ReadBuildInfo()
+ if ok {
+ // We parse the version here for two reasons:
+ // * to weed out the "(devel)" version and fallback to "unknown" instead
+ // (see https://github.com/golang/go/issues/29228 for details on when this might happen)
+ // * to remove the "v" prefix from the BuildInfo's version (e.g. "v0.7.0") and thus be consistent
+ // with the binary builds, where the version string would be "0.7.0" instead
+ semver, err := goversion.NewSemver(info.Main.Version)
+ if err == nil {
+ Version = semver.String()
+ }
+ }
+ }
+
+ FullVersion = fmt.Sprintf("%s-%s", Version, Commit)
+}