Skip to content
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

Tooling for Mac Distribution #324

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions tools/mac-distribution/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# mac-packaging

## Usage

### Packaging

App Bundle (.app)
```shell
mac-distribution package-app tsh tsh.app/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of having a separate tool for every artifact, what do you think about having a single build tool with subcommands that we can extend as we convert more stuff? E.g. something like:

$ tbuild build-mac ... --notarize

```

Package Installer (.pkg)

```shell
# Staging files
mkdir "${STAGING_PKG}"
cp "file1" "file2" "${STAGING_PKG}"

# Package
mac-distribution package-pkg --install-location /usr/local/bin "${STAGING_PKG}" "my-app.pkg"
```

### Notarization

By default, notarization is disabled and will output dryrun logs. To enable it you must either set the following options:
```shell
mac-distribution --apple-username="" --apple-password="" --signing-identity="" --bundle-id="" ...
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we support flags at all? Doesn't this just make it more likely that secrets get recorded in shell history files?

```

These flags can also be set through the environment.
```shell
APPLE_USERNAME=""
APPLE_PASSWORD=""
SIGNING_IDENTITY=""
BUNDLE_ID=""
```

If all of these are set then notarization will be enabled and the tool will notarize after packaging.
This is to make it convenient to test locally without having to set up creds to build packages.

However this isn't desirable in CI environments where notarization must happen. Enabling dryrun will "silently" cause a failure.
For convenience the `--force-notarization` flag is provided to fail in the scenario where creds are missing.
Comment on lines +41 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most CI environments set a CI environment variable - what do you think about automatically forcing notarization when $CI is set?

16 changes: 16 additions & 0 deletions tools/mac-distribution/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/gravitational/shared-workflows/tools/mac-distribution

go 1.23.2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1.23.6 is the current latest 1.23 and is what we have in the main teleport repo.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like 1.24.0 was just released today!


require (
github.com/alecthomas/kong v1.6.1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current latest release is 1.7.0, although 1.8.0 was tagged 4 hours ago, but nothing on the releases page yet (although historically, kong has not used github releases; there's only 2 there at the moment).

github.com/gravitational/trace v1.5.0
github.com/stretchr/testify v1.8.3
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/net v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
22 changes: 22 additions & 0 deletions tools/mac-distribution/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.6.1 h1:/7bVimARU3uxPD0hbryPE8qWrS3Oz3kPQoxA/H2NKG8=
github.com/alecthomas/kong v1.6.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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/gravitational/trace v1.5.0 h1:JbeL2HDGyzgy7G72Z2hP2gExEyA6Y2p7fCiSjyZwCJw=
github.com/gravitational/trace v1.5.0/go.mod h1:dxezSkKm880IIDx+czWG8fq+pLnXjETBewMgN3jOBlg=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
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/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
61 changes: 61 additions & 0 deletions tools/mac-distribution/internal/exec/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package exec

import (
"bytes"
"log/slog"
"os/exec"

"github.com/gravitational/trace"
)

// CommandRunner is a wrapper around [exec.Command] that is useful for testing.
type CommandRunner interface {
RunCommand(path string, args ...string) ([]byte, error)
}

func NewDefaultCommandRunner() *DefaultCommandRunner {
return &DefaultCommandRunner{}
}

type DefaultCommandRunner struct {
}

var _ CommandRunner = &DefaultCommandRunner{}

func (d *DefaultCommandRunner) RunCommand(path string, args ...string) ([]byte, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer

cmd := exec.Command(path, args...)
cmd.Stderr = &stderr
cmd.Stdout = &stdout

err := cmd.Run()
out := bytes.TrimSpace(stdout.Bytes())
if err != nil {
// stdout is also returned since it may contain useful information
return out, trace.Wrap(err, "failed to run command: %s", stderr.String())
}
return out, nil
}

// DryRunner is a dry runner that does not actually run the command.
// Instead, it logs the command that would have been run.
type DryRunner struct {
log *slog.Logger
}

var _ CommandRunner = &DryRunner{}

// NewDryRunner creates a new dry runner.
func NewDryRunner(logger *slog.Logger) *DryRunner {
return &DryRunner{
log: logger,
}
}

// RunCommand logs the command that would have been run.
func (d *DryRunner) RunCommand(path string, args ...string) ([]byte, error) {
d.log.Info("dry run", "path", path, "args", args)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at usage, this will potentially log all sensitive CLI arguments like Apple username and password, right? For example, when we call notarytool. We should not do that.

return []byte("dry run"), nil
}
69 changes: 69 additions & 0 deletions tools/mac-distribution/internal/fileutil/copy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package fileutil

import (
"io"
"os"

"github.com/gravitational/trace"
)

// CopyOpt is a functional option for configuring the copy operation.
type CopyOpt func(*copyOpts)

type copyOpts struct {
destPermissions os.FileMode
}

// CopyFile copies a file from src to dst.
func CopyFile(src, dst string, opts ...CopyOpt) (err error) {
var o copyOpts
for _, opt := range opts {
opt(&o)
}

r, err := os.Open(src)
if err != nil {
return trace.Wrap(err)
}
defer r.Close()

// If the destination permissions are not set, use the source permissions.
if o.destPermissions == 0 {
info, err := r.Stat()
if err != nil {
return trace.Wrap(err)
}
o.destPermissions = info.Mode().Perm()
}

w, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, o.destPermissions) // create or overwrite
if err != nil {
return trace.Wrap(err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On error should we be deleting the dst file or leaving it behind?

}

// Close the writer and remove the destination file if an error occurs.
defer func() {
closeErr := w.Close()
if err == nil { // return close error if NO ERROR occurred before
err = closeErr
}
if err != nil {
// Attempt to remove the destination file if an error occurred.
err = trace.NewAggregate(err, trace.Wrap(os.Remove(dst), "failed to remove destination file"))
}
}()

if _, err = io.Copy(w, r); err != nil {
return trace.Wrap(err)
}

return nil
}

// WithDestPermissions sets the permissions of the destination file.
// By default the destination file will have the same permissions as the source file.
func WithDestPermissions(perm os.FileMode) CopyOpt {
return func(o *copyOpts) {
o.destPermissions = perm
}
}
111 changes: 111 additions & 0 deletions tools/mac-distribution/internal/zipper/zipper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package zipper

import (
"archive/zip"
"io"
"io/fs"
"os"
"path/filepath"
"strings"

"github.com/gravitational/trace"
)

// FileInfo contains information about a file to archive.
type FileInfo struct {
// Path is the path to the file.
Path string

// ArchiveName is the desired name of the file in the archive.
// If ArchiveName is empty, the base name of path will be used.
ArchiveName string
}

// ZipFiles will create a zip with the specified files and write the archive to the specified writer.
func ZipFiles(out io.Writer, files []FileInfo) (err error) {
zipwriter := zip.NewWriter(out)
defer func() {
if err == nil { // if NO errors
// Closing finishes the write by writing the central directory.
// To avoid propagating an error from an earlier operation only close if there is no error.
err = trace.Wrap(zipwriter.Close())
}
}()

for _, file := range files {
if file.ArchiveName == "" {
file.ArchiveName = filepath.Base(file.Path)
}

w, err := zipwriter.Create(file.ArchiveName)
if err != nil {
return trace.Wrap(err)
}

f, err := os.Open(file.Path)
if err != nil {
return trace.Wrap(err)
}

_, err = io.Copy(w, f)
if err != nil {
f.Close() // Ignore close error since we already have an error to return.
return trace.Wrap(err)
}

if err := f.Close(); err != nil {
return trace.Wrap(err)
}
}

return nil
}

// DirZipperOpt is a functional option for configuring a DirZipper.
type DirZipperOpt func(*dirZipperOpts)

type dirZipperOpts struct {
includeParent bool
}

// IncludeParent determines whether to keep the root directory as a prefix in the zip file.
// This is particularly useful for App Bundles where the root directory (.app) should be included.
func IncludeParent() DirZipperOpt {
return func(o *dirZipperOpts) {
o.includeParent = true
}
}

// ZipDir will zip the directory into the specified output file
func ZipDir(dir string, out io.Writer, opts ...DirZipperOpt) (err error) {
var o dirZipperOpts
for _, opt := range opts {
opt(&o)
}

files := []FileInfo{}
parentDir := filepath.Base(dir)

// Construct a list of files to include in the zip
err = filepath.WalkDir(dir, fs.WalkDirFunc(func(path string, d fs.DirEntry, err error) error {
// Ignore zipping directories
if d.IsDir() {
return nil
}

// Avoid including root path structure in the zip file.
archiveName := strings.TrimPrefix(path, dir)

if o.includeParent {
archiveName = filepath.Join(parentDir, archiveName)
}

files = append(files, FileInfo{
Path: path,
ArchiveName: archiveName,
})
return nil
}))

return trace.Wrap(ZipFiles(out, files))
}
Loading
Loading