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

Client tools auto update #47466

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
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
}
193 changes: 193 additions & 0 deletions integration/autoupdate/tools/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
* 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"
"net/http"
"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)
}

var srv *http.Server
srv, baseURL = startTestHTTPServer(tmp)
for _, version := range testVersions {
if err := buildAndArchiveApps(ctx, tmp, toolsDir, version, baseURL); err != nil {
log.Fatalf("failed to build testing app binary archive: %v", err)
}
}

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

if err := srv.Close(); err != nil {
log.Fatalf("failed to shutdown server: %v", err)
}
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)
}
}

// startTestHTTPServer starts the file-serving HTTP server for testing.
func startTestHTTPServer(baseDir string) (*http.Server, string) {
vapopov marked this conversation as resolved.
Show resolved Hide resolved
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
filePath := filepath.Join(baseDir, 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)
}
})}

listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatalf("failed to create listener: %v", err)
}

go func() {
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("failed to start server: %s", err)
}
}()

return srv, listener.Addr().String()
}

// 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=http://%s'", baseURL),
}, " "),
"./updater",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

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