Skip to content

Commit

Permalink
Activate step executable
Browse files Browse the repository at this point in the history
  • Loading branch information
ofalvai committed Aug 27, 2024
1 parent 5b7e38a commit 3b3418a
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 74 deletions.
99 changes: 30 additions & 69 deletions activator/steplib/activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,52 @@ 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 {
stepCollection, err := stepman.ReadStepSpec(stepLibURI)
if err != nil {
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)
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()
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)
return activateStepSource(stepCollection, stepLibURI, id, version, step, destination, destinationStepYML, log, isOfflineMode)
}

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)
log.Debugf("Downloaded executable in %s", time.Since(downloadStart).Round(time.Millisecond))
return nil
} else {
log.Infof("No prebuilt executable found for %s, falling back to source build", platform)
return activateStepSource(stepCollection, stepLibURI, id, version, step, destination, destinationStepYML, log, isOfflineMode)
}

return fmt.Errorf("failed to download step: %s", err)
}

if err := copyStep(srcFolder, destination); err != nil {
return fmt.Errorf("copy step failed: %s", err)
} else {
return activateStepSource(stepCollection, stepLibURI, id, version, step, destination, destinationStepYML, log, isOfflineMode)
}

if destinationStepYML != "" {
if err := copyStepYML(stepLibURI, id, version, destinationStepYML); err != nil {
return fmt.Errorf("copy step.yml failed: %s", 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 {
Expand All @@ -75,46 +70,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) {
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 {
Expand All @@ -138,8 +93,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)
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
}
Expand Down
84 changes: 84 additions & 0 deletions activator/steplib/activate_executable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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,
) error {
resp, err := retryablehttp.Get(executable.Url)
if err != nil {
return fmt.Errorf("fetch from %s: %w", executable.Url, err)
}
defer resp.Body.Close()

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 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
}
60 changes: 60 additions & 0 deletions activator/steplib/activate_executable_test.go
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)
}
})
}
}
77 changes: 77 additions & 0 deletions activator/steplib/activate_source.go
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
}
1 change: 1 addition & 0 deletions activator/steplib/testdata/file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hash verification test
Loading

0 comments on commit 3b3418a

Please sign in to comment.