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  }