-
Notifications
You must be signed in to change notification settings - Fork 12
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
Activate step executable #334
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,57 +4,55 @@ import ( | |
"fmt" | ||
"os" | ||
"path/filepath" | ||
"runtime" | ||
"slices" | ||
"time" | ||
|
||
"github.com/bitrise-io/go-utils/command" | ||
"github.com/bitrise-io/go-utils/pathutil" | ||
"github.com/bitrise-io/stepman/models" | ||
"github.com/bitrise-io/stepman/stepman" | ||
) | ||
|
||
var errStepNotAvailableOfflineMode error = fmt.Errorf("step not available in offline mode") | ||
const precompiledStepsEnv = "BITRISE_EXPERIMENT_PRECOMPILED_STEPS" | ||
|
||
func ActivateStep(stepLibURI, id, version, destination, destinationStepYML string, log stepman.Logger, isOfflineMode bool) error { | ||
func ActivateStep(stepLibURI, id, version, destination, destinationStepYML string, log stepman.Logger, isOfflineMode bool) (string, error) { | ||
stepCollection, err := stepman.ReadStepSpec(stepLibURI) | ||
if err != nil { | ||
return fmt.Errorf("failed to read %s steplib: %s", stepLibURI, err) | ||
return "", fmt.Errorf("failed to read %s steplib: %s", stepLibURI, err) | ||
} | ||
|
||
step, version, err := queryStep(stepCollection, stepLibURI, id, version) | ||
step, version, err := queryStepMetadata(stepCollection, stepLibURI, id, version) | ||
if err != nil { | ||
return fmt.Errorf("failed to find step: %s", err) | ||
} | ||
|
||
srcFolder, err := activateStep(stepCollection, stepLibURI, id, version, step, log, isOfflineMode) | ||
if err != nil { | ||
if err == errStepNotAvailableOfflineMode { | ||
availableVersions := ListCachedStepVersions(log, stepCollection, stepLibURI, id) | ||
versionList := "Other versions available in the local cache:" | ||
for _, version := range availableVersions { | ||
versionList = versionList + fmt.Sprintf("\n- %s", version) | ||
return "", fmt.Errorf("failed to find step: %s", err) | ||
} | ||
|
||
if os.Getenv(precompiledStepsEnv) == "true" { | ||
platform := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) | ||
executableForPlatform, ok := step.Executables[platform] | ||
if ok { | ||
log.Debugf("Downloading executable for %s", platform) | ||
downloadStart := time.Now() | ||
execPath, err := activateStepExecutable(stepLibURI, id, version, executableForPlatform, destination, destinationStepYML) | ||
if err != nil { | ||
log.Warnf("Failed to download step executable, falling back to source build: %s", err) | ||
err = activateStepSource(stepCollection, stepLibURI, id, version, step, destination, destinationStepYML, log, isOfflineMode) | ||
return "", err | ||
} | ||
|
||
errMsg := fmt.Sprintf("version is not available in the local cache and $BITRISE_OFFLINE_MODE is set. %s", versionList) | ||
return fmt.Errorf("failed to download step: %s", errMsg) | ||
} | ||
|
||
return fmt.Errorf("failed to download step: %s", err) | ||
} | ||
|
||
if err := copyStep(srcFolder, destination); err != nil { | ||
return fmt.Errorf("copy step failed: %s", err) | ||
} | ||
|
||
if destinationStepYML != "" { | ||
if err := copyStepYML(stepLibURI, id, version, destinationStepYML); err != nil { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved this into both |
||
return fmt.Errorf("copy step.yml failed: %s", err) | ||
log.Debugf("Downloaded executable in %s", time.Since(downloadStart).Round(time.Millisecond)) | ||
return execPath, nil | ||
} else { | ||
log.Infof("No prebuilt executable found for %s, falling back to source build", platform) | ||
err = activateStepSource(stepCollection, stepLibURI, id, version, step, destination, destinationStepYML, log, isOfflineMode) | ||
return "", err | ||
} | ||
} else { | ||
err = activateStepSource(stepCollection, stepLibURI, id, version, step, destination, destinationStepYML, log, isOfflineMode) | ||
return "", err | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func queryStep(stepLib models.StepCollectionModel, stepLibURI string, id, version string) (models.StepModel, string, error) { | ||
func queryStepMetadata(stepLib models.StepCollectionModel, stepLibURI string, id, version string) (models.StepModel, string, error) { | ||
step, stepFound, versionFound := stepLib.GetStep(id, version) | ||
|
||
if !stepFound { | ||
|
@@ -75,46 +73,6 @@ func queryStep(stepLib models.StepCollectionModel, stepLibURI string, id, versio | |
return step, version, nil | ||
} | ||
|
||
func activateStep(stepLib models.StepCollectionModel, stepLibURI, id, version string, step models.StepModel, log stepman.Logger, isOfflineMode bool) (string, error) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ➡️ |
||
route, found := stepman.ReadRoute(stepLibURI) | ||
if !found { | ||
return "", fmt.Errorf("no route found for %s steplib", stepLibURI) | ||
} | ||
|
||
stepCacheDir := stepman.GetStepCacheDirPath(route, id, version) | ||
if exist, err := pathutil.IsPathExists(stepCacheDir); err != nil { | ||
return "", fmt.Errorf("failed to check if %s path exist: %s", stepCacheDir, err) | ||
} else if exist { | ||
return stepCacheDir, nil | ||
} | ||
|
||
// version specific source cache not exists | ||
if isOfflineMode { | ||
return "", errStepNotAvailableOfflineMode | ||
} | ||
|
||
if err := stepman.DownloadStep(stepLibURI, stepLib, id, version, step.Source.Commit, log); err != nil { | ||
return "", fmt.Errorf("download failed: %s", err) | ||
} | ||
|
||
return stepCacheDir, nil | ||
} | ||
|
||
func copyStep(src, dst string) error { | ||
if exist, err := pathutil.IsPathExists(dst); err != nil { | ||
return fmt.Errorf("failed to check if %s path exist: %s", dst, err) | ||
} else if !exist { | ||
if err := os.MkdirAll(dst, 0777); err != nil { | ||
return fmt.Errorf("failed to create dir for %s path: %s", dst, err) | ||
} | ||
} | ||
|
||
if err := command.CopyDir(src+"/", dst, true); err != nil { | ||
return fmt.Errorf("copy command failed: %s", err) | ||
} | ||
return nil | ||
} | ||
|
||
func copyStepYML(libraryURL, id, version, dest string) error { | ||
route, found := stepman.ReadRoute(libraryURL) | ||
if !found { | ||
|
@@ -138,8 +96,14 @@ func copyStepYML(libraryURL, id, version, dest string) error { | |
func ListCachedStepVersions(log stepman.Logger, stepLib models.StepCollectionModel, stepLibURI, stepID string) []string { | ||
versions := []models.Semver{} | ||
|
||
for version, step := range stepLib.Steps[stepID].Versions { | ||
_, err := activateStep(stepLib, stepLibURI, stepID, version, step, log, true) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was doing a full step activation just to check the error. I replaced this with a proper stepman call There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Technically it is not a full update, as there is an offline flag set in the last parameter. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, true. The reason I had to replace this is the circular dependency between the |
||
route, found := stepman.ReadRoute(stepLibURI) | ||
if !found { | ||
return nil | ||
} | ||
|
||
for version := range stepLib.Steps[stepID].Versions { | ||
stepCacheDir := stepman.GetStepCacheDirPath(route, stepID, version) | ||
_, err := os.Stat(stepCacheDir) | ||
if err != nil { | ||
continue | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
package steplib | ||
|
||
import ( | ||
"crypto/sha256" | ||
"encoding/hex" | ||
"fmt" | ||
"io" | ||
"os" | ||
"path/filepath" | ||
"strings" | ||
|
||
"github.com/bitrise-io/stepman/models" | ||
"github.com/hashicorp/go-retryablehttp" | ||
) | ||
|
||
func activateStepExecutable( | ||
stepLibURI string, | ||
stepID string, | ||
version string, | ||
executable models.Executable, | ||
destination string, | ||
destinationStepYML string, | ||
) (string, error) { | ||
resp, err := retryablehttp.Get(executable.Url) | ||
if err != nil { | ||
return "", fmt.Errorf("fetch from %s: %w", executable.Url, err) | ||
} | ||
defer resp.Body.Close() | ||
|
||
err = os.MkdirAll(destination, 0755) | ||
if err != nil { | ||
return "", fmt.Errorf("create directory %s: %w", destination, err) | ||
} | ||
|
||
path := filepath.Join(destination, stepID) | ||
file, err := os.Create(path) | ||
if err != nil { | ||
return "", fmt.Errorf("create file %s: %w", path, err) | ||
} | ||
|
||
_, err = io.Copy(file, resp.Body) | ||
if err != nil { | ||
return "", fmt.Errorf("download %s to %s: %w", executable.Url, path, err) | ||
} | ||
|
||
err = validateHash(path, executable.Hash) | ||
if err != nil { | ||
return "", fmt.Errorf("validate hash: %s", err) | ||
} | ||
|
||
err = os.Chmod(path, 0755) | ||
if err != nil { | ||
return "", fmt.Errorf("set executable permission on file: %s", err) | ||
} | ||
|
||
if err := copyStepYML(stepLibURI, stepID, version, destinationStepYML); err != nil { | ||
return "", fmt.Errorf("copy step.yml: %s", err) | ||
} | ||
|
||
return path, nil | ||
} | ||
|
||
func validateHash(filePath string, expectedHash string) error { | ||
if expectedHash == "" { | ||
return fmt.Errorf("hash is empty") | ||
} | ||
|
||
if !strings.HasPrefix(expectedHash, "sha256-") { | ||
return fmt.Errorf("only SHA256 hashes supported at this time, make sure to prefix the hash with `sha256-`. Found hash value: %s", expectedHash) | ||
} | ||
|
||
expectedHash = strings.TrimPrefix(expectedHash, "sha256-") | ||
|
||
reader, err := os.Open(filePath) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
h := sha256.New() | ||
_, err = io.Copy(h, reader) | ||
if err != nil { | ||
return fmt.Errorf("calculate hash: %w", err) | ||
} | ||
actualHash := hex.EncodeToString(h.Sum(nil)) | ||
if actualHash != expectedHash { | ||
return fmt.Errorf("hash mismatch: expected sha256-%s, got sha256-%s", expectedHash, actualHash) | ||
} | ||
return nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
package steplib | ||
|
||
import ( | ||
"fmt" | ||
"testing" | ||
) | ||
|
||
|
||
func TestValidateHash(t *testing.T) { | ||
tests := []struct { | ||
name string | ||
filePath string | ||
expectedHash string | ||
expectedErr error | ||
}{ | ||
{ | ||
name: "Valid hash", | ||
filePath: "testdata/file.txt", | ||
expectedHash: "sha256-f2040af3939f5033be8ca9b363055b3e53107c4688ba39b71d4529869a9cc9b2", | ||
expectedErr: nil, | ||
}, | ||
{ | ||
name: "Hash mismatch", | ||
filePath: "testdata/file.txt", | ||
expectedHash: "sha256-1234567890abcdef", | ||
expectedErr: fmt.Errorf("hash mismatch: expected sha256-1234567890abcdef, got sha256-f2040af3939f5033be8ca9b363055b3e53107c4688ba39b71d4529869a9cc9b2"), | ||
}, | ||
{ | ||
name: "Nonexistent file", | ||
filePath: "testdata/nonexistent.txt", | ||
expectedHash: "sha256-3b6b4f1e2e8b8a9e4f7a4b5e6c7d8e9f", | ||
expectedErr: fmt.Errorf("open testdata/nonexistent.txt: no such file or directory"), | ||
}, | ||
{ | ||
name: "Empty hash", | ||
filePath: "testdata/file.txt", | ||
expectedHash: "", | ||
expectedErr: fmt.Errorf("hash is empty"), | ||
}, | ||
{ | ||
name: "Invalid hash type", | ||
filePath: "testdata/file.txt", | ||
expectedHash: "md5-3b6b4f1e2e8b8a9e4f7a4b5e6c7d8e9f", | ||
expectedErr: fmt.Errorf("only SHA256 hashes supported at this time, make sure to prefix the hash with `sha256-`. Found hash value: md5-3b6b4f1e2e8b8a9e4f7a4b5e6c7d8e9f"), | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
err := validateHash(tt.filePath, tt.expectedHash) | ||
if err != nil && tt.expectedErr == nil { | ||
t.Errorf("unexpected error: %s", err) | ||
} else if err == nil && tt.expectedErr != nil { | ||
t.Errorf("expected error: %s, but got nil", tt.expectedErr) | ||
} else if err != nil && tt.expectedErr != nil && err.Error() != tt.expectedErr.Error() { | ||
t.Errorf("expected error: %s, but got: %s", tt.expectedErr, err) | ||
} | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
package steplib | ||
|
||
import ( | ||
"fmt" | ||
"os" | ||
|
||
"github.com/bitrise-io/go-utils/command" | ||
"github.com/bitrise-io/go-utils/pathutil" | ||
"github.com/bitrise-io/stepman/models" | ||
"github.com/bitrise-io/stepman/stepman" | ||
) | ||
|
||
func activateStepSource( | ||
stepLib models.StepCollectionModel, | ||
stepLibURI, id, version string, | ||
step models.StepModel, | ||
destination string, | ||
stepYMLDestination string, | ||
log stepman.Logger, | ||
isOfflineMode bool, | ||
) error { | ||
route, found := stepman.ReadRoute(stepLibURI) | ||
if !found { | ||
return fmt.Errorf("no route found for %s steplib", stepLibURI) | ||
} | ||
|
||
stepCacheDir := stepman.GetStepCacheDirPath(route, id, version) | ||
if exist, err := pathutil.IsPathExists(stepCacheDir); err != nil { | ||
return fmt.Errorf("failed to check if %s path exist: %s", stepCacheDir, err) | ||
} else if exist { | ||
if err := copyStep(stepCacheDir, destination); err != nil { | ||
return fmt.Errorf("copy step failed: %s", err) | ||
} | ||
return nil | ||
} | ||
|
||
// version specific source cache not exists | ||
if isOfflineMode { | ||
availableVersions := ListCachedStepVersions(log, stepLib, stepLibURI, id) | ||
versionList := "Other versions available in the local cache:" | ||
for _, version := range availableVersions { | ||
versionList = versionList + fmt.Sprintf("\n- %s", version) | ||
} | ||
|
||
errMsg := fmt.Sprintf("version is not available in the local cache and $BITRISE_OFFLINE_MODE is set. %s", versionList) | ||
return fmt.Errorf("download step: %s", errMsg) | ||
} | ||
|
||
if err := stepman.DownloadStep(stepLibURI, stepLib, id, version, step.Source.Commit, log); err != nil { | ||
return fmt.Errorf("download failed: %s", err) | ||
} | ||
|
||
if err := copyStep(stepCacheDir, destination); err != nil { | ||
return fmt.Errorf("copy step failed: %s", err) | ||
} | ||
|
||
if err := copyStepYML(stepLibURI, id, version, stepYMLDestination); err != nil { | ||
return fmt.Errorf("copy step.yml failed: %s", err) | ||
} | ||
|
||
return nil | ||
} | ||
|
||
func copyStep(src, dst string) error { | ||
if exist, err := pathutil.IsPathExists(dst); err != nil { | ||
return fmt.Errorf("failed to check if %s path exist: %s", dst, err) | ||
} else if !exist { | ||
if err := os.MkdirAll(dst, 0777); err != nil { | ||
return fmt.Errorf("failed to create dir for %s path: %s", dst, err) | ||
} | ||
} | ||
|
||
if err := command.CopyDir(src+"/", dst, true); err != nil { | ||
return fmt.Errorf("copy command failed: %s", err) | ||
} | ||
return nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
hash verification test |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inlined this error handling into
activateStepSource()
because offline mode needs to be reworked for step executables anyway (we only cache the step source now, we'll need to cache the executables themselves)