diff --git a/cli/cli/commands/kurtosis_context/context_switch/switch.go b/cli/cli/commands/kurtosis_context/context_switch/switch.go index bf83b4bfb3..3be173d623 100644 --- a/cli/cli/commands/kurtosis_context/context_switch/switch.go +++ b/cli/cli/commands/kurtosis_context/context_switch/switch.go @@ -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" @@ -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() @@ -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. @@ -147,6 +155,7 @@ func SwitchContext( }() logrus.Info("Successfully switched context") } + isContextSwitchSuccessful = true return nil } diff --git a/cli/cli/commands/portal/start/start.go b/cli/cli/commands/portal/start/start.go index 9d893eb19e..739bdfeee5 100644 --- a/cli/cli/commands/portal/start/start.go +++ b/cli/cli/commands/portal/start/start.go @@ -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" @@ -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 } diff --git a/cli/cli/helpers/portal_manager/binary_artifact_getter.go b/cli/cli/helpers/portal_manager/binary_artifact_getter.go index 1e9f9b8332..6af988969c 100644 --- a/cli/cli/helpers/portal_manager/binary_artifact_getter.go +++ b/cli/cli/helpers/portal_manager/binary_artifact_getter.go @@ -24,6 +24,8 @@ const ( portalBinaryFileMode = 0700 portalVersionFileMode = 0600 + portalTagLatest = "latest" + portalTag = "0.0.4" osArchitectureSeparator = "_" @@ -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") @@ -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) { @@ -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) @@ -162,6 +168,14 @@ 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") @@ -169,7 +183,6 @@ func extractAssetTgzToBinaryFileOnDisk(assetContent io.ReadCloser, assetVersion 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") } @@ -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") } diff --git a/cli/cli/helpers/portal_manager/portal_manager.go b/cli/cli/helpers/portal_manager/portal_manager.go index 1adae49c53..9d307f6c23 100644 --- a/cli/cli/helpers/portal_manager/portal_manager.go +++ b/cli/cli/helpers/portal_manager/portal_manager.go @@ -2,6 +2,12 @@ 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" @@ -9,10 +15,6 @@ import ( "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 ( @@ -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 { @@ -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.") } @@ -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 { @@ -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) +}