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

Add packaging utility for client tools auto updates #47060

Merged
merged 14 commits into from
Oct 9, 2024
168 changes: 168 additions & 0 deletions integration/helpers/archive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package helpers

import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"

"github.com/gravitational/teleport"
"github.com/gravitational/trace"
)

// CompressDirToZipFile compresses a directory into `.zip` format,
// preserving the relative file path structure of the source directory.
sclevine marked this conversation as resolved.
Show resolved Hide resolved
func CompressDirToZipFile(ctx context.Context, sourcePath, destPath string) (err error) {
archive, err := os.Create(destPath)
sclevine marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return trace.Wrap(err)
}
defer func() {
if closeErr := archive.Close(); closeErr != nil {
err = trace.Wrap(closeErr)
vapopov marked this conversation as resolved.
Show resolved Hide resolved
return
}
if err != nil {
if err := os.Remove(destPath); err != nil {
slog.ErrorContext(ctx, "failed to remove archive", "error", err)
}
}
}()

sclevine marked this conversation as resolved.
Show resolved Hide resolved
zipWriter := zip.NewWriter(archive)
err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return trace.Wrap(err)
}
if info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return trace.Wrap(err)
}
defer file.Close()
relPath, err := filepath.Rel(sourcePath, path)
if err != nil {
return trace.Wrap(err)
}
zipFileWriter, err := zipWriter.Create(relPath)
if err != nil {
return trace.Wrap(err)
}
if _, err = io.Copy(zipFileWriter, file); err != nil {
return trace.Wrap(err)
}
return trace.Wrap(file.Close())
})
if err != nil {
return trace.Wrap(err)
}
if err = zipWriter.Close(); err != nil {
return trace.Wrap(err)
}

return
}

// CompressDirToTarGzFile compresses a directory into .tar.gz format,
// preserving the relative file path structure of the source directory.
func CompressDirToTarGzFile(ctx context.Context, sourcePath, destPath string) (err error) {
archive, err := os.Create(destPath)
sclevine marked this conversation as resolved.
Show resolved Hide resolved
sclevine marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return trace.Wrap(err)
}
defer func() {
if closeErr := archive.Close(); closeErr != nil {
err = trace.Wrap(closeErr)
return
}
if err != nil {
if err := os.Remove(destPath); err != nil {
slog.ErrorContext(ctx, "failed to remove archive", "error", err)
}
}
}()
gzipWriter := gzip.NewWriter(archive)
tarWriter := tar.NewWriter(gzipWriter)
err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
header, err := tar.FileInfoHeader(info, info.Name())
sclevine marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return trace.Wrap(err)
}
header.Name, err = filepath.Rel(sourcePath, path)
if err != nil {
return trace.Wrap(err)
}
if err := tarWriter.WriteHeader(header); err != nil {
return trace.Wrap(err)
}
if _, err = io.Copy(tarWriter, file); err != nil {
return trace.Wrap(err)
}
return trace.Wrap(file.Close())
})
if err != nil {
return trace.Wrap(err)
}
if err = tarWriter.Close(); err != nil {
return trace.Wrap(err)
}
if err = gzipWriter.Close(); err != nil {
return trace.Wrap(err)
}

return
}

// CompressDirToPkgFile runs for the macOS `pkgbuild` command to generate a .pkg file from the source.
func CompressDirToPkgFile(ctx context.Context, sourcePath, destPath, identifier string) error {
if runtime.GOOS != "darwin" {
return trace.BadParameter("only darwin platform is supported for pkg file")
}
cmd := exec.CommandContext(
ctx,
"pkgbuild",
"--root", sourcePath,
"--identifier", identifier,
"--version", teleport.Version,
destPath,
)

return trace.Wrap(cmd.Run())
}
17 changes: 17 additions & 0 deletions lib/utils/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ func PercentUsed(path string) (float64, error) {
return Round(ratio * 100), nil
}

// FreeDiskWithReserve returns the available disk space (in bytes) on the disk at dir, minus `reservedFreeDisk`.
func FreeDiskWithReserve(dir string, reservedFreeDisk uint64) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(dir, &stat)
if err != nil {
return 0, trace.Wrap(err)
}
if stat.Bsize < 0 {
return 0, trace.Errorf("invalid size")
}
Comment on lines +56 to +58
Copy link
Contributor

@rosstimothy rosstimothy Oct 10, 2024

Choose a reason for hiding this comment

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

FYI @vapopov I'm seeing the following related to this change when running make lint-go locally:

lib/utils/disk.go:56:5: SA4003: no value of type uint32 is less than 0 (staticcheck)
	if stat.Bsize < 0 {
	  ^

Copy link
Contributor Author

@vapopov vapopov Oct 10, 2024

Choose a reason for hiding this comment

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

@rosstimothy thanks, I will change this one in next PR, previously it was unix package and replaced with syscall

ztypes_linux_arm.go:

type Statfs_t struct {
	Type    int32
	Bsize   int32
...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

actually syscall for linux also has similar types

type Statfs_t struct {
	Type    int64
	Bsize   int64
}

avail := stat.Bavail * uint64(stat.Bsize)
if reservedFreeDisk > avail {
return 0, trace.Errorf("no free space left")
}
return avail - reservedFreeDisk, nil
}

// CanUserWriteTo attempts to check if a user has write access to certain path.
// It also works around the program being run as root and tries to check
// the permissions of the user who executed the program as root.
Expand Down
18 changes: 17 additions & 1 deletion lib/utils/disk_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,29 @@

package utils

import "github.com/gravitational/trace"
import (
"github.com/gravitational/trace"
"golang.org/x/sys/windows"
)

// PercentUsed is not supported on Windows.
func PercentUsed(path string) (float64, error) {
return 0.0, trace.NotImplemented("disk usage not supported on Windows")
}

// FreeDiskWithReserve returns the available disk space (in bytes) on the disk at dir, minus `reservedFreeDisk`.
func FreeDiskWithReserve(dir string, reservedFreeDisk uint64) (uint64, error) {
var avail uint64
err := windows.GetDiskFreeSpaceEx(windows.StringToUTF16Ptr(dir), &avail, nil, nil)
if err != nil {
return 0, trace.Wrap(err)
}
if reservedFreeDisk > avail {
return 0, trace.Errorf("no free space left")
}
return avail - reservedFreeDisk, nil
}

// CanUserWriteTo is not supported on Windows.
func CanUserWriteTo(path string) (bool, error) {
return false, trace.NotImplemented("path permission checking is not supported on Windows")
Expand Down
158 changes: 158 additions & 0 deletions lib/utils/packaging/unarchive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package packaging

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

"github.com/gravitational/trace"

"github.com/gravitational/teleport/lib/utils"
)

const (
// reservedFreeDisk is the predefined amount of free disk space (in bytes) required
// to remain available after extracting Teleport binaries.
reservedFreeDisk = 10 * 1024 * 1024
vapopov marked this conversation as resolved.
Show resolved Hide resolved
)

// RemoveWithSuffix removes all that matches the provided suffix, except for file or directory with `skipName`.
func RemoveWithSuffix(dir, suffix, skipName string) error {
var removePaths []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return trace.Wrap(err)
}
if skipName == info.Name() {
return nil
}
if !strings.HasSuffix(info.Name(), suffix) {
return nil
}
removePaths = append(removePaths, path)
if info.IsDir() {
return filepath.SkipDir
}
return nil
})
if err != nil {
return trace.Wrap(err)
}
for _, path := range removePaths {
if err := os.RemoveAll(path); err != nil {
return trace.Wrap(err)
}
}
return nil
}

// replaceZip un-archives the Teleport package in .zip format, iterates through
// the compressed content, and ignores everything not matching the binaries specified
// in the execNames argument. The data is extracted to extractDir, and symlinks are created
// in toolsDir pointing to the extractDir path with binaries.
func replaceZip(toolsDir string, archivePath string, extractDir string, execNames []string) error {
f, err := os.Open(archivePath)
if err != nil {
return trace.Wrap(err)
}
defer f.Close()

fi, err := f.Stat()
if err != nil {
return trace.Wrap(err)
}
zipReader, err := zip.NewReader(f, fi.Size())
if err != nil {
return trace.Wrap(err)
}

var totalSize uint64 = 0
for _, zipFile := range zipReader.File {
baseName := filepath.Base(zipFile.Name)
// Skip over any files in the archive that are not defined execNames.
if !slices.ContainsFunc(execNames, func(s string) bool {
return baseName == s
}) {
continue
}
totalSize += zipFile.UncompressedSize64
}
// Verify that we have enough space for uncompressed zipFile.
if err := checkFreeSpace(extractDir, totalSize); err != nil {
return trace.Wrap(err)
}

for _, zipFile := range zipReader.File {
baseName := filepath.Base(zipFile.Name)
// Skip over any files in the archive that are not defined execNames.
if !slices.Contains(execNames, baseName) {
continue
}

if err := func(zipFile *zip.File) error {
file, err := zipFile.Open()
if err != nil {
return trace.Wrap(err)
}
defer file.Close()

sclevine marked this conversation as resolved.
Show resolved Hide resolved
dest := filepath.Join(extractDir, baseName)
destFile, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return trace.Wrap(err)
}
defer destFile.Close()
sclevine marked this conversation as resolved.
Show resolved Hide resolved

if _, err := io.Copy(destFile, file); err != nil {
return trace.Wrap(err)
}
appPath := filepath.Join(toolsDir, baseName)
if err := os.Remove(appPath); err != nil && !os.IsNotExist(err) {
return trace.Wrap(err)
}
if err := os.Symlink(dest, appPath); err != nil {
return trace.Wrap(err)
}
return trace.Wrap(destFile.Close())
}(zipFile); err != nil {
return trace.Wrap(err)
}
}

return nil
}

// checkFreeSpace verifies that we have enough requested space (in bytes) at specific directory.
func checkFreeSpace(path string, requested uint64) error {
free, err := utils.FreeDiskWithReserve(path, reservedFreeDisk)
if err != nil {
return trace.Errorf("failed to calculate free disk in %q: %v", path, err)
}
// Bail if there's not enough free disk space at the target.
if requested > free {
return trace.Errorf("%q needs %d additional bytes of disk space", path, requested-free)
}

return nil
}
Loading
Loading