blob: 29b937f7fc646031ce35bc0647d27be4361667cf [file] [edit]
// Package gcs provides tools for interacting with GCS buckets and files.
package gcs
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"cloud.google.com/go/storage"
"google.golang.org/api/iterator"
)
const (
expectedGCSPathFmt = "gs://<bucket-name>/<directory_path>"
defaultFilePermissions = 0755
)
// GCSConfig stores the gcs bucket handler and a module gcs directory path.
type GCSConfig struct {
bucket *storage.BucketHandle
path string
}
func NewClient(ctx context.Context) (*storage.Client, error) {
client, err := storage.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create new GCS client: %v", err)
}
return client, err
}
// Init initializes the gcs bucket and module directory path from a
// gsutil uri.
func (c *GCSConfig) Init(ctx context.Context, gsUri string, client *storage.Client) error {
bucketName, dir, err := getBucketNameAndDir(gsUri)
if err != nil {
return fmt.Errorf("failed to retrieve bucket name and module path: %v", err)
}
// Creating bucket handle
bkt := client.Bucket(bucketName)
// Checking if directory exists and non-empty
it := bkt.Objects(ctx, &storage.Query{
Prefix: c.path,
})
if _, err = it.Next(); err != nil {
if err == iterator.Done {
return fmt.Errorf("failed to validate gcs directory (%s/%s): empty or non-existent directory", bucketName, dir)
}
return fmt.Errorf("failed to validate gcs directory (%s/%s) : %v", bucketName, dir, err)
}
c.bucket = bkt
c.path = dir
return nil
}
// ListModules returns a list of all the modules in a GCS directory.
// If recursive is set, it includes modules from the subdirectories of the gcs path.
// Identifies modules as files with .ko extension.
func (c *GCSConfig) ListModules(ctx context.Context, recursive bool) ([]string, error) {
// Querying for files in gcs directory.
var query storage.Query
if recursive {
query = storage.Query{
Prefix: c.path,
}
} else {
query = storage.Query{
Prefix: c.path,
Delimiter: "/",
}
}
it := c.bucket.Objects(ctx, &query)
var modules []string
for {
attrs, err := it.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, fmt.Errorf("failed to retrieve modules from the gcs directory (%s/%s): %v", c.bucketName(), c.path, err)
}
if filepath.Ext(attrs.Name) == ".ko" {
moduleName, _ := strings.CutPrefix(attrs.Name, c.path)
modules = append(modules, moduleName)
}
}
return modules, nil
}
// getModulePath safely joins the localDir and moduleName, ensuring the resulting path is within localDir.
func getModulePath(localDir, moduleName string) (string, error) {
if moduleName == "" {
return "", fmt.Errorf("moduleName cannot be empty")
}
dst := filepath.Join(localDir, moduleName)
absLocalDir, err := filepath.Abs(localDir)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for local directory (%s): %v", localDir, err)
}
absDst, err := filepath.Abs(dst)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for destination (%s): %v", dst, err)
}
if !strings.HasPrefix(absDst, absLocalDir) {
return "", fmt.Errorf("invalid module name, path traversal detected: %s", moduleName)
}
return absDst, nil
}
// DownloadKernelModule copies a module file from a GCS directory to a local directory.
// It returns the local file path of the downloaded module.
// If a file exists in that directory with the same name as the module, an error
// is returned to the user.
func (c *GCSConfig) DownloadKernelModule(ctx context.Context, moduleName, localDir string) (string, error) {
dst, err := getModulePath(localDir, moduleName)
if err != nil {
return "", fmt.Errorf("failed to get path for module (%s) in directory (%s): %v", moduleName, localDir, err)
}
src := filepath.Join(c.path, moduleName)
// Checking if there exists a file with the same name as module in directory
// if not, ensuring the path exists.
if _, err := os.Stat(dst); errors.Is(err, fs.ErrNotExist) {
if err := os.MkdirAll(filepath.Dir(dst), defaultFilePermissions); err != nil {
return "", fmt.Errorf("failed to create directory (%s): %v", filepath.Dir(dst), err)
}
} else if err == nil {
return "", fmt.Errorf("failed to download module (%s) to path (%s). File already exists", moduleName, dst)
} else {
return "", fmt.Errorf("failed to download module (%s) to path (%s): %v", moduleName, dst, err)
}
moduleObj := c.bucket.Object(src)
rc, err := moduleObj.NewReader(ctx)
if err != nil {
return "", fmt.Errorf("failed to read from module (%s) in gcs bucket (%s): %v", src, c.bucketName(), err)
}
defer rc.Close()
// Creating a file with the same name as the module in the local directory
f, err := os.Create(dst)
if err != nil {
return "", fmt.Errorf("failed to create file (%s): %v", dst, err)
}
defer f.Close()
if _, err := io.Copy(f, rc); err != nil {
return "", fmt.Errorf("failed to copy module (%s) from gcs bucket (%s): %v", src, c.bucketName(), err)
}
return dst, nil
}
func getBucketNameAndDir(gcsPath string) (string, string, error) {
filePath, isValid := strings.CutPrefix(gcsPath, "gs://")
if !isValid {
return "", "", fmt.Errorf("invalid syntax for gcs path. Expected gsutil uri format: %s \t got: %v", expectedGCSPathFmt, gcsPath)
}
bktName, path, isValid := strings.Cut(filePath, "/")
if !isValid || strings.TrimSpace(path) == "" {
return "", "", fmt.Errorf("file path (%s) should have both bucket name and path to module directory", filePath)
}
if !strings.HasSuffix(path, "/") {
path = fmt.Sprintf("%s/", path)
}
return bktName, path, nil
}
func (c *GCSConfig) bucketName() string {
if c.bucket == nil {
return ""
}
return c.bucket.BucketName()
}