go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/deploy/service/ui/asset.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 ui
    16  
    17  import (
    18  	"fmt"
    19  	"html"
    20  	"net/url"
    21  	"sort"
    22  	"strings"
    23  	"time"
    24  
    25  	"github.com/dustin/go-humanize"
    26  	"google.golang.org/protobuf/types/known/timestamppb"
    27  
    28  	"go.chromium.org/luci/server/router"
    29  	"go.chromium.org/luci/server/templates"
    30  
    31  	"go.chromium.org/luci/deploy/api/modelpb"
    32  	"go.chromium.org/luci/deploy/api/rpcpb"
    33  )
    34  
    35  var (
    36  	// Timezone for displaying absolute times.
    37  	hardcodedTZ = time.FixedZone("UTC-07", -7*60*60)
    38  	// For sorting, to make sure "missing" timestamps show up first.
    39  	distantFuture = time.Date(2112, 1, 1, 1, 1, 1, 1, time.UTC)
    40  )
    41  
    42  // assetState is an overall asset state to display in the UI.
    43  type assetState string
    44  
    45  const (
    46  	stateUnknown  assetState = "UNKNOWN"    // should be unreachable
    47  	stateUpToDate assetState = "UP-TO-DATE" // matches intent
    48  	stateLocked   assetState = "LOCKED"     // has outstanding locks
    49  	stateDisabled assetState = "DISABLED"   // disabled in the config
    50  	stateUpdating assetState = "UPDATING"   // has an active actuation right now
    51  	stateBroken   assetState = "BROKEN"     // broken configuration
    52  	stateFailed   assetState = "FAILED"     // the last actuation failed
    53  )
    54  
    55  // deriveState derives an overall state based on the asset proto.
    56  func deriveState(a *modelpb.Asset) assetState {
    57  	switch a.LastDecision.GetDecision() {
    58  	case modelpb.ActuationDecision_SKIP_UPTODATE:
    59  		return stateUpToDate
    60  
    61  	case modelpb.ActuationDecision_SKIP_DISABLED:
    62  		return stateDisabled
    63  
    64  	case modelpb.ActuationDecision_SKIP_LOCKED:
    65  		return stateLocked
    66  
    67  	case modelpb.ActuationDecision_SKIP_BROKEN:
    68  		return stateBroken
    69  
    70  	case modelpb.ActuationDecision_ACTUATE_FORCE, modelpb.ActuationDecision_ACTUATE_STALE:
    71  		switch a.LastActuation.GetState() {
    72  		case modelpb.Actuation_EXECUTING:
    73  			return stateUpdating
    74  		case modelpb.Actuation_SUCCEEDED:
    75  			return stateUpToDate
    76  		case modelpb.Actuation_FAILED, modelpb.Actuation_EXPIRED:
    77  			return stateFailed
    78  		default:
    79  			return stateUnknown // this should not be possible
    80  		}
    81  
    82  	default:
    83  		return stateUnknown // this should not be possible
    84  	}
    85  }
    86  
    87  // tableClass is the corresponding Bootstrap CSS table class.
    88  func (s assetState) tableClass() string {
    89  	switch s {
    90  	case stateUpToDate:
    91  		return "table-light"
    92  	case stateLocked:
    93  		return "table-warning"
    94  	case stateDisabled:
    95  		return "table-secondary"
    96  	case stateUpdating:
    97  		return "table-info"
    98  	case stateUnknown, stateBroken, stateFailed:
    99  		return "table-danger"
   100  	default:
   101  		panic("impossible")
   102  	}
   103  }
   104  
   105  // badgeClass is the corresponding Bootstrap CSS badge class.
   106  func (s assetState) badgeClass() string {
   107  	switch s {
   108  	case stateUpToDate:
   109  		return "bg-success"
   110  	case stateLocked:
   111  		return "bg-warning text-dark"
   112  	case stateDisabled:
   113  		return "bg-secondary"
   114  	case stateUpdating:
   115  		return "bg-info text-dark"
   116  	case stateUnknown, stateBroken, stateFailed:
   117  		return "bg-danger"
   118  	default:
   119  		panic("impossible")
   120  	}
   121  }
   122  
   123  // assetRef is a reference to an asset in the UI.
   124  type assetRef struct {
   125  	Kind string // kind of the asset (as human readable string)
   126  	Icon string // icon image file name
   127  	Name string // display name of the asset e.g. GAE app ID
   128  	Href string // link to the asset page
   129  }
   130  
   131  // assetRefFromID constructs assetRef based on the asset ID.
   132  func assetRefFromID(assetID string) assetRef {
   133  	href := fmt.Sprintf("/a/%s", assetID)
   134  	switch {
   135  	case strings.HasPrefix(assetID, "apps/"):
   136  		return assetRef{
   137  			Kind: "Appengine service",
   138  			Icon: "app_engine.svg",
   139  			Name: strings.TrimPrefix(assetID, "apps/"),
   140  			Href: href,
   141  		}
   142  	default:
   143  		return assetRef{
   144  			Kind: "Unknown",
   145  			Icon: "unknown.svg",
   146  			Name: assetID,
   147  			Href: href,
   148  		}
   149  	}
   150  }
   151  
   152  // linkHref is a potentially clickable link.
   153  type linkHref struct {
   154  	Text    string
   155  	Href    string
   156  	Tooltip string
   157  	Target  string
   158  }
   159  
   160  // hrefTarget derives "target" attribute for an href.
   161  func hrefTarget(href string) string {
   162  	if !strings.HasPrefix(href, "/") {
   163  		return "_blank"
   164  	}
   165  	return ""
   166  }
   167  
   168  // timestampHref a link that looks like a timestamp.
   169  func timestampHref(ts *timestamppb.Timestamp, href, tooltipSfx string) linkHref {
   170  	if ts == nil {
   171  		return linkHref{
   172  			Href:   href,
   173  			Target: hrefTarget(href),
   174  		}
   175  	}
   176  	t := ts.AsTime()
   177  
   178  	tooltip := t.In(hardcodedTZ).Format(time.RFC822)
   179  	if tooltipSfx != "" {
   180  		tooltip += " " + tooltipSfx
   181  	}
   182  
   183  	return linkHref{
   184  		Text:    humanize.RelTime(t, time.Now(), "ago", "from now"),
   185  		Href:    href,
   186  		Tooltip: tooltip,
   187  		Target:  hrefTarget(href),
   188  	}
   189  }
   190  
   191  // buildbucketHref returns a link to the build running the actuation.
   192  func buildbucketHref(act *modelpb.ActuatorInfo) string {
   193  	if act.BuildbucketBuild == 0 {
   194  		return ""
   195  	}
   196  	return fmt.Sprintf("https://%s/build/%d", act.Buildbucket, act.BuildbucketBuild)
   197  }
   198  
   199  // appengineServiceHref returns a link to a GAE service Cloud Console page.
   200  func appengineServiceHref(project, service string) linkHref {
   201  	return linkHref{
   202  		Text: service,
   203  		Href: "https://console.cloud.google.com/appengine/versions?" + (url.Values{
   204  			"project":   {project},
   205  			"serviceId": {service},
   206  		}).Encode(),
   207  		Target: "_blank",
   208  	}
   209  }
   210  
   211  // appengineVersionHref returns a link to a GAE version Cloud Console logs page.
   212  func appengineVersionHref(project, service, version string) linkHref {
   213  	// That's how "TOOLS => Logs" link looks like on GAE Versions list page.
   214  	return linkHref{
   215  		Text: version,
   216  		Href: "https://console.cloud.google.com/logs?" + (url.Values{
   217  			"project":   {project},
   218  			"serviceId": {service},
   219  			"service":   {"appengine.googleapis.com"},
   220  			"key1":      {service},
   221  			"key2":      {version},
   222  		}).Encode(),
   223  		Target: "_blank",
   224  	}
   225  }
   226  
   227  // commitHref returns a link to the commit matching the deployment.
   228  func commitHref(dep *modelpb.Deployment) linkHref {
   229  	if dep.GetId().GetRepoHost() == "" || dep.GetConfigRev() == "" {
   230  		return linkHref{}
   231  	}
   232  	return linkHref{
   233  		Text: dep.ConfigRev[:8],
   234  		Href: fmt.Sprintf("https://%s.googlesource.com/%s/+/%s",
   235  			dep.Id.RepoHost, dep.Id.RepoName, dep.ConfigRev),
   236  		Target: "_blank",
   237  	}
   238  }
   239  
   240  // assetOverview is a subset of asset data passed to the index HTML template.
   241  type assetOverview struct {
   242  	Ref assetRef // link to the asset page
   243  
   244  	State      assetState // overall state
   245  	TableClass string     // CSS class for the table row
   246  	BadgeClass string     // CSS class for the state cell
   247  
   248  	LastCheckIn   linkHref // when its state was reported last time
   249  	LastActuation linkHref // when the last non-trivial actuation happened
   250  	Revision      linkHref // last applied IaC revision
   251  }
   252  
   253  // deriveAssetOverview derives assetOverview from the asset proto.
   254  func deriveAssetOverview(asset *modelpb.Asset) assetOverview {
   255  	out := assetOverview{
   256  		Ref:   assetRefFromID(asset.Id),
   257  		State: deriveState(asset),
   258  	}
   259  
   260  	out.TableClass = out.State.tableClass()
   261  	out.BadgeClass = out.State.badgeClass()
   262  
   263  	// The last "check in" time always matches the last performed actuation
   264  	// regardless of its outcome. Most of the time this outcome is SKIP_UPTODATE.
   265  	if asset.LastActuation != nil {
   266  		out.LastCheckIn = timestampHref(
   267  			asset.LastActuation.Created,
   268  			buildbucketHref(asset.LastActuation.Actuator),
   269  			"",
   270  		)
   271  	}
   272  
   273  	// Show the last non-trivial actuation (may still be executing).
   274  	if asset.LastActuateActuation != nil {
   275  		if asset.LastActuateActuation.Finished != nil {
   276  			out.LastActuation = timestampHref(
   277  				asset.LastActuateActuation.Finished,
   278  				asset.LastActuateActuation.LogUrl,
   279  				"",
   280  			)
   281  		} else {
   282  			out.LastActuation = linkHref{
   283  				Text:   "now",
   284  				Href:   asset.LastActuateActuation.LogUrl,
   285  				Target: "_blank",
   286  			}
   287  		}
   288  	}
   289  
   290  	// If have an actuation executing right now, show its IaC revision (as the
   291  	// one being applied now), otherwise show the last successfully applied
   292  	// revision.
   293  	if asset.LastActuation.GetState() == modelpb.Actuation_EXECUTING {
   294  		out.Revision = commitHref(asset.LastActuation.Deployment)
   295  	} else if asset.AppliedState != nil {
   296  		out.Revision = commitHref(asset.AppliedState.Deployment)
   297  	}
   298  
   299  	return out
   300  }
   301  
   302  ////////////////////////////////////////////////////////////////////////////////
   303  
   304  type versionState struct {
   305  	Service         linkHref // name of the service e.g. "default"
   306  	Version         linkHref // name of the version e.g. "1234-abcedf"
   307  	Deployed        linkHref // when it was deployed
   308  	TrafficIntended int32    // intended percent of traffic
   309  	TrafficReported int32    // reported percent of traffic
   310  
   311  	RowSpan int // helper to merge HTML table cells
   312  
   313  	sortKey1 string // sorting helper, derived from service name
   314  	sortKey2 int64  // sorting helper, derived from deployment timestamp
   315  }
   316  
   317  func versionsSummary(asset *modelpb.Asset, active bool) []versionState {
   318  	type versionID struct {
   319  		svc string
   320  		ver string
   321  	}
   322  	versions := map[versionID]versionState{}
   323  
   324  	serviceSortKey := func(svc string) string {
   325  		// We want to show services like "default" and "default-go" on top, they
   326  		// are known to be the most important.
   327  		if strings.HasPrefix(svc, "default") {
   328  			return "a " + svc
   329  		}
   330  		return "b " + svc
   331  	}
   332  
   333  	projectID := strings.TrimPrefix(asset.Id, "apps/")
   334  
   335  	// Add all currently running versions to the table.
   336  	for _, svc := range asset.GetReportedState().GetAppengine().GetServices() {
   337  		for _, ver := range svc.Versions {
   338  			// Visit only active or only inactive versions, based on `active` flag.
   339  			traffic := (svc.TrafficAllocation[ver.Name] * 100) / 1000
   340  			if active != (traffic != 0) {
   341  				continue
   342  			}
   343  
   344  			// Trim giant email suffixes of service accounts. There are very few of
   345  			// service accounts that should be deploying stuff, and they are easily
   346  			// identified by their name alone.
   347  			deployer := ver.GetCapturedState().CreatedBy
   348  			if strings.HasSuffix(deployer, ".gserviceaccount.com") {
   349  				deployer = strings.Split(deployer, "@")[0]
   350  			}
   351  
   352  			versions[versionID{svc.Name, ver.Name}] = versionState{
   353  				Service:         appengineServiceHref(projectID, svc.Name),
   354  				Version:         appengineVersionHref(projectID, svc.Name, ver.Name),
   355  				Deployed:        timestampHref(ver.GetCapturedState().CreateTime, "", "<br>by "+html.EscapeString(deployer)),
   356  				TrafficReported: traffic,
   357  				sortKey1:        serviceSortKey(svc.Name),
   358  				sortKey2:        ver.GetCapturedState().CreateTime.AsTime().Unix(),
   359  			}
   360  		}
   361  	}
   362  
   363  	// Add all versions that are declared in the configs. They are all considered
   364  	// active (even when they get 0% intended traffic), since they are "actively"
   365  	// declared in the configs.
   366  	if active {
   367  		for _, svc := range asset.GetIntendedState().GetAppengine().GetServices() {
   368  			for _, ver := range svc.Versions {
   369  				key := versionID{svc.Name, ver.Name}
   370  				val, ok := versions[key]
   371  				if !ok {
   372  					val = versionState{
   373  						Service:  appengineServiceHref(projectID, svc.Name),
   374  						Version:  appengineVersionHref(projectID, svc.Name, ver.Name),
   375  						sortKey1: serviceSortKey(svc.Name),
   376  						sortKey2: distantFuture.Unix(), // will show up before any currently deployed version
   377  					}
   378  				}
   379  				val.TrafficIntended = (svc.TrafficAllocation[ver.Name] * 100) / 1000
   380  				versions[key] = val
   381  			}
   382  		}
   383  	}
   384  
   385  	// Sort by service, then by version deployment time (most recent on top).
   386  	versionsList := make([]versionState, 0, len(versions))
   387  	for _, v := range versions {
   388  		versionsList = append(versionsList, v)
   389  	}
   390  	sort.Slice(versionsList, func(li, ri int) bool {
   391  		l, r := versionsList[li], versionsList[ri]
   392  		if l.sortKey1 == r.sortKey1 {
   393  			return l.sortKey2 > r.sortKey2 // reversed intentionally
   394  		}
   395  		return l.sortKey1 < r.sortKey1
   396  	})
   397  
   398  	if len(versionsList) == 0 {
   399  		return versionsList
   400  	}
   401  
   402  	// Populate RowSpan by "merging" rows with the same Service name.
   403  	curIdx := 0
   404  	for i := range versionsList {
   405  		if versionsList[i].Service != versionsList[curIdx].Service {
   406  			curIdx = i
   407  		}
   408  		versionsList[curIdx].RowSpan += 1
   409  	}
   410  
   411  	return versionsList
   412  }
   413  
   414  ////////////////////////////////////////////////////////////////////////////////
   415  
   416  // assetPage renders the asset page.
   417  func (ui *UI) assetPage(ctx *router.Context, assetID string) error {
   418  	const historyLimit = 10
   419  
   420  	assetHistory, err := ui.assets.ListAssetHistory(ctx.Request.Context(), &rpcpb.ListAssetHistoryRequest{
   421  		AssetId: assetID,
   422  		Limit:   historyLimit,
   423  	})
   424  	if err != nil {
   425  		return err
   426  	}
   427  
   428  	if assetHistory.Current != nil {
   429  		// TODO: Show detailed UI.
   430  	}
   431  
   432  	history := make([]*historyOverview, len(assetHistory.History))
   433  	for i, rec := range assetHistory.History {
   434  		history[i] = deriveHistoryOverview(assetHistory.Asset, rec)
   435  	}
   436  
   437  	ref := assetRefFromID(assetHistory.Asset.Id)
   438  
   439  	templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/asset.html", map[string]any{
   440  		"Breadcrumbs":       assetBreadcrumbs(ref),
   441  		"Ref":               ref,
   442  		"Overview":          deriveAssetOverview(assetHistory.Asset),
   443  		"ActiveVersions":    versionsSummary(assetHistory.Asset, true),
   444  		"InactiveVersions":  versionsSummary(assetHistory.Asset, false),
   445  		"History":           history,
   446  		"LikelyMoreHistory": len(history) == historyLimit,
   447  		"HistoryHref":       fmt.Sprintf("/a/%s/history", assetID),
   448  	})
   449  	return nil
   450  }