github.com/grafana/pyroscope@v1.18.0/pkg/adhocprofiles/adhocprofiles.go (about)

     1  package adhocprofiles
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/rand"
     7  	"encoding/base64"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"slices"
    12  	"strings"
    13  	"time"
    14  
    15  	"connectrpc.com/connect"
    16  	"github.com/dustin/go-humanize"
    17  	"github.com/go-kit/log"
    18  	"github.com/go-kit/log/level"
    19  	"github.com/grafana/dskit/services"
    20  	"github.com/grafana/dskit/tenant"
    21  	"github.com/oklog/ulid/v2"
    22  	"github.com/pkg/errors"
    23  
    24  	v1 "github.com/grafana/pyroscope/api/gen/proto/go/adhocprofiles/v1"
    25  	"github.com/grafana/pyroscope/pkg/objstore"
    26  	"github.com/grafana/pyroscope/pkg/og/structs/flamebearer"
    27  	"github.com/grafana/pyroscope/pkg/og/structs/flamebearer/convert"
    28  	"github.com/grafana/pyroscope/pkg/pprof"
    29  	"github.com/grafana/pyroscope/pkg/validation"
    30  )
    31  
    32  type AdHocProfiles struct {
    33  	services.Service
    34  
    35  	logger log.Logger
    36  	limits Limits
    37  	bucket objstore.Bucket
    38  }
    39  
    40  type Limits interface {
    41  	validation.FlameGraphLimits
    42  	MaxProfileSizeBytes(tenantID string) int
    43  }
    44  
    45  type AdHocProfile struct {
    46  	Name       string    `json:"name"`
    47  	Data       string    `json:"data"`
    48  	UploadedAt time.Time `json:"uploadedAt"`
    49  }
    50  
    51  func validRunes(r rune) bool {
    52  	if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-' || r == '_' {
    53  		return true
    54  	}
    55  	return false
    56  }
    57  
    58  // check if the id is valid
    59  func validID(id string) bool {
    60  	for _, r := range id {
    61  		if !validRunes(r) {
    62  			return false
    63  		}
    64  	}
    65  	return true
    66  }
    67  
    68  // replaces invalid runes in the id with underscores
    69  func replaceInvalidRunes(id string) string {
    70  	return strings.Map(func(r rune) rune {
    71  		if validRunes(r) {
    72  			return r
    73  		}
    74  		return '_'
    75  	}, id)
    76  }
    77  
    78  func NewAdHocProfiles(bucket objstore.Bucket, logger log.Logger, limits Limits) *AdHocProfiles {
    79  	a := &AdHocProfiles{
    80  		logger: logger,
    81  		bucket: bucket,
    82  		limits: limits,
    83  	}
    84  	a.Service = services.NewBasicService(nil, a.running, nil)
    85  	return a
    86  }
    87  
    88  func (a *AdHocProfiles) running(ctx context.Context) error {
    89  	<-ctx.Done()
    90  	return nil
    91  }
    92  
    93  func (a *AdHocProfiles) Upload(ctx context.Context, c *connect.Request[v1.AdHocProfilesUploadRequest]) (*connect.Response[v1.AdHocProfilesGetResponse], error) {
    94  	tenantID, err := tenant.TenantID(ctx)
    95  	if err != nil {
    96  		return nil, connect.NewError(connect.CodeInvalidArgument, err)
    97  	}
    98  
    99  	adHocProfile := AdHocProfile{
   100  		Name:       c.Msg.Name,
   101  		Data:       c.Msg.Profile,
   102  		UploadedAt: time.Now().UTC(),
   103  	}
   104  
   105  	// replace runes outside of [a-zA-Z0-9_-.] with underscores
   106  	adHocProfile.Name = replaceInvalidRunes(adHocProfile.Name)
   107  
   108  	limits, err := a.newConvertLimits(tenantID, c.Msg.GetMaxNodes())
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  
   113  	// TODO: Add more per-tenant upload limits (number of files, total size, etc.)
   114  	if limits.MaxProfileSizeBytes > 0 && len(adHocProfile.Data) > limits.MaxProfileSizeBytes {
   115  		return nil, connect.NewError(connect.CodeInvalidArgument, validation.NewErrorf(validation.ProfileSizeLimit, "profile payload size exceeds limit of %s", humanize.Bytes(uint64(limits.MaxProfileSizeBytes))))
   116  	}
   117  
   118  	profile, profileTypes, err := parse(&adHocProfile, nil, limits)
   119  	if err != nil {
   120  		dsErr := new(pprof.ErrDecompressedSizeExceedsLimit)
   121  		if errors.As(err, &dsErr) {
   122  			return nil, connect.NewError(connect.CodeInvalidArgument, validation.NewErrorf(validation.ProfileSizeLimit, "uncompressed profile payload size exceeds limit of %s", humanize.Bytes(uint64(dsErr.Limit))))
   123  		}
   124  		return nil, errors.Wrapf(err, "failed to parse profile")
   125  	}
   126  
   127  	bucket := a.getBucket(tenantID)
   128  
   129  	uid := ulid.MustNew(ulid.Timestamp(adHocProfile.UploadedAt), rand.Reader)
   130  	id := strings.Join([]string{uid.String(), adHocProfile.Name}, "-")
   131  
   132  	dataToStore, err := json.Marshal(adHocProfile)
   133  	if err != nil {
   134  		return nil, errors.Wrapf(err, "failed to upload profile")
   135  	}
   136  
   137  	err = bucket.Upload(ctx, id, bytes.NewReader(dataToStore))
   138  	if err != nil {
   139  		return nil, errors.Wrapf(err, "failed to upload profile")
   140  	}
   141  
   142  	jsonProfile, err := json.Marshal(profile)
   143  	if err != nil {
   144  		return nil, errors.Wrapf(err, "failed to parse profile")
   145  	}
   146  
   147  	return connect.NewResponse(&v1.AdHocProfilesGetResponse{
   148  		Id:                 id,
   149  		Name:               adHocProfile.Name,
   150  		UploadedAt:         adHocProfile.UploadedAt.UnixMilli(),
   151  		FlamebearerProfile: string(jsonProfile),
   152  		ProfileType:        profile.Metadata.Name,
   153  		ProfileTypes:       profileTypes,
   154  	}), nil
   155  }
   156  
   157  func (a *AdHocProfiles) newConvertLimits(tenantID string, msgMaxNodes int64) (convert.Limits, error) {
   158  	maxNodes, err := validation.ValidateMaxNodes(a.limits, []string{tenantID}, msgMaxNodes)
   159  	if err != nil {
   160  		return convert.Limits{}, errors.Wrapf(err, "could not determine max nodes")
   161  	}
   162  
   163  	return convert.Limits{
   164  		MaxNodes:            int(maxNodes),
   165  		MaxProfileSizeBytes: a.limits.MaxProfileSizeBytes(tenantID),
   166  	}, nil
   167  }
   168  
   169  func (a *AdHocProfiles) Get(ctx context.Context, c *connect.Request[v1.AdHocProfilesGetRequest]) (*connect.Response[v1.AdHocProfilesGetResponse], error) {
   170  	tenantID, err := tenant.TenantID(ctx)
   171  	if err != nil {
   172  		return nil, connect.NewError(connect.CodeInvalidArgument, err)
   173  	}
   174  
   175  	bucket := a.getBucket(tenantID)
   176  
   177  	id := c.Msg.GetId()
   178  	if !validID(id) {
   179  		return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("id '%s' is invalid: can only contain [a-zA-Z0-9_-.]", id))
   180  	}
   181  
   182  	reader, err := bucket.Get(ctx, id)
   183  	if err != nil {
   184  		return nil, errors.Wrapf(err, "failed to get profile")
   185  	}
   186  	defer func() {
   187  		_ = reader.Close()
   188  	}()
   189  
   190  	adHocProfileBytes, err := io.ReadAll(reader)
   191  	if err != nil {
   192  		return nil, err
   193  	}
   194  
   195  	var adHocProfile AdHocProfile
   196  	err = json.Unmarshal(adHocProfileBytes, &adHocProfile)
   197  	if err != nil {
   198  		return nil, err
   199  	}
   200  
   201  	limits, err := a.newConvertLimits(tenantID, c.Msg.GetMaxNodes())
   202  	if err != nil {
   203  		return nil, err
   204  	}
   205  
   206  	profile, profileTypes, err := parse(&adHocProfile, c.Msg.ProfileType, limits)
   207  	if err != nil {
   208  		return nil, errors.Wrapf(err, "failed to parse profile")
   209  	}
   210  
   211  	jsonProfile, err := json.Marshal(profile)
   212  	if err != nil {
   213  		return nil, errors.Wrapf(err, "failed to parse profile")
   214  	}
   215  
   216  	return connect.NewResponse(&v1.AdHocProfilesGetResponse{
   217  		Id:                 c.Msg.Id,
   218  		Name:               adHocProfile.Name,
   219  		UploadedAt:         adHocProfile.UploadedAt.UnixMilli(),
   220  		FlamebearerProfile: string(jsonProfile),
   221  		ProfileType:        profile.Metadata.Name,
   222  		ProfileTypes:       profileTypes,
   223  	}), nil
   224  }
   225  
   226  func (a *AdHocProfiles) List(ctx context.Context, c *connect.Request[v1.AdHocProfilesListRequest]) (*connect.Response[v1.AdHocProfilesListResponse], error) {
   227  	bucket, err := a.getBucketFromContext(ctx)
   228  	if err != nil {
   229  		return nil, err
   230  	}
   231  
   232  	profiles := make([]*v1.AdHocProfilesProfileMetadata, 0)
   233  	err = bucket.Iter(ctx, "", func(s string) error {
   234  		// do not list elements with invalid ids
   235  		if !validID(s) {
   236  			return nil
   237  		}
   238  
   239  		separatorIndex := strings.IndexRune(s, '-')
   240  		id, err := ulid.Parse(s[0:separatorIndex])
   241  		if err != nil {
   242  			level.Warn(a.logger).Log("msg", "cannot parse ad hoc profile", "key", s, "err", err)
   243  			return nil
   244  		}
   245  		name := s[separatorIndex+1:]
   246  		profiles = append(profiles, &v1.AdHocProfilesProfileMetadata{
   247  			Id:         s,
   248  			Name:       name,
   249  			UploadedAt: int64(id.Time()),
   250  		})
   251  		return nil
   252  	})
   253  	cmp := func(a, b *v1.AdHocProfilesProfileMetadata) int {
   254  		if a.UploadedAt < b.UploadedAt {
   255  			return 1
   256  		}
   257  		if a.UploadedAt > b.UploadedAt {
   258  			return -1
   259  		}
   260  		return 0
   261  	}
   262  	slices.SortFunc(profiles, cmp)
   263  	if err != nil {
   264  		return nil, err
   265  	}
   266  	return connect.NewResponse(&v1.AdHocProfilesListResponse{Profiles: profiles}), nil
   267  }
   268  
   269  func (a *AdHocProfiles) getBucketFromContext(ctx context.Context) (objstore.Bucket, error) {
   270  	tenantID, err := tenant.TenantID(ctx)
   271  	if err != nil {
   272  		return nil, connect.NewError(connect.CodeInvalidArgument, err)
   273  	}
   274  	return a.getBucket(tenantID), nil
   275  }
   276  
   277  func (a *AdHocProfiles) getBucket(tenantID string) objstore.Bucket {
   278  	return objstore.NewPrefixedBucket(a.bucket, tenantID+"/adhoc")
   279  }
   280  
   281  func parse(p *AdHocProfile, profileType *string, limits convert.Limits) (fg *flamebearer.FlamebearerProfile, profileTypes []string, err error) {
   282  	base64decoded, err := base64.StdEncoding.DecodeString(p.Data)
   283  	if err != nil {
   284  		return nil, nil, errors.Wrapf(err, "failed to upload profile")
   285  	}
   286  
   287  	f := convert.ProfileFile{
   288  		Name:     p.Name,
   289  		TypeData: convert.ProfileFileTypeData{},
   290  		Data:     base64decoded,
   291  	}
   292  
   293  	profiles, err := convert.FlamebearerFromFile(f, limits)
   294  	if err != nil {
   295  		return nil, nil, err
   296  	}
   297  	if len(profiles) == 0 {
   298  		return nil, nil, errors.Wrapf(err, "no profiles found after parsing")
   299  	}
   300  
   301  	profileTypes = make([]string, 0)
   302  	profileTypeIndex := -1
   303  	for i, p := range profiles {
   304  		profileTypes = append(profileTypes, p.Metadata.Name)
   305  		if profileType != nil && p.Metadata.Name == *profileType {
   306  			profileTypeIndex = i
   307  		}
   308  	}
   309  
   310  	var profile *flamebearer.FlamebearerProfile
   311  	if profileTypeIndex >= 0 {
   312  		profile = profiles[profileTypeIndex]
   313  	} else {
   314  		profile = profiles[0]
   315  	}
   316  
   317  	return profile, profileTypes, nil
   318  }