Skip to content

Commit

Permalink
feat: Portal automatic start and stop on context change (#1086)
Browse files Browse the repository at this point in the history
## Description:
The user currently needs to make sure that the portal is running before
switching to a remote context. This change does that automatically for
the user. This change also stops the portal when the context switches
back to local.

Add the portal release tag to install so we can pin a portal version
instead of using the latest. The portal release tag can also be set to
"latest". This PR pins the portal version to 0.0.4

Upcoming changes:
- Release portal 0.0.5 containing the support for remote endpoints.
- Update the mono-repo to pass the remote endpoint type to the portal
client forward port calls. Set the portal version to latest.

## Is this change user facing?
YES

## References (if applicable):
Closes #970
  • Loading branch information
laurentluce committed Aug 10, 2023
1 parent ba53beb commit a6a73d1
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 53 deletions.
17 changes: 13 additions & 4 deletions cli/cli/commands/kurtosis_context/context_switch/switch.go
Expand Up @@ -3,6 +3,7 @@ package context_switch
import (
"context"
"fmt"

"github.com/kurtosis-tech/kurtosis-portal/api/golang/constructors"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/highlevel/context_id_arg"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel"
Expand Down Expand Up @@ -111,7 +112,10 @@ func SwitchContext(
}

portalManager := portal_manager.NewPortalManager()
if portalManager.IsReachable() {
if store.IsRemote(currentContext) {
if err := portalManager.StartRequiredVersion(ctx); err != nil {
return stacktrace.Propagate(err, "An error occurred starting the portal")
}
portalDaemonClient := portalManager.GetClient()
if portalDaemonClient != nil {
switchContextArg := constructors.NewSwitchContextArgs()
Expand All @@ -120,11 +124,15 @@ func SwitchContext(
}
}
} else {
if store.IsRemote(currentContext) {
return stacktrace.NewError("New context is remote but Kurtosis Portal is not reachable locally. " +
"Make sure Kurtosis Portal is running before switching to a remote context again.")
// We stop the portal when the user switches back to the local context.
// We do that to be consistent with the start above.
// However the portal is designed to also work with the local context with a client and server
// running locally.
if err := portalManager.StopExisting(ctx); err != nil {
return stacktrace.Propagate(err, "An error occurred stopping Kurtosis Portal")
}
}

logrus.Infof("Context switched to '%s', Kurtosis engine will now be restarted", contextIdentifier)

// Instantiate the engine manager after storing the new context so the manager can read it.
Expand All @@ -147,6 +155,7 @@ func SwitchContext(
}()
logrus.Info("Successfully switched context")
}

isContextSwitchSuccessful = true
return nil
}
23 changes: 8 additions & 15 deletions cli/cli/commands/portal/start/start.go
Expand Up @@ -2,6 +2,7 @@ package start

import (
"context"

"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel/args"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel/flags"
Expand All @@ -22,32 +23,24 @@ var PortalStartCmd = &lowlevel.LowlevelKurtosisCommand{
PostValidationAndRunFunc: nil,
}

func run(ctx context.Context, _ *flags.ParsedFlags, args *args.ParsedArgs) error {
logrus.Infof("Starting Kurtosis Portal")
func run(ctx context.Context, _ *flags.ParsedFlags, _ *args.ParsedArgs) error {
portalManager := portal_manager.NewPortalManager()

// Checking if new version is available and potentially downloading it
if _, err := portal_manager.DownloadLatestKurtosisPortalBinary(ctx); err != nil {
return stacktrace.Propagate(err, "An unexpected error occurred trying to download the latest version of Kurtosis Portal")
}

currentPortalPid, process, isPortalReachable, err := portalManager.CurrentStatus(ctx)
currentPortalPid, _, isPortalReachable, err := portalManager.CurrentStatus(ctx)
if err != nil {
return stacktrace.Propagate(err, "Unable to determine current state of Kurtosis Portal process")
}

// If there is a healthy running Portal, we do nothing since we don't want to break the current port forward.
// We could save the port forward state and restart the Portal but it is not yet implemented.
if isPortalReachable {
logrus.Infof("Portal is currently running on PID '%d' and healthy.", currentPortalPid)
return nil
}
if process != nil {
logrus.Warnf("A non-healthy Portal process is currently running on PID '%d'. Stop it first before starting a new one", currentPortalPid)
return nil
}

startedPortalPid, err := portalManager.StartNew(ctx)
logrus.Infof("Starting Kurtosis Portal")
err = portalManager.StartRequiredVersion(ctx)
if err != nil {
return stacktrace.Propagate(err, "Error starting portal")
}
logrus.Infof("Kurtosis Portal started successfully on PID %d", startedPortalPid)
return nil
}
73 changes: 43 additions & 30 deletions cli/cli/helpers/portal_manager/binary_artifact_getter.go
Expand Up @@ -24,6 +24,8 @@ const (

portalBinaryFileMode = 0700
portalVersionFileMode = 0600
portalTagLatest = "latest"
portalTag = "0.0.4"

osArchitectureSeparator = "_"

Expand All @@ -32,13 +34,13 @@ const (
numberOfAssetsPerPage = 50
)

// DownloadLatestKurtosisPortalBinary downloads the latest version of Kurtosis Portal.
// It returns true if a new version was downloaded and installed, false if it no-oped because latest was already
// installed or because latest version information could not be retrieved.
// DownloadRequiredKurtosisPortalBinary downloads the required version of Kurtosis Portal.
// It returns true if a new version was downloaded and installed, false if it no-oped because required was already
// installed or because required version information could not be retrieved.
// Note that it returns an error only if the end state is such that the portal cannot be run properly. I.e. if a
// Portal is already installed, and it failed to retrieve the latest version for example, it returns gracefully
// Portal is already installed, and it failed to retrieve the required version for example, it returns gracefully
// as this is not critical (current version will be used and will continue to run fine)
func DownloadLatestKurtosisPortalBinary(ctx context.Context) (bool, error) {
func DownloadRequiredKurtosisPortalBinary(ctx context.Context) (bool, error) {
binaryFilePath, err := host_machine_directories.GetPortalBinaryFilePath()
if err != nil {
return false, stacktrace.Propagate(err, "Unable to get file path to Kurtosis Portal binary file")
Expand All @@ -54,46 +56,50 @@ func DownloadLatestKurtosisPortalBinary(ctx context.Context) (bool, error) {
}

ghClient := github.NewClient(http.DefaultClient)
latestRelease, err := getLatestRelease(ctx, ghClient)
release, err := getRelease(ctx, ghClient)
if err != nil {
return false, defaultToCurrentVersionOrError(currentVersionStrMaybe, err)
}

latestVersionStr, currentVersionMatchesLatest, err := compareLatestVersionWithCurrent(currentVersionStrMaybe, latestRelease)
requiredVersionStr, currentVersionMatchesRequired, err := compareRequiredVersionWithCurrent(currentVersionStrMaybe, release)
if err != nil {
return false, defaultToCurrentVersionOrError(currentVersionStrMaybe, err)
}
if currentVersionMatchesLatest {
logrus.Infof("Kurtosis Portal version '%s' is the latest and already installed", latestVersionStr)
if currentVersionMatchesRequired {
logrus.Infof("Installed Kurtosis Portal version '%s' is the required one", requiredVersionStr)
return false, nil
}

if currentVersionStrMaybe == "" {
// no Portal is currently installed. Installing a brand new one
logrus.Infof("Installing Kurtosis Portal version '%s'", latestVersionStr)
logrus.Infof("Installing Kurtosis Portal version '%s'", requiredVersionStr)
} else {
logrus.Infof("Upgrading currently Kurtosis Portal version '%s' to '%s'", currentVersionStrMaybe, latestVersionStr)
logrus.Infof("Upgrading currently Kurtosis Portal version '%s' to '%s'", currentVersionStrMaybe, requiredVersionStr)
}

githubAssetContent, err := downloadGithubAsset(ctx, ghClient, latestRelease)
githubAssetContent, err := downloadGithubAsset(ctx, ghClient, release)
if err != nil {
return false, defaultToCurrentVersionOrError(currentVersionStrMaybe, err)
}

if err = extractAssetTgzToBinaryFileOnDisk(githubAssetContent, latestVersionStr, binaryFilePath, currentVersionFilePath); err != nil {
if err = extractAssetTgzToBinaryFileOnDisk(githubAssetContent, requiredVersionStr, binaryFilePath, currentVersionFilePath); err != nil {
return false, defaultToCurrentVersionOrError(currentVersionStrMaybe, err)
}
return true, nil
}

func getLatestRelease(ctx context.Context, ghClient *github.Client) (*github.RepositoryRelease, error) {
// First, browse the list of releases for the Kurtosis Portal repo
latestRelease, _, err := ghClient.Repositories.GetLatestRelease(ctx, kurtosisTechGithubOrg, kurtosisPortalGithubRepoName)
func getRelease(ctx context.Context, ghClient *github.Client) (*github.RepositoryRelease, error) {
var err error
var release *github.RepositoryRelease
if portalTag == portalTagLatest {
release, _, err = ghClient.Repositories.GetLatestRelease(ctx, kurtosisTechGithubOrg, kurtosisPortalGithubRepoName)
} else {
release, _, err = ghClient.Repositories.GetReleaseByTag(ctx, kurtosisTechGithubOrg, kurtosisPortalGithubRepoName, portalTag)
}
if err != nil {
return nil, stacktrace.Propagate(err, "Unable to retrieve latest version of Kurtosis Portal form GitHub")
return nil, stacktrace.Propagate(err, "Unable to retrieve version %s of Kurtosis Portal from GitHub", portalTag)
}
logrus.Debugf("Latest release is %s", latestRelease.GetName())
return latestRelease, nil
return release, nil
}

func getVersionCurrentlyInstalled(currentVersionFilePath string) (string, error) {
Expand All @@ -107,23 +113,23 @@ func getVersionCurrentlyInstalled(currentVersionFilePath string) (string, error)
return strings.TrimSpace(string(currentVersionBytes)), nil
}

func compareLatestVersionWithCurrent(currentVersionStrIfAny string, latestRelease *github.RepositoryRelease) (string, bool, error) {
latestVersionStr := latestRelease.GetName()
if currentVersionStrIfAny == latestVersionStr {
return latestVersionStr, true, nil
func compareRequiredVersionWithCurrent(currentVersionStrIfAny string, requiredRelease *github.RepositoryRelease) (string, bool, error) {
requiredVersionStr := requiredRelease.GetName()
if currentVersionStrIfAny == requiredVersionStr {
return requiredVersionStr, true, nil
}
return latestVersionStr, false, nil
return requiredVersionStr, false, nil
}

func downloadGithubAsset(ctx context.Context, ghClient *github.Client, latestRelease *github.RepositoryRelease) (io.ReadCloser, error) {
func downloadGithubAsset(ctx context.Context, ghClient *github.Client, release *github.RepositoryRelease) (io.ReadCloser, error) {
// Get all assets associated with this release and identify the one matching the current machine architecture
opts := &github.ListOptions{
Page: assetsFirstPageNumber,
PerPage: numberOfAssetsPerPage,
}
allReleaseAssets, _, err := ghClient.Repositories.ListReleaseAssets(ctx, kurtosisTechGithubOrg, kurtosisPortalGithubRepoName, latestRelease.GetID(), opts)
allReleaseAssets, _, err := ghClient.Repositories.ListReleaseAssets(ctx, kurtosisTechGithubOrg, kurtosisPortalGithubRepoName, release.GetID(), opts)
if err != nil {
return nil, stacktrace.Propagate(err, "Unable to get list of assets from latest version")
return nil, stacktrace.Propagate(err, "Unable to get list of assets from required version")
}
detectedOsArch := fmt.Sprintf("%s%s%s", runtime.GOOS, osArchitectureSeparator, runtime.GOARCH)
assetFileExpectedSuffix := fmt.Sprintf("%s%s", detectedOsArch, githubAssetExtension)
Expand Down Expand Up @@ -162,14 +168,21 @@ func extractAssetTgzToBinaryFileOnDisk(assetContent io.ReadCloser, assetVersion
return stacktrace.Propagate(err, "Archive seems to be a directory, but expecting a single binary file")
}

// Remove the existing Portal binary file since some OSes cache file signatures and complain that the content change
// when you just copy into the existing file. Darwin is an example.
err = os.Remove(destFilePath)
if err != nil && !os.IsNotExist(err) {
return stacktrace.Propagate(err, "Unable to remove the existing Kurtosis Portal binary file")
}

// Create a new empty Portal binary file and copy the asset content into it.
portalBinaryFile, err := os.Create(destFilePath)
if err != nil {
return stacktrace.Propagate(err, "Unable to create a new empty file to store Kurtosis Portal binary")
}
if err = os.Chmod(portalBinaryFile.Name(), portalBinaryFileMode); err != nil {
return stacktrace.Propagate(err, "Unable to switch file mode to executable for Kurtosis Portal binary file")
}

if _, err = io.Copy(portalBinaryFile, assetTarReader); err != nil {
return stacktrace.Propagate(err, "Unable to copy content of Kurtosis Portal binary to executable file")
}
Expand All @@ -185,9 +198,9 @@ func extractAssetTgzToBinaryFileOnDisk(assetContent io.ReadCloser, assetVersion

func defaultToCurrentVersionOrError(currentVersionStr string, nonNilError error) error {
if currentVersionStr != "" {
logrus.Warnf("Checking for latest version of Kurtosis Portal failed. Currently installed version '%s' will be used", currentVersionStr)
logrus.Warnf("Checking for required version of Kurtosis Portal failed. Currently installed version '%s' will be used", currentVersionStr)
logrus.Debugf("Error was: %v", nonNilError.Error())
return nil
}
return stacktrace.Propagate(nonNilError, "An error occurred installing Kurtosis Portal latest version")
return stacktrace.Propagate(nonNilError, "An error occurred installing Kurtosis Portal required version")
}
65 changes: 61 additions & 4 deletions cli/cli/helpers/portal_manager/portal_manager.go
Expand Up @@ -2,17 +2,19 @@ package portal_manager

import (
"context"
"os"
"os/exec"
"strconv"
"syscall"
"time"

portal_constructors "github.com/kurtosis-tech/kurtosis-portal/api/golang/constructors"
portal_generated_api "github.com/kurtosis-tech/kurtosis-portal/api/golang/generated"
"github.com/kurtosis-tech/kurtosis/api/golang/core/lib/services"
"github.com/kurtosis-tech/kurtosis/api/golang/engine/lib/kurtosis_context"
"github.com/kurtosis-tech/kurtosis/cli/cli/helpers/host_machine_directories"
"github.com/kurtosis-tech/stacktrace"
"github.com/sirupsen/logrus"
"os"
"os/exec"
"strconv"
"syscall"
)

const (
Expand All @@ -27,6 +29,9 @@ const (
"reached locally on its ports. This is unexpected and Kurtosis cannot stop it. Was the Portal started " +
"with something else then Kurtosis CLI? If that's the case, please kill the current Portal process and " +
"start it using Kurtosis CLI"

retries = 25
retriesDelayMilliseconds = 200
)

type PortalManager struct {
Expand Down Expand Up @@ -161,6 +166,9 @@ func (portalManager *PortalManager) StopExisting(_ context.Context) error {
logrus.Warnf("Error stopping currently running portal on PID: '%d'. It might already be stopped. "+
"PID file will be removed. Error was: %s", pid, err.Error())
}
if err = waitForTermination(process.Pid, retries, retriesDelayMilliseconds); err != nil {
logrus.Warnf("Error waiting for running portal on PID '%d' to terminate. Error was: %s", pid, err.Error())
}
} else {
logrus.Infof("Portal already stopped but PID file exists. Removing it.")
}
Expand Down Expand Up @@ -213,6 +221,37 @@ func (portalManager *PortalManager) MapPorts(ctx context.Context, localPortToRem
return successfullyMappedPorts, failedPorts, nil
}

// StartRequiredVersion downloads the required version and (re)starts the portal if the required version
// is not already running.
func (portalManager *PortalManager) StartRequiredVersion(ctx context.Context) error {
// Checking if new version is available and potentially downloading it
newVersionDownloaded, err := DownloadRequiredKurtosisPortalBinary(ctx)
if err != nil {
return stacktrace.Propagate(err, "An unexpected error occurred trying to download the required version of Kurtosis Portal")
}

currentPortalPid, _, isPortalReachable, err := portalManager.CurrentStatus(ctx)
if err != nil {
return stacktrace.Propagate(err, "Unable to determine current state of Kurtosis Portal process")
}

if isPortalReachable && !newVersionDownloaded {
logrus.Infof("Portal is currently running the required version on PID '%d' and healthy.", currentPortalPid)
return nil
}

if err := portalManager.StopExisting(ctx); err != nil {
return stacktrace.Propagate(err, "Error stopping Kurtosis Portal")
}

startedPortalPid, err := portalManager.StartNew(ctx)
if err != nil {
return stacktrace.Propagate(err, "Error starting portal")
}
logrus.Infof("Kurtosis Portal started successfully on PID %d", startedPortalPid)
return nil
}

func (portalManager *PortalManager) instantiateClientIfUnset() error {
portalDaemonClientMaybe, err := kurtosis_context.CreatePortalDaemonClient(true)
if err != nil {
Expand Down Expand Up @@ -263,3 +302,21 @@ func getRunningProcessFromPID(pid int) (*os.Process, error) {
}
return process, nil
}

// waitForTermination waits for the portal process running on pid to terminate
func waitForTermination(pid int, retries int, retriesDelayMilliseconds int) error {
logrus.Info("Waiting for the portal to terminate...")
for i := 0; i < retries; i++ {
process, err := getRunningProcessFromPID(pid)
if err != nil {
return stacktrace.Propagate(err, "Unexpected error getting process from pid '%d' while waiting for termination", pid)
}
if process == nil {
logrus.Info("Portal terminated")
return nil
}
time.Sleep(time.Duration(retriesDelayMilliseconds) * time.Millisecond)
}

return stacktrace.NewError("Portal did not terminate after %d retries", retries)
}

0 comments on commit a6a73d1

Please sign in to comment.