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

Activate step executable #334

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions activator/activator.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package activator
type ActivatedStep struct {
StepYMLPath string

ExecutablePath string

// DidStepLibUpdate indicates that the local steplib cache was updated while resolving the exact step version.
DidStepLibUpdate bool
}
112 changes: 38 additions & 74 deletions activator/steplib/activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Contributor Author

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)

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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this into both activateStepSource() and activateStepExecutable()

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 {
Expand All @@ -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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➡️ activate_source.go

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 +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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 activateStep() function that now calls this function.

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
89 changes: 89 additions & 0 deletions activator/steplib/activate_executable.go
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
}
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