Skip to content

Commit

Permalink
Client tools auto update (#47466)
Browse files Browse the repository at this point in the history
* Add client tools auto update

* Replace fork for posix platform for re-exec
Move integration tests to client tools specific dir
Use context cancellation with SIGTERM, SIGINT
Remove cancelable tee reader with context replacement
Renaming

* Fix syscall path execution
Fix archive cleanup if hash is not valid
Limit the archive write bytes

* Cover the case with single package for darwin platform after v17

* Move updater logic to tools package

* Move context out from the library
Base URL renaming

* Add more context in comments

* Changes in find endpoint

* Replace test http server with `httptest`
Replace hash for bytes matching
Proper temp file close for archive download

* Add more context to comments

* Move feature flag to main package to be reused

* Constant rename

* Replace build tag with lib/modules to identify enterprise build

* Replace fips tag with modules flag
  • Loading branch information
vapopov committed Nov 8, 2024
1 parent 36ae92b commit a81e9b7
Show file tree
Hide file tree
Showing 11 changed files with 1,283 additions and 5 deletions.
89 changes: 89 additions & 0 deletions integration/autoupdate/tools/helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 tools_test

import (
"net/http"
"sync"
)

type limitRequest struct {
limit int64
lock chan struct{}
}

// limitedResponseWriter wraps http.ResponseWriter and enforces a write limit
// then block the response until signal is received.
type limitedResponseWriter struct {
requests chan limitRequest
}

// newLimitedResponseWriter creates a new limitedResponseWriter with the lock.
func newLimitedResponseWriter() *limitedResponseWriter {
lw := &limitedResponseWriter{
requests: make(chan limitRequest, 10),
}
return lw
}

// Wrap wraps response writer if limit was previously requested, if not, return original one.
func (lw *limitedResponseWriter) Wrap(w http.ResponseWriter) http.ResponseWriter {
select {
case request := <-lw.requests:
return &wrapper{
ResponseWriter: w,
request: request,
}
default:
return w
}
}

// SetLimitRequest sends limit request to the pool to wrap next response writer with defined limits.
func (lw *limitedResponseWriter) SetLimitRequest(limit limitRequest) {
lw.requests <- limit
}

// wrapper wraps the http response writer to control writing operation by blocking it.
type wrapper struct {
http.ResponseWriter

written int64
request limitRequest
released bool

mutex sync.Mutex
}

// Write writes data to the underlying ResponseWriter but respects the byte limit.
func (lw *wrapper) Write(p []byte) (int, error) {
lw.mutex.Lock()
defer lw.mutex.Unlock()

if lw.written >= lw.request.limit && !lw.released {
// Send signal that lock is acquired and wait till it was released by response.
lw.request.lock <- struct{}{}
<-lw.request.lock
lw.released = true
}

n, err := lw.ResponseWriter.Write(p)
lw.written += int64(n)
return n, err
}
37 changes: 37 additions & 0 deletions integration/autoupdate/tools/helper_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//go:build !windows

/*
* 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 tools_test

import (
"errors"
"syscall"

"github.com/gravitational/trace"
)

// sendInterrupt sends a SIGINT to the process.
func sendInterrupt(pid int) error {
err := syscall.Kill(pid, syscall.SIGINT)
if errors.Is(err, syscall.ESRCH) {
return trace.BadParameter("can't find the process: %v", pid)
}
return trace.Wrap(err)
}
42 changes: 42 additions & 0 deletions integration/autoupdate/tools/helper_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//go:build windows

/*
* 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 tools_test

import (
"syscall"

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

var (
kernel = windows.NewLazyDLL("kernel32.dll")
ctrlEvent = kernel.NewProc("GenerateConsoleCtrlEvent")
)

// sendInterrupt sends a Ctrl-Break event to the process.
func sendInterrupt(pid int) error {
r, _, err := ctrlEvent.Call(uintptr(syscall.CTRL_BREAK_EVENT), uintptr(pid))
if r == 0 {
return trace.Wrap(err)
}
return nil
}
173 changes: 173 additions & 0 deletions integration/autoupdate/tools/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* 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 tools_test

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/gravitational/trace"

"github.com/gravitational/teleport/integration/helpers"
)

const (
testBinaryName = "updater"
teleportToolsVersion = "TELEPORT_TOOLS_VERSION"
)

var (
// testVersions list of the pre-compiled binaries with encoded versions to check.
testVersions = []string{
"1.2.3",
"3.2.1",
}
limitedWriter = newLimitedResponseWriter()

toolsDir string
baseURL string
)

func TestMain(m *testing.M) {
ctx := context.Background()
tmp, err := os.MkdirTemp(os.TempDir(), testBinaryName)
if err != nil {
log.Fatalf("failed to create temporary directory: %v", err)
}

toolsDir, err = os.MkdirTemp(os.TempDir(), "tools")
if err != nil {
log.Fatalf("failed to create temporary directory: %v", err)
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
filePath := filepath.Join(tmp, r.URL.Path)
switch {
case strings.HasSuffix(r.URL.Path, ".sha256"):
serve256File(w, r, strings.TrimSuffix(filePath, ".sha256"))
default:
http.ServeFile(limitedWriter.Wrap(w), r, filePath)
}
}))
baseURL = server.URL
for _, version := range testVersions {
if err := buildAndArchiveApps(ctx, tmp, toolsDir, version, server.URL); err != nil {
log.Fatalf("failed to build testing app binary archive: %v", err)
}
}

// Run tests after binary is built.
code := m.Run()

server.Close()
if err := os.RemoveAll(tmp); err != nil {
log.Fatalf("failed to remove temporary directory: %v", err)
}
if err := os.RemoveAll(toolsDir); err != nil {
log.Fatalf("failed to remove tools directory: %v", err)
}

os.Exit(code)
}

// serve256File calculates sha256 checksum for requested file.
func serve256File(w http.ResponseWriter, _ *http.Request, filePath string) {
log.Printf("Calculating and serving file checksum: %s\n", filePath)

w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(filePath)+".sha256\"")
w.Header().Set("Content-Type", "plain/text")

file, err := os.Open(filePath)
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "file not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "failed to open file", http.StatusInternalServerError)
return
}
defer file.Close()

hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
http.Error(w, "failed to write to hash", http.StatusInternalServerError)
return
}
if _, err := hex.NewEncoder(w).Write(hash.Sum(nil)); err != nil {
http.Error(w, "failed to write checksum", http.StatusInternalServerError)
}
}

// buildAndArchiveApps compiles the updater integration and pack it depends on platform is used.
func buildAndArchiveApps(ctx context.Context, path string, toolsDir string, version string, baseURL string) error {
versionPath := filepath.Join(path, version)
for _, app := range []string{"tsh", "tctl"} {
output := filepath.Join(versionPath, app)
switch runtime.GOOS {
case "windows":
output = filepath.Join(versionPath, app+".exe")
case "darwin":
output = filepath.Join(versionPath, app+".app", "Contents", "MacOS", app)
}
if err := buildBinary(output, toolsDir, version, baseURL); err != nil {
return trace.Wrap(err)
}
}
switch runtime.GOOS {
case "darwin":
archivePath := filepath.Join(path, fmt.Sprintf("teleport-%s.pkg", version))
return trace.Wrap(helpers.CompressDirToPkgFile(ctx, versionPath, archivePath, "com.example.pkgtest"))
case "windows":
archivePath := filepath.Join(path, fmt.Sprintf("teleport-v%s-windows-amd64-bin.zip", version))
return trace.Wrap(helpers.CompressDirToZipFile(ctx, versionPath, archivePath))
default:
archivePath := filepath.Join(path, fmt.Sprintf("teleport-v%s-linux-%s-bin.tar.gz", version, runtime.GOARCH))
return trace.Wrap(helpers.CompressDirToTarGzFile(ctx, versionPath, archivePath))
}
}

// buildBinary executes command to build binary with updater logic only for testing.
func buildBinary(output string, toolsDir string, version string, baseURL string) error {
cmd := exec.Command(
"go", "build", "-o", output,
"-ldflags", strings.Join([]string{
fmt.Sprintf("-X 'main.toolsDir=%s'", toolsDir),
fmt.Sprintf("-X 'main.version=%s'", version),
fmt.Sprintf("-X 'main.baseURL=%s'", baseURL),
}, " "),
"./updater",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

return trace.Wrap(cmd.Run())
}
Loading

0 comments on commit a81e9b7

Please sign in to comment.