| // Copyright 2023 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // Command makemac manages MacService instances for LUCI. | |
| // | |
| // It performs several different operations: | |
| // | |
| // - Detects MacService leases that MacService thinks are running, but never | |
| // connected to LUCI (failed to boot?) and destroys them. | |
| // - Detects MacService leases that MacService thinks are running, but LUCI | |
| // thinks are dead (froze/crashed?) and destroys them. | |
| // - Renews MacService leases that both MacService and LUCI agree are healthy | |
| // to ensure they don't expire. | |
| // - Destroys MacService leases with images that are not requested by the | |
| // configuration in config.go. | |
| // - Launches new MacService leases to ensure that there are the at least as | |
| // many leases of each type as specified in the configuration in config.go. | |
| package main | |
| import ( | |
| "context" | |
| "flag" | |
| "fmt" | |
| "log" | |
| "regexp" | |
| "sort" | |
| "strings" | |
| "time" | |
| "go.chromium.org/luci/swarming/client/swarming" | |
| spb "go.chromium.org/luci/swarming/proto/api_v2" | |
| "golang.org/x/build/internal/macservice" | |
| "golang.org/x/build/internal/secret" | |
| "golang.org/x/oauth2/google" | |
| ) | |
| var ( | |
| apiKey = secret.Flag("macservice-api-key", "MacService API key") | |
| period = flag.Duration("period", 1*time.Hour, "How often to check bots and leases. As a special case, -period=0 checks exactly once and then exits") | |
| dryRun = flag.Bool("dry-run", false, "Print the actions that would be taken without actually performing them") | |
| ) | |
| const ( | |
| createExpirationDuration = 24 * time.Hour | |
| createExpirationDurationString = "86400s" | |
| // Shorter renew expiration is a workaround to detect newly-created | |
| // leases. See comment in handleMissingBots. | |
| renewExpirationDuration = 23 * time.Hour | |
| renewExpirationDurationString = "82800s" // 23h | |
| ) | |
| const ( | |
| macServiceCustomer = "golang" | |
| // Leases managed by makemac have ProjectName "makemac/SWARMING_HOST", | |
| // indicating that it is managed by makemac, and which swarming host it | |
| // belongs to. Leases without this project prefix will not be touched. | |
| // | |
| // Note that we track the swarming host directly in the lease project | |
| // name because new leases may not have yet connected to the swarming | |
| // server, but we still need to know which host to count them towards. | |
| managedProjectPrefix = "makemac" | |
| ) | |
| func main() { | |
| if err := secret.InitFlagSupport(context.Background()); err != nil { | |
| log.Fatalln(err) | |
| } | |
| flag.Parse() | |
| if err := run(); err != nil { | |
| log.Fatalln(err) | |
| } | |
| } | |
| func run() error { | |
| ctx := context.Background() | |
| var mc macServiceClient | |
| mc = macservice.NewClient(*apiKey) | |
| if *dryRun { | |
| mc = readOnlyMacServiceClient{mc: mc} | |
| } | |
| // Use service account / application default credentials for swarming | |
| // authentication. | |
| ac, err := google.DefaultClient(ctx) | |
| if err != nil { | |
| return fmt.Errorf("error creating authenticated client: %w", err) | |
| } | |
| // Initialize each swarming client. | |
| for sc, ic := range prodImageConfig { | |
| c, err := swarming.NewClient(ctx, swarming.ClientOptions{ | |
| ServiceURL: "https://" + sc.Host, | |
| AuthenticatedClient: ac, | |
| }) | |
| if err != nil { | |
| return fmt.Errorf("error creating swarming client for %s: %w", sc.Host, err) | |
| } | |
| sc.client = c | |
| logImageConfig(sc, ic) | |
| } | |
| // Always run once at startup. | |
| runOnce(ctx, prodImageConfig, mc) | |
| if *period == 0 { | |
| // User only wants a single check. We're done. | |
| return nil | |
| } | |
| t := time.NewTicker(*period) | |
| for range t.C { | |
| runOnce(ctx, prodImageConfig, mc) | |
| } | |
| return nil | |
| } | |
| func runOnce(ctx context.Context, config map[*swarmingConfig][]imageConfig, mc macServiceClient) { | |
| bots, err := swarmingBots(ctx, config) | |
| if err != nil { | |
| log.Printf("Error looking up swarming bots: %v", err) | |
| return | |
| } | |
| leases, err := macServiceLeases(mc) | |
| if err != nil { | |
| log.Printf("Error looking up MacService leases: %v", err) | |
| return | |
| } | |
| logSummary(bots, leases) | |
| // These directly correspond to the operation described in the package | |
| // comment above. | |
| handleMissingBots(mc, bots, leases) | |
| handleDeadBots(mc, bots, leases) | |
| renewLeases(mc, leases) | |
| handleObsoleteLeases(mc, config, leases) | |
| addNewLeases(mc, config, leases, secret.DefaultResolver.ResolveSecret) | |
| } | |
| // leaseSwarmingHost returns the swarming host a managed lease belongs to. | |
| // | |
| // Returns "" if this isn't a managed lease. | |
| func leaseSwarmingHost(l macservice.Lease) string { | |
| prefix, host, ok := strings.Cut(l.VMResourceNamespace.ProjectName, "/") | |
| if !ok { | |
| // Malformed project name, must not be managed. | |
| return "" | |
| } | |
| if prefix != managedProjectPrefix { | |
| // Some other prefix. Not managed. | |
| return "" | |
| } | |
| return host | |
| } | |
| func leaseIsManaged(l macservice.Lease) bool { | |
| return leaseSwarmingHost(l) != "" | |
| } | |
| func logSummary(bots map[string]*spb.BotInfo, leases map[string]macservice.Instance) { | |
| keys := make([]string, 0, len(bots)) | |
| for k := range bots { | |
| keys = append(keys, k) | |
| } | |
| sort.Strings(keys) | |
| log.Printf("Swarming bots:") | |
| for _, k := range keys { | |
| b := bots[k] | |
| alive := true | |
| if b.GetIsDead() { | |
| alive = false | |
| } | |
| os := "<unknown OS version>" | |
| dimensions := b.GetDimensions() | |
| for _, d := range dimensions { | |
| if d.Key != "os" { | |
| continue | |
| } | |
| if len(d.Value) == 0 { | |
| continue | |
| } | |
| os = d.Value[len(d.Value)-1] // most specific value last. | |
| } | |
| log.Printf("\t%s: alive=%t\tos=%s", k, alive, os) | |
| } | |
| keys = make([]string, 0, len(leases)) | |
| for k := range leases { | |
| keys = append(keys, k) | |
| } | |
| sort.Strings(keys) | |
| log.Printf("MacService leases:") | |
| for _, k := range keys { | |
| inst := leases[k] | |
| swarming := leaseSwarmingHost(inst.Lease) | |
| if swarming == "" { | |
| swarming = "<unmanaged>" | |
| } | |
| image := inst.InstanceSpecification.DiskSelection.ImageHashes.BootSHA256 | |
| log.Printf("\t%s: image=%s\tswarming=%s", k, image, swarming) | |
| } | |
| } | |
| // e.g., darwin-amd64-11--39b47cf6-2aaa-4c80-b9cb-b800844fb104.golang.c3.macservice.goog | |
| var botIDRe = regexp.MustCompile(`.*--([0-9a-f-]+)\.golang\..*\.macservice.goog$`) | |
| // swarmingBots returns set of bots backed by MacService, as seen by swarming. | |
| // The map key is the MacService lease ID. | |
| // Bots may be dead. | |
| func swarmingBots(ctx context.Context, config map[*swarmingConfig][]imageConfig) (map[string]*spb.BotInfo, error) { | |
| m := make(map[string]*spb.BotInfo) | |
| scs := sortedSwarmingConfigs(config) | |
| for _, sc := range scs { | |
| dimensions := []*spb.StringPair{ | |
| { | |
| Key: "pool", | |
| Value: sc.Pool, | |
| }, | |
| { | |
| Key: "os", | |
| Value: "Mac", | |
| }, | |
| } | |
| bb, err := sc.client.ListBots(ctx, dimensions) | |
| if err != nil { | |
| return nil, fmt.Errorf("error listing bots: %w", err) | |
| } | |
| for _, b := range bb { | |
| id := b.GetBotId() | |
| match := botIDRe.FindStringSubmatch(id) | |
| if match == nil { | |
| log.Printf("Swarming bot %s is not a MacService bot, skipping...", id) | |
| continue | |
| } | |
| lease := match[1] | |
| m[lease] = b | |
| } | |
| } | |
| return m, nil | |
| } | |
| // macServiceLeases returns the set of active MacService leases. | |
| func macServiceLeases(mc macServiceClient) (map[string]macservice.Instance, error) { | |
| resp, err := mc.Find(macservice.FindRequest{ | |
| VMResourceNamespace: macservice.Namespace{ | |
| CustomerName: "golang", | |
| }, | |
| }) | |
| if err != nil { | |
| return nil, fmt.Errorf("error finding leases: %v", err) | |
| } | |
| m := make(map[string]macservice.Instance) | |
| for _, i := range resp.Instances { | |
| m[i.Lease.LeaseID] = i | |
| } | |
| return m, nil | |
| } | |
| // handleMissingBots detects MacService leases that MacService thinks are | |
| // running, but never connected to LUCI (i.e., missing completely from LUCI) | |
| // and destroys them. | |
| // | |
| // These are bots that perhaps never successfully booted? | |
| func handleMissingBots(mc macServiceClient, bots map[string]*spb.BotInfo, leases map[string]macservice.Instance) { | |
| log.Printf("Checking for missing bots...") | |
| var missing []string | |
| for id := range leases { | |
| if _, ok := bots[id]; !ok { | |
| missing = append(missing, id) | |
| } | |
| } | |
| // Sort to make the logs easier to follow when comparing vs a bot/lease | |
| // list. | |
| sort.Strings(missing) | |
| for _, id := range missing { | |
| lease := leases[id] | |
| if !leaseIsManaged(lease.Lease) { | |
| log.Printf("Lease %s missing from LUCI, but not managed by makemac; skipping", id) | |
| continue | |
| } | |
| // There is a race window here: if this lease was created in | |
| // the last few minutes, the initial boot may still be ongoing, | |
| // and thus being missing from LUCI is expected. We don't want | |
| // to destroy these leases. | |
| // | |
| // Unfortunately MacService doesn't report lease creation time, | |
| // so we can't trivially check for this case. It does report | |
| // expiration time. As a workaround, we create new leases with | |
| // a 24h expiration time, but renew leases with a 23h | |
| // expiration. Thus if we see expiration is >23h from now then | |
| // this lease must have been created in the last hour. | |
| untilExpiration := time.Until(lease.Lease.Expires) | |
| if untilExpiration > renewExpirationDuration { | |
| log.Printf("Lease %s missing from LUCI, but created in the last hour (still booting?); skipping", id) | |
| continue | |
| } | |
| log.Printf("Lease %s missing from LUCI; failed initial boot?", id) | |
| log.Printf("Vacating lease %s...", id) | |
| if err := mc.Vacate(macservice.VacateRequest{LeaseID: id}); err != nil { | |
| log.Printf("Error vacating lease %s: %v", id, err) | |
| continue | |
| } | |
| delete(leases, id) // Drop from map so future calls know it is gone. | |
| } | |
| } | |
| // handleDeadBots detects MacService leases that MacService thinks are running, | |
| // but LUCI thinks are dead (froze/crashed?) and destroys them. | |
| // | |
| // These are bots that perhaps froze/crashed at some point after starting. | |
| func handleDeadBots(mc macServiceClient, bots map[string]*spb.BotInfo, leases map[string]macservice.Instance) { | |
| log.Printf("Checking for dead bots...") | |
| var dead []string | |
| for id, b := range bots { | |
| if b.GetIsDead() { | |
| dead = append(dead, id) | |
| } | |
| } | |
| // Sort to make the logs easier to follow when comparing vs a bot/lease | |
| // list. | |
| sort.Strings(dead) | |
| for _, id := range dead { | |
| lease, ok := leases[id] | |
| if !ok { | |
| // Dead bot already gone from MacService; nothing to do. | |
| continue | |
| } | |
| if !leaseIsManaged(lease.Lease) { | |
| log.Printf("Lease %s is dead on LUCI, but still present on MacService, but not managed by makemac; skipping", id) | |
| continue | |
| } | |
| // No need to check for newly created leases like we do in | |
| // handleMissingBots. If a bot appears as dead on LUCI then it | |
| // must have successfully connected at some point. | |
| log.Printf("Lease %s is dead on LUCI, but still present on MacService; VM froze/crashed?", id) | |
| log.Printf("Vacating lease %s...", id) | |
| if err := mc.Vacate(macservice.VacateRequest{LeaseID: id}); err != nil { | |
| log.Printf("Error vacating lease %s: %v", id, err) | |
| continue | |
| } | |
| delete(leases, id) // Drop from map so future calls know it is gone. | |
| } | |
| } | |
| // renewLeases renews lease expiration on all makemac-managed leases. Note that | |
| // this may renew leases that will later be removed because their image is no | |
| // longer required or there are fewer of them requested. This is harmless. | |
| func renewLeases(mc macServiceClient, leases map[string]macservice.Instance) { | |
| log.Printf("Renewing leases...") | |
| var ids []string | |
| for id := range leases { | |
| ids = append(ids, id) | |
| } | |
| // Sort to make the logs easier to follow when comparing vs a bot/lease | |
| // list. | |
| sort.Strings(ids) | |
| for _, id := range ids { | |
| lease := leases[id] | |
| if !leaseIsManaged(lease.Lease) { | |
| log.Printf("Lease %s is not managed by makemac; skipping renew", id) | |
| continue | |
| } | |
| // Extra spaces to make expiration line up with the renewal message below. | |
| log.Printf("Lease ID: %s currently expires: %v", lease.Lease.LeaseID, lease.Lease.Expires) | |
| // Newly created leases have a longer expiration duration than | |
| // our renewal expiration duration. Don't renew these, which | |
| // would would unintentionally shorten their expiration. See | |
| // comment in handleMissingBots. | |
| until := time.Until(lease.Lease.Expires) | |
| if until > renewExpirationDuration { | |
| log.Printf("Lease ID: %s skip renew, current expiration further out than renew expiration", lease.Lease.LeaseID) | |
| continue | |
| } | |
| rr, err := mc.Renew(macservice.RenewRequest{ | |
| LeaseID: lease.Lease.LeaseID, | |
| Duration: renewExpirationDurationString, | |
| }) | |
| if err == nil { | |
| log.Printf("Lease ID: %s renewed, now expires: %v", lease.Lease.LeaseID, rr.Expires) | |
| } else { | |
| log.Printf("Lease ID: %s error renewing %v", lease.Lease.LeaseID, err) | |
| } | |
| } | |
| } | |
| // handleObsoleteLeases vacates any makemac-managed leases with images that are | |
| // not requested by imageConfigs. This typically occurs when makemac is updated | |
| // to roll out a new image version or if Count is reduced for an existing image. | |
| func handleObsoleteLeases(mc macServiceClient, config map[*swarmingConfig][]imageConfig, leases map[string]macservice.Instance) { | |
| log.Printf("Checking for leases with obsolete images...") | |
| // swarming host -> image sha -> image config | |
| swarmingImages := make(map[string]map[string]*imageConfig) | |
| for sc, ic := range config { | |
| swarmingImages[sc.Host] = imageConfigMap(ic) | |
| } | |
| // swarming host -> image sha -> count | |
| swarmingImageCount := make(map[string]map[string]int) | |
| var ids []string | |
| for id := range leases { | |
| ids = append(ids, id) | |
| } | |
| // Sort to make the logs easier to follow when comparing vs a bot/lease | |
| // list. | |
| sort.Strings(ids) | |
| for _, id := range ids { | |
| lease := leases[id] | |
| swarming := leaseSwarmingHost(lease.Lease) | |
| if swarming == "" { | |
| log.Printf("Lease %s is not managed by makemac; skipping image check", id) | |
| continue | |
| } | |
| images, ok := swarmingImages[swarming] | |
| if !ok { | |
| log.Printf("Lease %s belongs to unknown swarming host %s; skipping image check", id, swarming) | |
| continue | |
| } | |
| var vacateReason string // A non-empty string means to vacate with said reason. | |
| image := lease.InstanceSpecification.DiskSelection.ImageHashes.BootSHA256 | |
| if config, ok := images[image]; !ok { | |
| vacateReason = fmt.Sprintf("it uses an obsolete image %s", image) | |
| } else { | |
| if _, ok := swarmingImageCount[swarming]; !ok { | |
| swarmingImageCount[swarming] = make(map[string]int) | |
| } | |
| swarmingImageCount[swarming][image]++ | |
| haveCount := swarmingImageCount[swarming][image] | |
| extra := haveCount - config.Count | |
| if extra > 0 { | |
| vacateReason = fmt.Sprintf("it is instance number %d, want only %d instances", haveCount, config.Count) | |
| } | |
| } | |
| if vacateReason == "" { | |
| continue | |
| } | |
| // Config doesn't want this instance. Vacate. | |
| log.Printf("Lease %s is being vacated because %s", id, vacateReason) | |
| log.Printf("Vacating lease %s...", id) | |
| if err := mc.Vacate(macservice.VacateRequest{LeaseID: id}); err != nil { | |
| log.Printf("Error vacating lease %s: %v", id, err) | |
| continue | |
| } | |
| delete(leases, id) // Drop from map so future calls know it is gone. | |
| } | |
| } | |
| func makeLeaseRequest(sc *swarmingConfig, ic *imageConfig, resolveSecret func(string) (string, error)) (macservice.LeaseRequest, error) { | |
| cert, err := resolveSecret(ic.Cert) | |
| if err != nil { | |
| return macservice.LeaseRequest{}, fmt.Errorf("error resolving certificate secret: %w", err) | |
| } | |
| key, err := resolveSecret(ic.Key) | |
| if err != nil { | |
| return macservice.LeaseRequest{}, fmt.Errorf("error resolving key secret: %w", err) | |
| } | |
| return macservice.LeaseRequest{ | |
| VMResourceNamespace: macservice.Namespace{ | |
| CustomerName: macServiceCustomer, | |
| ProjectName: managedProjectPrefix + "/" + sc.Host, | |
| }, | |
| InstanceSpecification: macservice.InstanceSpecification{ | |
| Profile: macservice.V1_MEDIUM_VM, | |
| AccessLevel: macservice.GOLANG_OSS, | |
| DiskSelection: macservice.DiskSelection{ | |
| ImageHashes: macservice.ImageHashes{ | |
| BootSHA256: ic.Image, | |
| }, | |
| }, | |
| Metadata: []macservice.MetadataEntry{ | |
| { | |
| Key: "golang.swarming", | |
| Value: sc.Host, | |
| }, | |
| { | |
| Key: "golang.hostname", | |
| Value: ic.Hostname, | |
| }, | |
| { | |
| Key: "golang.cert", | |
| Value: cert, | |
| }, | |
| { | |
| Key: "golang.key", | |
| Value: key, | |
| }, | |
| }, | |
| }, | |
| Duration: createExpirationDurationString, | |
| }, nil | |
| } | |
| // addNewLeases adds new MacService leases as needed to ensure that there are | |
| // at least Count makemac-managed leases of each configured image type. | |
| // Removing leases if they exceed Count is done by handleObsoleteLeases. | |
| func addNewLeases( | |
| mc macServiceClient, config map[*swarmingConfig][]imageConfig, leases map[string]macservice.Instance, | |
| resolveSecret func(string) (string, error), | |
| ) { | |
| log.Printf("Checking if new leases are required...") | |
| // Count images per swarming host. Each host gets a different | |
| // configuration. Map of swarming host -> image sha -> count. | |
| swarmingImageCount := make(map[string]map[string]int) | |
| for _, lease := range leases { | |
| swarming := leaseSwarmingHost(lease.Lease) | |
| if swarming == "" { | |
| // Don't count leases we don't manage. | |
| continue | |
| } | |
| if _, ok := swarmingImageCount[swarming]; !ok { | |
| swarmingImageCount[swarming] = make(map[string]int) | |
| } | |
| image := lease.InstanceSpecification.DiskSelection.ImageHashes.BootSHA256 | |
| swarmingImageCount[swarming][image]++ | |
| } | |
| // Iterate through configs in swarming order, then image order. | |
| swarmingOrder := sortedSwarmingConfigs(config) | |
| imageMap := make([]map[string]*imageConfig, 0, len(swarmingOrder)) | |
| imageOrder := make([][]string, 0, len(swarmingOrder)) | |
| for _, sc := range swarmingOrder { | |
| m := imageConfigMap(config[sc]) | |
| order := make([]string, 0, len(m)) | |
| for image := range m { | |
| order = append(order, image) | |
| } | |
| sort.Strings(order) | |
| imageMap = append(imageMap, m) | |
| imageOrder = append(imageOrder, order) | |
| } | |
| log.Printf("Current image lease count:") | |
| for i, sc := range swarmingOrder { | |
| for _, image := range imageOrder[i] { | |
| config := imageMap[i][image] | |
| gotCount := swarmingImageCount[sc.Host][config.Image] | |
| log.Printf("\tHost %s: image %s: have %d leases\twant %d leases", sc.Host, config.Image, gotCount, config.Count) | |
| } | |
| } | |
| for i, sc := range swarmingOrder { | |
| for _, image := range imageOrder[i] { | |
| config := imageMap[i][image] | |
| gotCount := swarmingImageCount[sc.Host][config.Image] | |
| need := config.Count - gotCount | |
| if need <= 0 { | |
| continue | |
| } | |
| log.Printf("Host %s: image %s: creating %d new leases", sc.Host, config.Image, need) | |
| req, err := makeLeaseRequest(sc, config, resolveSecret) | |
| if err != nil { | |
| log.Printf("Host %s: image %s: creating lease request: error %v", sc.Host, config.Image, err) | |
| continue | |
| } | |
| for i := range need { | |
| log.Printf("Host %s: image %s: creating lease %d...", sc.Host, config.Image, i) | |
| resp, err := mc.Lease(req) | |
| if err != nil { | |
| log.Printf("Host %s: image %s: creating lease %d: error %v", sc.Host, config.Image, i, err) | |
| continue | |
| } | |
| log.Printf("Host %s: image %s: created lease %s", sc.Host, config.Image, resp.PendingLease.LeaseID) | |
| } | |
| } | |
| } | |
| } |