go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/swarming/server/model/utils.go (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package model 16 17 import ( 18 "bytes" 19 "context" 20 "encoding/json" 21 "io" 22 "sort" 23 "strconv" 24 "strings" 25 26 "github.com/klauspost/compress/zlib" 27 28 "go.chromium.org/luci/common/errors" 29 "go.chromium.org/luci/gae/service/datastore" 30 31 apipb "go.chromium.org/luci/swarming/proto/api_v2" 32 ) 33 34 // checkIsHex returns an error if the string doesn't look like a lowercase hex 35 // string. 36 func checkIsHex(s string, minLen int) error { 37 if len(s) < minLen { 38 return errors.New("too small") 39 } 40 for _, c := range s { 41 if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { 42 return errors.Reason("bad lowercase hex string %q, wrong char %c", s, c).Err() 43 } 44 } 45 return nil 46 } 47 48 // ToJSONProperty serializes a value into a JSON blob property. 49 // 50 // Empty maps and lists are stored as nulls. 51 func ToJSONProperty(val any) (datastore.Property, error) { 52 if val == nil { 53 return datastore.MkPropertyNI(nil), nil 54 } 55 blob, err := json.Marshal(val) 56 if bytes.Equal(blob, []byte("{}")) || bytes.Equal(blob, []byte("[]")) { 57 return datastore.MkPropertyNI(nil), nil 58 } 59 return datastore.MkPropertyNI(string(blob)), err 60 } 61 62 // FromJSONProperty deserializes a JSON blob property into `val`. 63 // 64 // If the property is missing, `val` will be unchanged. Assumes `val` is a list 65 // or a dict. 66 // 67 // Recognizes zlib-compressed properties for compatibility with older entities. 68 func FromJSONProperty(prop datastore.Property, val any) error { 69 propVal, err := prop.Project(datastore.PTBytes) 70 if err != nil { 71 return err 72 } 73 blob, _ := propVal.([]byte) 74 if len(blob) == 0 { 75 return nil 76 } 77 78 // This seems to be an uncompressed JSON. Load it as is. Note that zlib 79 // compressed data always starts with 0x78 byte (part of the zlib header). 80 if blob[0] == '{' || blob[0] == '[' { 81 return json.Unmarshal(blob, val) 82 } 83 84 // If this doesn't look like JSON, this is likely an older zlib-compressed 85 // property. Try to uncompress and load it. 86 r, err := zlib.NewReader(bytes.NewBuffer(blob)) 87 if err != nil { 88 return err 89 } 90 w := bytes.NewBuffer(nil) 91 if _, err := io.Copy(w, r); err != nil { 92 _ = r.Close() 93 return err 94 } 95 if err := r.Close(); err != nil { 96 return err 97 } 98 return json.Unmarshal(w.Bytes(), val) 99 } 100 101 // LegacyProperty is a placeholder for "recognizing" known legacy properties. 102 // 103 // Properties of this type are silently discarded when read (and consequently 104 // not stored back when written). This is useful for dropping properties that 105 // were known to exist at some point, but which are no longer used by anything 106 // at all. If we just ignore them completely, they'll end up in `Extra` maps, 107 // which we want to avoid (`Extra` is only for truly unexpected properties). 108 type LegacyProperty struct{} 109 110 var _ datastore.PropertyConverter = &LegacyProperty{} 111 112 // FromProperty implements datastore.PropertyConverter. 113 func (*LegacyProperty) FromProperty(p datastore.Property) error { 114 return nil 115 } 116 117 // ToProperty implements datastore.PropertyConverter. 118 func (*LegacyProperty) ToProperty() (datastore.Property, error) { 119 return datastore.Property{}, datastore.ErrSkipProperty 120 } 121 122 // SortStringPairs sorts string pairs. 123 // This was stolen from go.chromium.org/luci/buildbucket/protoutil/tag.go 124 // and should probably be moved to go.chromium.org/luci/common, but that would 125 // require a larger refactor, hence the following: 126 // TODO (crbug.com/1508908): remove this once refactored. 127 func SortStringPairs(pairs []*apipb.StringPair) { 128 sort.Slice(pairs, func(i, j int) bool { 129 switch { 130 case pairs[i].Key < pairs[j].Key: 131 return true 132 case pairs[i].Key > pairs[j].Key: 133 return false 134 default: 135 return pairs[i].Value < pairs[j].Value 136 } 137 }) 138 } 139 140 // dimensionsFlatToPb converts a list of k:v pairs into []*apipb.StringListPair. 141 func dimensionsFlatToPb(flat []string) []*apipb.StringListPair { 142 // In the vast majority of cases `flat` is already sorted and we can skip 143 // unnecessary maps and resorting. Start with the assumption it is sorted and 144 // fallback to a generic implementation if we notice a violation. 145 var out []*apipb.StringListPair 146 for _, kv := range flat { 147 k, v, _ := strings.Cut(kv, ":") 148 if len(out) == 0 { 149 out = append(out, &apipb.StringListPair{ 150 Key: k, 151 Value: []string{v}, 152 }) 153 continue 154 } 155 switch prev := out[len(out)-1]; { 156 case k == prev.Key: 157 switch prevV := prev.Value[len(prev.Value)-1]; { 158 case v == prevV: 159 // Skip the duplicate. 160 case v > prevV: 161 prev.Value = append(prev.Value, v) 162 default: // v < prevV => the `flat` is not sorted in ascending order 163 return dimensionsFlatToPbSlow(flat) 164 } 165 case k > prev.Key: 166 out = append(out, &apipb.StringListPair{ 167 Key: k, 168 Value: []string{v}, 169 }) 170 default: // i.e. k < prev.Key => the `flat` is not sorted in ascending order 171 return dimensionsFlatToPbSlow(flat) 172 } 173 } 174 return out 175 } 176 177 // dimensionsFlatToPbSlow is the same as dimensionsFlatToPb, but it doesn't rely 178 // on `flat` being presorted. 179 func dimensionsFlatToPbSlow(flat []string) []*apipb.StringListPair { 180 sortedCopy := append(make([]string, 0, len(flat)), flat...) 181 sort.Strings(sortedCopy) 182 return dimensionsFlatToPb(sortedCopy) 183 } 184 185 // MapToStringListPair converts a map[string][]string to []*apipb.StringListPair. 186 // If keySorting, sorting is applied to the keys. 187 func MapToStringListPair(p map[string][]string, keySorting bool) []*apipb.StringListPair { 188 if len(p) == 0 { 189 return nil 190 } 191 keys := make([]string, 0, len(p)) 192 for k := range p { 193 keys = append(keys, k) 194 } 195 if keySorting { 196 sort.Strings(keys) 197 } 198 slp := make([]*apipb.StringListPair, len(keys)) 199 for i, key := range keys { 200 slp[i] = &apipb.StringListPair{ 201 Key: key, 202 Value: p[key], 203 } 204 } 205 return slp 206 } 207 208 // PutMockTaskOutput is a testing util that will create mock TaskOutputChunk datastore entities. 209 func PutMockTaskOutput(ctx context.Context, reqKey *datastore.Key, numChunks int) { 210 toPut := make([]*TaskOutputChunk, numChunks) 211 for i := 0; i < numChunks; i++ { 212 expectedStr := strings.Repeat(strconv.Itoa(i), ChunkSize) 213 var b bytes.Buffer 214 w := zlib.NewWriter(&b) 215 _, err := w.Write([]byte(expectedStr)) 216 if err != nil { 217 panic(err) 218 } 219 err = w.Close() 220 if err != nil { 221 panic(err) 222 } 223 compressedChunk := b.Bytes() 224 toPut[i] = &TaskOutputChunk{ 225 Key: TaskOutputChunkKey(ctx, reqKey, int64(i)), 226 Chunk: compressedChunk, 227 } 228 } 229 err := datastore.Put(ctx, toPut) 230 if err != nil { 231 panic(err) 232 } 233 }