go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/deploy/service/model/logic.go (about) 1 // Copyright 2022 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 "fmt" 19 "strings" 20 21 "google.golang.org/grpc/codes" 22 "google.golang.org/protobuf/proto" 23 24 "go.chromium.org/luci/common/errors" 25 "go.chromium.org/luci/deploy/api/modelpb" 26 ) 27 28 // ValidateIntendedState checks AssetState proto matches the asset kind. 29 // 30 // Checks all `intended_state` fields are populated. Purely erronous states 31 // (with no state and non-zero status code) are considered valid. 32 func ValidateIntendedState(assetID string, state *modelpb.AssetState) error { 33 return validateState(assetID, state, func(s any) error { 34 switch state := s.(type) { 35 case *modelpb.AppengineState: 36 return validateAppengineIntendedState(state) 37 default: 38 panic("impossible") 39 } 40 }) 41 } 42 43 // ValidateReportedState checks AssetState proto matches the asset kind. 44 // 45 // Checks all `captured_state` fields are populated. Purely erronous states 46 // (with no state and non-zero status code) are considered valid. 47 func ValidateReportedState(assetID string, state *modelpb.AssetState) error { 48 return validateState(assetID, state, func(s any) error { 49 switch state := s.(type) { 50 case *modelpb.AppengineState: 51 return validateAppengineReportedState(state) 52 default: 53 panic("impossible") 54 } 55 }) 56 } 57 58 // validateState verifies assetID kind matches the populated oneof field in 59 // `state` and calls the callback, passing this oneof's payload to it. 60 func validateState(assetID string, state *modelpb.AssetState, cb func(state any) error) error { 61 switch { 62 case state == nil: 63 // AssetState itself should be populated. 64 return errors.Reason("no state populated").Err() 65 case state.Status.GetCode() != int32(codes.OK): 66 if state.State != nil { 67 return errors.Reason("if `status` is not OK, `state` should be absent").Err() 68 } 69 return nil 70 case isAppengineAssetID(assetID): 71 if s := state.GetAppengine(); s != nil { 72 return cb(s) 73 } 74 return errors.Reason("not an Appengine state").Err() 75 default: 76 return errors.Reason("unrecognized asset ID format").Err() 77 } 78 } 79 80 // IsActuationEnabed checks if the actuation for an asset is enabled. 81 func IsActuationEnabed(cfg *modelpb.AssetConfig, dep *modelpb.DeploymentConfig) bool { 82 return cfg.GetEnableAutomation() 83 } 84 85 // IntendedMatchesReported is true if the intended state matches the reported 86 // state. 87 // 88 // States must be non-erroneous and be valid per ValidateIntendedState and 89 // ValidateReportedState. 90 func IntendedMatchesReported(intended, reported *modelpb.AssetState) bool { 91 if intended := intended.GetAppengine(); intended != nil { 92 if reported := reported.GetAppengine(); reported != nil { 93 return appengineIntendedMatchesReported(intended, reported) 94 } 95 return false 96 } 97 return false 98 } 99 100 // IsSameState compares `state` portion of AssetState. 101 // 102 // Ignores all other fields. If any of the states is erroneous (with no `state` 103 // field), returns false. 104 func IsSameState(a, b *modelpb.AssetState) bool { 105 // Note that `state` is a oneof field and there's no way to compare such 106 // fields without examining "arms" first. 107 concrete := func(s *modelpb.AssetState) proto.Message { 108 switch v := s.GetState().(type) { 109 case *modelpb.AssetState_Appengine: 110 return v.Appengine 111 default: 112 return nil 113 } 114 } 115 if a, b := concrete(a), concrete(b); a != nil && b != nil { 116 return proto.Equal(a, b) 117 } 118 return false 119 } 120 121 // IsUpToDate returns true if an asset is up-to-date and should not be actuated. 122 // 123 // An asset is considered up-to-date if its reported state matches the intended 124 // state, and the intended state hasn't changed since the last successful 125 // actuation. 126 func IsUpToDate(inteded, reported, applied *modelpb.AssetState) bool { 127 return applied != nil && 128 IntendedMatchesReported(inteded, reported) && 129 IsSameState(inteded, applied) 130 } 131 132 //////////////////////////////////////////////////////////////////////////////// 133 // Appengine logic. 134 135 func isAppengineAssetID(assetID string) bool { 136 return strings.HasPrefix(assetID, "apps/") 137 } 138 139 func validateAppengineIntendedState(state *modelpb.AppengineState) error { 140 if state.IntendedState == nil { 141 return errors.Reason("no intended_state field").Err() 142 } 143 144 err := visitServices(state, true, func(svc *modelpb.AppengineState_Service) error { 145 if err := validateTrafficAllocation(svc.TrafficAllocation); err != nil { 146 return err 147 } 148 if svc.TrafficSplitting == 0 { 149 return errors.Reason("no traffic_splitting field").Err() 150 } 151 return nil 152 }) 153 if err != nil { 154 return err 155 } 156 157 return visitVersions(state, true, func(ver *modelpb.AppengineState_Service_Version) error { 158 if ver.IntendedState == nil { 159 return errors.Reason("no intended_state field").Err() 160 } 161 return nil 162 }) 163 } 164 165 func validateAppengineReportedState(state *modelpb.AppengineState) error { 166 if state.CapturedState == nil { 167 return errors.Reason("no captured_state field").Err() 168 } 169 170 // Note: the list of reported services may be empty for a completely new GAE 171 // app. 172 err := visitServices(state, true, func(svc *modelpb.AppengineState_Service) error { 173 if err := validateTrafficAllocation(svc.TrafficAllocation); err != nil { 174 return err 175 } 176 // Sadly, GAE Admin API doesn't report traffic_splitting method, so we skip 177 // validating it. 178 return nil 179 }) 180 if err != nil { 181 return err 182 } 183 184 // Note: it is not possible to have a GAE service running without any 185 // versions. 186 return visitVersions(state, false, func(ver *modelpb.AppengineState_Service_Version) error { 187 if ver.CapturedState == nil { 188 return errors.Reason("no captured_state field").Err() 189 } 190 return nil 191 }) 192 } 193 194 func validateTrafficAllocation(t map[string]int32) error { 195 if len(t) == 0 { 196 return errors.Reason("no traffic_allocation field").Err() 197 } 198 total := 0 199 for _, p := range t { 200 total += int(p) 201 } 202 if total != 1000 { 203 return errors.Reason("traffic_allocation: total traffic %d != 1000", total).Err() 204 } 205 return nil 206 } 207 208 // visitServices calls the callback for each Service proto. 209 func visitServices(state *modelpb.AppengineState, allowEmpty bool, cb func(*modelpb.AppengineState_Service) error) error { 210 if len(state.Services) == 0 && !allowEmpty { 211 return errors.Reason("services list is empty").Err() 212 } 213 for _, svc := range state.Services { 214 if svc.Name == "" { 215 return errors.Reason("unnamed service").Err() 216 } 217 if err := cb(svc); err != nil { 218 return errors.Annotate(err, "in service %q", svc.Name).Err() 219 } 220 } 221 return nil 222 } 223 224 // visitVersions calls the callback for each Version proto across all Services. 225 func visitVersions(state *modelpb.AppengineState, allowEmpty bool, cb func(*modelpb.AppengineState_Service_Version) error) error { 226 for _, svc := range state.Services { 227 if len(svc.Versions) == 0 && !allowEmpty { 228 return errors.Reason("in service %q: no versions", svc.Name).Err() 229 } 230 for _, ver := range svc.Versions { 231 if ver.Name == "" { 232 return errors.Reason("in service %q: unnamed version", svc.Name).Err() 233 } 234 if err := cb(ver); err != nil { 235 return errors.Annotate(err, "in service %q: in version %q", svc.Name, ver.Name).Err() 236 } 237 } 238 } 239 return nil 240 } 241 242 // appengineIntendedMatchesReported returns true if all intended versions are 243 // deployed and receive the intended percent of traffic. 244 // 245 // It is OK if more versions or services are deployed as long as they don't get 246 // any traffic. 247 // 248 // Note that Appengine Admin API doesn't provide visibility into what versions 249 // of "special" YAMLs (like queue.yaml and index.yaml) are deployed. They are 250 // ignored by this function. Same applies to the traffic splitting method. 251 func appengineIntendedMatchesReported(intended, reported *modelpb.AppengineState) bool { 252 if err := validateAppengineIntendedState(intended); err != nil { 253 panic(fmt.Sprintf("got invalid intended state (%s): %q", err, intended)) 254 } 255 if err := validateAppengineReportedState(reported); err != nil { 256 panic(fmt.Sprintf("got invalid reported state (%s): %s", err, reported)) 257 } 258 259 want := trafficMap(intended) 260 have := trafficMap(reported) 261 262 for svc, wantTraffic := range want { 263 // Note: `wantTraffic` may be 0 if we want to deploy a version, but don't 264 // route any traffic to it yet. 265 switch currentTraffic, ok := have[svc]; { 266 case !ok: 267 // No such version deployed at all. 268 return false 269 case currentTraffic != wantTraffic: 270 // Deployed, but receives wrong portion of traffic. 271 return false 272 } 273 } 274 275 return true 276 } 277 278 type serviceVersion struct { 279 service string // e.g. "default" 280 version string // e.g. "24344-abcedfa" 281 } 282 283 // trafficMap returns a mapping from a concrete version to the portion of 284 // traffic assigned to it within its service. 285 func trafficMap(s *modelpb.AppengineState) map[serviceVersion]int { 286 out := make(map[serviceVersion]int) 287 for _, svc := range s.Services { 288 for _, ver := range svc.Versions { 289 out[serviceVersion{svc.Name, ver.Name}] = int(svc.TrafficAllocation[ver.Name]) 290 } 291 } 292 return out 293 }