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 }