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 }