github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/cmd/peribolos/main.go (about)

     1  /*
     2  Copyright 2018 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package main
    18  
    19  import (
    20  	"errors"
    21  	"flag"
    22  	"fmt"
    23  	"os"
    24  	"strings"
    25  
    26  	"github.com/sirupsen/logrus"
    27  	"k8s.io/apimachinery/pkg/util/sets"
    28  	"sigs.k8s.io/yaml"
    29  
    30  	"k8s.io/test-infra/prow/config"
    31  	"k8s.io/test-infra/prow/config/org"
    32  	"k8s.io/test-infra/prow/config/secret"
    33  	"k8s.io/test-infra/prow/flagutil"
    34  	"k8s.io/test-infra/prow/github"
    35  	"k8s.io/test-infra/prow/logrusutil"
    36  )
    37  
    38  const (
    39  	defaultMinAdmins = 5
    40  	defaultDelta     = 0.25
    41  	defaultTokens    = 300
    42  	defaultBurst     = 100
    43  )
    44  
    45  type options struct {
    46  	config         string
    47  	confirm        bool
    48  	dump           string
    49  	jobConfig      string
    50  	maximumDelta   float64
    51  	minAdmins      int
    52  	requireSelf    bool
    53  	requiredAdmins flagutil.Strings
    54  	fixOrg         bool
    55  	fixOrgMembers  bool
    56  	fixTeamMembers bool
    57  	fixTeams       bool
    58  	github         flagutil.GitHubOptions
    59  	tokenBurst     int
    60  	tokensPerHour  int
    61  }
    62  
    63  func parseOptions() options {
    64  	var o options
    65  	if err := o.parseArgs(flag.CommandLine, os.Args[1:]); err != nil {
    66  		logrus.Fatalf("Invalid flags: %v", err)
    67  	}
    68  	return o
    69  }
    70  
    71  func (o *options) parseArgs(flags *flag.FlagSet, args []string) error {
    72  	o.requiredAdmins = flagutil.NewStrings()
    73  	flags.Var(&o.requiredAdmins, "required-admins", "Ensure config specifies these users as admins")
    74  	flags.IntVar(&o.minAdmins, "min-admins", defaultMinAdmins, "Ensure config specifies at least this many admins")
    75  	flags.BoolVar(&o.requireSelf, "require-self", true, "Ensure --github-token-path user is an admin")
    76  	flags.Float64Var(&o.maximumDelta, "maximum-removal-delta", defaultDelta, "Fail if config removes more than this fraction of current members")
    77  	flags.StringVar(&o.config, "config-path", "", "Path to prow config.yaml")
    78  	flags.StringVar(&o.jobConfig, "job-config-path", "", "Path to prow job configs.")
    79  	flags.BoolVar(&o.confirm, "confirm", false, "Mutate github if set")
    80  	flags.IntVar(&o.tokensPerHour, "tokens", defaultTokens, "Throttle hourly token consumption (0 to disable)")
    81  	flags.IntVar(&o.tokenBurst, "token-burst", defaultBurst, "Allow consuming a subset of hourly tokens in a short burst")
    82  	flags.StringVar(&o.dump, "dump", "", "Output current config of this org if set")
    83  	flags.BoolVar(&o.fixOrg, "fix-org", false, "Change org metadata if set")
    84  	flags.BoolVar(&o.fixOrgMembers, "fix-org-members", false, "Add/remove org members if set")
    85  	flags.BoolVar(&o.fixTeams, "fix-teams", false, "Create/delete/update teams if set")
    86  	flags.BoolVar(&o.fixTeamMembers, "fix-team-members", false, "Add/remove team members if set")
    87  	o.github.AddFlags(flags)
    88  	if err := flags.Parse(args); err != nil {
    89  		return err
    90  	}
    91  	if err := o.github.Validate(!o.confirm); err != nil {
    92  		return err
    93  	}
    94  	if o.tokensPerHour > 0 && o.tokenBurst >= o.tokensPerHour {
    95  		return fmt.Errorf("--tokens=%d must exceed --token-burst=%d", o.tokensPerHour, o.tokenBurst)
    96  	}
    97  
    98  	if o.minAdmins < 2 {
    99  		return fmt.Errorf("--min-admins=%d must be at least 2", o.minAdmins)
   100  	}
   101  	if o.maximumDelta > 1 || o.maximumDelta < 0 {
   102  		return fmt.Errorf("--maximum-removal-delta=%f must be a non-negative number less than 1.0", o.maximumDelta)
   103  	}
   104  
   105  	if o.confirm && o.dump != "" {
   106  		return fmt.Errorf("--confirm cannot be used with --dump=%s", o.dump)
   107  	}
   108  	if o.config == "" && o.dump == "" {
   109  		return errors.New("--config-path or --dump required")
   110  	}
   111  	if o.config != "" && o.dump != "" {
   112  		return fmt.Errorf("--config-path=%s and --dump=%s cannot both be set", o.config, o.dump)
   113  	}
   114  
   115  	if o.fixTeamMembers && !o.fixTeams {
   116  		return fmt.Errorf("--fix-team-members requires --fix-teams")
   117  	}
   118  
   119  	return nil
   120  }
   121  
   122  func main() {
   123  	logrus.SetFormatter(
   124  		logrusutil.NewDefaultFieldsFormatter(nil, logrus.Fields{"component": "peribolos"}),
   125  	)
   126  	o := parseOptions()
   127  
   128  	secretAgent := &secret.Agent{}
   129  	if err := secretAgent.Start([]string{o.github.TokenPath}); err != nil {
   130  		logrus.WithError(err).Fatal("Error starting secrets agent.")
   131  	}
   132  
   133  	githubClient, err := o.github.GitHubClient(secretAgent, !o.confirm)
   134  	if err != nil {
   135  		logrus.WithError(err).Fatal("Error getting GitHub client.")
   136  	}
   137  	if o.tokensPerHour > 0 {
   138  		githubClient.Throttle(o.tokensPerHour, o.tokenBurst) // 300 hourly tokens, bursts of 100 (default)
   139  	}
   140  
   141  	if o.dump != "" {
   142  		ret, err := dumpOrgConfig(githubClient, o.dump)
   143  		if err != nil {
   144  			logrus.WithError(err).Fatalf("Dump %s failed to collect current data.", o.dump)
   145  		}
   146  		out, err := yaml.Marshal(ret)
   147  		if err != nil {
   148  			logrus.WithError(err).Fatalf("Dump %s failed to marshal output.", o.dump)
   149  		}
   150  		logrus.Infof("Dumping orgs[\"%s\"]:", o.dump)
   151  		fmt.Println(string(out))
   152  		return
   153  	}
   154  
   155  	cfg, err := config.Load(o.config, o.jobConfig)
   156  	if err != nil {
   157  		logrus.Fatalf("Failed to load --config=%s: %v", o.config, err)
   158  	}
   159  
   160  	for name, orgcfg := range cfg.Orgs {
   161  		if err := configureOrg(o, githubClient, name, orgcfg); err != nil {
   162  			logrus.Fatalf("Configuration failed: %v", err)
   163  		}
   164  	}
   165  }
   166  
   167  type dumpClient interface {
   168  	GetOrg(name string) (*github.Organization, error)
   169  	ListOrgMembers(org, role string) ([]github.TeamMember, error)
   170  	ListTeams(org string) ([]github.Team, error)
   171  	ListTeamMembers(id int, role string) ([]github.TeamMember, error)
   172  }
   173  
   174  func dumpOrgConfig(client dumpClient, orgName string) (*org.Config, error) {
   175  	out := org.Config{}
   176  	meta, err := client.GetOrg(orgName)
   177  	if err != nil {
   178  		return nil, fmt.Errorf("failed to get org: %v", err)
   179  	}
   180  	out.Metadata.BillingEmail = &meta.BillingEmail
   181  	out.Metadata.Company = &meta.Company
   182  	out.Metadata.Email = &meta.Email
   183  	out.Metadata.Name = &meta.Name
   184  	out.Metadata.Description = &meta.Description
   185  	out.Metadata.Location = &meta.Location
   186  	out.Metadata.HasOrganizationProjects = &meta.HasOrganizationProjects
   187  	out.Metadata.HasRepositoryProjects = &meta.HasRepositoryProjects
   188  	drp := org.RepoPermissionLevel(meta.DefaultRepositoryPermission)
   189  	out.Metadata.DefaultRepositoryPermission = &drp
   190  	out.Metadata.MembersCanCreateRepositories = &meta.MembersCanCreateRepositories
   191  
   192  	admins, err := client.ListOrgMembers(orgName, github.RoleAdmin)
   193  	if err != nil {
   194  		return nil, fmt.Errorf("failed to list org admins: %v", err)
   195  	}
   196  	for _, m := range admins {
   197  		out.Admins = append(out.Admins, m.Login)
   198  	}
   199  
   200  	orgMembers, err := client.ListOrgMembers(orgName, github.RoleMember)
   201  	if err != nil {
   202  		return nil, fmt.Errorf("failed to list org members: %v", err)
   203  	}
   204  	for _, m := range orgMembers {
   205  		out.Members = append(out.Members, m.Login)
   206  	}
   207  
   208  	teams, err := client.ListTeams(orgName)
   209  	if err != nil {
   210  		return nil, fmt.Errorf("failed to list teams: %v", err)
   211  	}
   212  
   213  	names := map[int]string{}   // what's the name of a team?
   214  	idMap := map[int]org.Team{} // metadata for a team
   215  	children := map[int][]int{} // what children does it have
   216  	var tops []int              // what are the top-level teams
   217  
   218  	for _, t := range teams {
   219  		p := org.Privacy(t.Privacy)
   220  		d := t.Description
   221  		nt := org.Team{
   222  			TeamMetadata: org.TeamMetadata{
   223  				Description: &d,
   224  				Privacy:     &p,
   225  			},
   226  			Maintainers: []string{},
   227  			Members:     []string{},
   228  			Children:    map[string]org.Team{},
   229  		}
   230  		maintainers, err := client.ListTeamMembers(t.ID, github.RoleMaintainer)
   231  		if err != nil {
   232  			return nil, fmt.Errorf("failed to list team %d(%s) maintainers: %v", t.ID, t.Name, err)
   233  		}
   234  		for _, m := range maintainers {
   235  			nt.Maintainers = append(nt.Maintainers, m.Login)
   236  		}
   237  		teamMembers, err := client.ListTeamMembers(t.ID, github.RoleMember)
   238  		if err != nil {
   239  			return nil, fmt.Errorf("failed to list team %d(%s) members: %v", t.ID, t.Name, err)
   240  		}
   241  		for _, m := range teamMembers {
   242  			nt.Members = append(nt.Members, m.Login)
   243  		}
   244  
   245  		names[t.ID] = t.Name
   246  		idMap[t.ID] = nt
   247  
   248  		if t.Parent == nil { // top level team
   249  			tops = append(tops, t.ID)
   250  		} else { // add this id to the list of the parent's children
   251  			children[t.Parent.ID] = append(children[t.Parent.ID], t.ID)
   252  		}
   253  	}
   254  
   255  	var makeChild func(id int) org.Team
   256  	makeChild = func(id int) org.Team {
   257  		t := idMap[id]
   258  		for _, cid := range children[id] {
   259  			child := makeChild(cid)
   260  			t.Children[names[cid]] = child
   261  		}
   262  		return t
   263  	}
   264  
   265  	out.Teams = make(map[string]org.Team, len(tops))
   266  	for _, id := range tops {
   267  		out.Teams[names[id]] = makeChild(id)
   268  	}
   269  
   270  	return &out, nil
   271  }
   272  
   273  type orgClient interface {
   274  	BotName() (string, error)
   275  	ListOrgMembers(org, role string) ([]github.TeamMember, error)
   276  	RemoveOrgMembership(org, user string) error
   277  	UpdateOrgMembership(org, user string, admin bool) (*github.OrgMembership, error)
   278  }
   279  
   280  func configureOrgMembers(opt options, client orgClient, orgName string, orgConfig org.Config, invitees sets.String) error {
   281  	// Get desired state
   282  	wantAdmins := sets.NewString(orgConfig.Admins...)
   283  	wantMembers := sets.NewString(orgConfig.Members...)
   284  
   285  	// Sanity desired state
   286  	if n := len(wantAdmins); n < opt.minAdmins {
   287  		return fmt.Errorf("%s must specify at least %d admins, only found %d", orgName, opt.minAdmins, n)
   288  	}
   289  	var missing []string
   290  	for _, r := range opt.requiredAdmins.Strings() {
   291  		if !wantAdmins.Has(r) {
   292  			missing = append(missing, r)
   293  		}
   294  	}
   295  	if len(missing) > 0 {
   296  		return fmt.Errorf("%s must specify %v as admins, missing %v", orgName, opt.requiredAdmins, missing)
   297  	}
   298  	if opt.requireSelf {
   299  		if me, err := client.BotName(); err != nil {
   300  			return fmt.Errorf("cannot determine user making requests for %s: %v", opt.github.TokenPath, err)
   301  		} else if !wantAdmins.Has(me) {
   302  			return fmt.Errorf("authenticated user %s is not an admin of %s", me, orgName)
   303  		}
   304  	}
   305  
   306  	// Get current state
   307  	haveAdmins := sets.String{}
   308  	haveMembers := sets.String{}
   309  	ms, err := client.ListOrgMembers(orgName, github.RoleAdmin)
   310  	if err != nil {
   311  		return fmt.Errorf("failed to list %s admins: %v", orgName, err)
   312  	}
   313  	for _, m := range ms {
   314  		haveAdmins.Insert(m.Login)
   315  	}
   316  	if ms, err = client.ListOrgMembers(orgName, github.RoleMember); err != nil {
   317  		return fmt.Errorf("failed to list %s members: %v", orgName, err)
   318  	}
   319  	for _, m := range ms {
   320  		haveMembers.Insert(m.Login)
   321  	}
   322  
   323  	have := memberships{members: haveMembers, super: haveAdmins}
   324  	want := memberships{members: wantMembers, super: wantAdmins}
   325  	have.normalize()
   326  	want.normalize()
   327  	// Figure out who to remove
   328  	remove := have.all().Difference(want.all())
   329  
   330  	// Sanity check changes
   331  	if d := float64(len(remove)) / float64(len(have.all())); d > opt.maximumDelta {
   332  		return fmt.Errorf("cannot delete %d memberships or %.3f of %s (exceeds limit of %.3f)", len(remove), d, orgName, opt.maximumDelta)
   333  	}
   334  
   335  	teamMembers := sets.String{}
   336  	teamNames := sets.String{}
   337  	duplicateTeamNames := sets.String{}
   338  	for name, team := range orgConfig.Teams {
   339  		teamMembers.Insert(team.Members...)
   340  		teamMembers.Insert(team.Maintainers...)
   341  		if teamNames.Has(name) {
   342  			duplicateTeamNames.Insert(name)
   343  		}
   344  		teamNames.Insert(name)
   345  		for _, n := range team.Previously {
   346  			if teamNames.Has(n) {
   347  				duplicateTeamNames.Insert(n)
   348  			}
   349  			teamNames.Insert(n)
   350  		}
   351  	}
   352  
   353  	teamMembers = normalize(teamMembers)
   354  	if outside := teamMembers.Difference(want.all()); len(outside) > 0 {
   355  		return fmt.Errorf("all team members/maintainers must also be org members: %s", strings.Join(outside.List(), ", "))
   356  	}
   357  
   358  	if n := len(duplicateTeamNames); n > 0 {
   359  		return fmt.Errorf("team names must be unique (including previous names), %d duplicated names: %s", n, strings.Join(duplicateTeamNames.List(), ", "))
   360  	}
   361  
   362  	adder := func(user string, super bool) error {
   363  		if invitees.Has(user) { // Do not add them, as this causes another invite.
   364  			logrus.Infof("Waiting for %s to accept invitation to %s", user, orgName)
   365  			return nil
   366  		}
   367  		role := github.RoleMember
   368  		if super {
   369  			role = github.RoleAdmin
   370  		}
   371  		om, err := client.UpdateOrgMembership(orgName, user, super)
   372  		if err != nil {
   373  			logrus.WithError(err).Warnf("UpdateOrgMembership(%s, %s, %t) failed", orgName, user, super)
   374  		} else if om.State == github.StatePending {
   375  			logrus.Infof("Invited %s to %s as a %s", user, orgName, role)
   376  		} else {
   377  			logrus.Infof("Set %s as a %s of %s", user, role, orgName)
   378  		}
   379  		return err
   380  	}
   381  
   382  	remover := func(user string) error {
   383  		err := client.RemoveOrgMembership(orgName, user)
   384  		if err != nil {
   385  			logrus.WithError(err).Warnf("RemoveOrgMembership(%s, %s) failed", orgName, user)
   386  		}
   387  		return err
   388  	}
   389  
   390  	return configureMembers(have, want, invitees, adder, remover)
   391  }
   392  
   393  type memberships struct {
   394  	members sets.String
   395  	super   sets.String
   396  }
   397  
   398  func (m memberships) all() sets.String {
   399  	return m.members.Union(m.super)
   400  }
   401  
   402  func normalize(s sets.String) sets.String {
   403  	out := sets.String{}
   404  	for i := range s {
   405  		out.Insert(github.NormLogin(i))
   406  	}
   407  	return out
   408  }
   409  
   410  func (m *memberships) normalize() {
   411  	m.members = normalize(m.members)
   412  	m.super = normalize(m.super)
   413  }
   414  
   415  func configureMembers(have, want memberships, invitees sets.String, adder func(user string, super bool) error, remover func(user string) error) error {
   416  	have.normalize()
   417  	want.normalize()
   418  	if both := want.super.Intersection(want.members); len(both) > 0 {
   419  		return fmt.Errorf("users in both roles: %s", strings.Join(both.List(), ", "))
   420  	}
   421  	havePlusInvites := have.all().Union(invitees)
   422  	remove := havePlusInvites.Difference(want.all())
   423  	members := want.members.Difference(have.members)
   424  	supers := want.super.Difference(have.super)
   425  
   426  	var errs []error
   427  	for u := range members {
   428  		if err := adder(u, false); err != nil {
   429  			errs = append(errs, err)
   430  		}
   431  	}
   432  	for u := range supers {
   433  		if err := adder(u, true); err != nil {
   434  			errs = append(errs, err)
   435  		}
   436  	}
   437  
   438  	for u := range remove {
   439  		if err := remover(u); err != nil {
   440  			errs = append(errs, err)
   441  		}
   442  	}
   443  
   444  	if n := len(errs); n > 0 {
   445  		return fmt.Errorf("%d errors: %v", n, errs)
   446  	}
   447  	return nil
   448  }
   449  
   450  // findTeam returns teams[n] for the first n in [name, previousNames, ...] that is in teams.
   451  func findTeam(teams map[string]github.Team, name string, previousNames ...string) *github.Team {
   452  	if t, ok := teams[name]; ok {
   453  		return &t
   454  	}
   455  	for _, p := range previousNames {
   456  		if t, ok := teams[p]; ok {
   457  			return &t
   458  		}
   459  	}
   460  	return nil
   461  }
   462  
   463  // validateTeamNames returns an error if any current/previous names are used multiple times in the config.
   464  func validateTeamNames(orgConfig org.Config) error {
   465  	// Does the config duplicate any team names?
   466  	used := sets.String{}
   467  	dups := sets.String{}
   468  	for name, orgTeam := range orgConfig.Teams {
   469  		if used.Has(name) {
   470  			dups.Insert(name)
   471  		} else {
   472  			used.Insert(name)
   473  		}
   474  		for _, n := range orgTeam.Previously {
   475  			if used.Has(n) {
   476  				dups.Insert(n)
   477  			} else {
   478  				used.Insert(n)
   479  			}
   480  		}
   481  	}
   482  	if n := len(dups); n > 0 {
   483  		return fmt.Errorf("%d duplicated names: %s", n, strings.Join(dups.List(), ", "))
   484  	}
   485  	return nil
   486  }
   487  
   488  type teamClient interface {
   489  	ListTeams(org string) ([]github.Team, error)
   490  	CreateTeam(org string, team github.Team) (*github.Team, error)
   491  	DeleteTeam(id int) error
   492  }
   493  
   494  // configureTeams returns the ids for all expected team names, creating/deleting teams as necessary.
   495  func configureTeams(client teamClient, orgName string, orgConfig org.Config, maxDelta float64) (map[string]github.Team, error) {
   496  	if err := validateTeamNames(orgConfig); err != nil {
   497  		return nil, err
   498  	}
   499  
   500  	// What teams exist?
   501  	ids := map[int]github.Team{}
   502  	ints := sets.Int{}
   503  	teamList, err := client.ListTeams(orgName)
   504  	if err != nil {
   505  		return nil, fmt.Errorf("failed to list teams: %v", err)
   506  	}
   507  	for _, t := range teamList {
   508  		ids[t.ID] = t
   509  		ints.Insert(t.ID)
   510  	}
   511  
   512  	// What is the lowest ID for each team?
   513  	older := map[string][]github.Team{}
   514  	names := map[string]github.Team{}
   515  	for _, t := range ids {
   516  		n := t.Name
   517  		switch val, ok := names[n]; {
   518  		case !ok: // first occurrence of the name
   519  			names[n] = t
   520  		case ok && t.ID < val.ID: // t has the lower ID, replace and send current to older set
   521  			names[n] = t
   522  			older[n] = append(older[n], val)
   523  		default: // t does not have smallest id, add it to older set
   524  			older[n] = append(older[n], val)
   525  		}
   526  	}
   527  
   528  	// What team are we using for each configured name, and which names are missing?
   529  	matches := map[string]github.Team{}
   530  	missing := map[string]org.Team{}
   531  	used := sets.Int{}
   532  	var match func(teams map[string]org.Team)
   533  	match = func(teams map[string]org.Team) {
   534  		for name, orgTeam := range teams {
   535  			match(orgTeam.Children)
   536  			t := findTeam(names, name, orgTeam.Previously...)
   537  			if t == nil {
   538  				missing[name] = orgTeam
   539  				continue
   540  			}
   541  			matches[name] = *t // t.Name != name if we matched on orgTeam.Previously
   542  			used.Insert(t.ID)
   543  		}
   544  	}
   545  	match(orgConfig.Teams)
   546  
   547  	// First compute teams we will delete, ensure we are not deleting too many
   548  	unused := ints.Difference(used)
   549  	if delta := float64(len(unused)) / float64(len(ints)); delta > maxDelta {
   550  		return nil, fmt.Errorf("cannot delete %d teams or %.3f of %s teams (exceeds limit of %.3f)", len(unused), delta, orgName, maxDelta)
   551  	}
   552  
   553  	// Create any missing team names
   554  	var failures []string
   555  	for name, orgTeam := range missing {
   556  		t := &github.Team{Name: name}
   557  		if orgTeam.Description != nil {
   558  			t.Description = *orgTeam.Description
   559  		}
   560  		if orgTeam.Privacy != nil {
   561  			t.Privacy = string(*orgTeam.Privacy)
   562  		}
   563  		t, err := client.CreateTeam(orgName, *t)
   564  		if err != nil {
   565  			logrus.WithError(err).Warnf("Failed to create %s in %s", name, orgName)
   566  			failures = append(failures, name)
   567  			continue
   568  		}
   569  		matches[name] = *t
   570  		// t.ID may include an ID already present in ints if other actors are deleting teams.
   571  		used.Insert(t.ID)
   572  	}
   573  	if n := len(failures); n > 0 {
   574  		return nil, fmt.Errorf("failed to create %d teams: %s", n, strings.Join(failures, ", "))
   575  	}
   576  
   577  	// Remove any IDs returned by CreateTeam() that are in the unused set.
   578  	if reused := unused.Intersection(used); len(reused) > 0 {
   579  		// Logically possible for:
   580  		// * another actor to delete team N after the ListTeams() call
   581  		// * github to reuse team N after someone deleted it
   582  		// Therefore used may now include IDs in unused, handle this situation.
   583  		logrus.Warnf("Will not delete %d team IDs reused by github: %v", len(reused), reused.List())
   584  		unused = unused.Difference(reused)
   585  	}
   586  	// Delete undeclared teams.
   587  	for id := range unused {
   588  		if err := client.DeleteTeam(id); err != nil {
   589  			str := fmt.Sprintf("%d(%s)", id, ids[id].Name)
   590  			logrus.WithError(err).Warnf("Failed to delete team %s from %s", str, orgName)
   591  			failures = append(failures, str)
   592  		}
   593  	}
   594  	if n := len(failures); n > 0 {
   595  		return nil, fmt.Errorf("failed to delete %d teams: %s", n, strings.Join(failures, ", "))
   596  	}
   597  
   598  	// Return matches
   599  	return matches, nil
   600  }
   601  
   602  // updateString will return true and set have to want iff they are set and different.
   603  func updateString(have, want *string) bool {
   604  	switch {
   605  	case have == nil:
   606  		panic("have must be non-nil")
   607  	case want == nil:
   608  		return false // do not care what we have
   609  	case *have == *want:
   610  		return false // already have it
   611  	}
   612  	*have = *want // update value
   613  	return true
   614  }
   615  
   616  // updateBool will return true and set have to want iff they are set and different.
   617  func updateBool(have, want *bool) bool {
   618  	switch {
   619  	case have == nil:
   620  		panic("have must not be nil")
   621  	case want == nil:
   622  		return false // do not care what we have
   623  	case *have == *want:
   624  		return false //already have it
   625  	}
   626  	*have = *want // update value
   627  	return true
   628  }
   629  
   630  type orgMetadataClient interface {
   631  	GetOrg(name string) (*github.Organization, error)
   632  	EditOrg(name string, org github.Organization) (*github.Organization, error)
   633  }
   634  
   635  // configureOrgMeta will update github to have the non-nil wanted metadata values.
   636  func configureOrgMeta(client orgMetadataClient, orgName string, want org.Metadata) error {
   637  	cur, err := client.GetOrg(orgName)
   638  	if err != nil {
   639  		return fmt.Errorf("failed to get %s metadata: %v", orgName, err)
   640  	}
   641  	change := false
   642  	change = updateString(&cur.BillingEmail, want.BillingEmail) || change
   643  	change = updateString(&cur.Company, want.Company) || change
   644  	change = updateString(&cur.Email, want.Email) || change
   645  	change = updateString(&cur.Name, want.Name) || change
   646  	change = updateString(&cur.Description, want.Description) || change
   647  	change = updateString(&cur.Location, want.Location) || change
   648  	if want.DefaultRepositoryPermission != nil {
   649  		w := string(*want.DefaultRepositoryPermission)
   650  		change = updateString(&cur.DefaultRepositoryPermission, &w)
   651  	}
   652  	change = updateBool(&cur.HasOrganizationProjects, want.HasOrganizationProjects) || change
   653  	change = updateBool(&cur.HasRepositoryProjects, want.HasRepositoryProjects) || change
   654  	change = updateBool(&cur.MembersCanCreateRepositories, want.MembersCanCreateRepositories) || change
   655  	if change {
   656  		if _, err := client.EditOrg(orgName, *cur); err != nil {
   657  			return fmt.Errorf("failed to edit %s metadata: %v", orgName, err)
   658  		}
   659  	}
   660  	return nil
   661  }
   662  
   663  type inviteClient interface {
   664  	ListOrgInvitations(org string) ([]github.OrgInvitation, error)
   665  }
   666  
   667  func orgInvitations(opt options, client inviteClient, orgName string) (sets.String, error) {
   668  	invitees := sets.String{}
   669  	if !opt.fixOrgMembers && !opt.fixTeamMembers {
   670  		return invitees, nil
   671  	}
   672  	is, err := client.ListOrgInvitations(orgName)
   673  	if err != nil {
   674  		return nil, err
   675  	}
   676  	for _, i := range is {
   677  		if i.Login == "" {
   678  			continue
   679  		}
   680  		invitees.Insert(github.NormLogin(i.Login))
   681  	}
   682  	return invitees, nil
   683  }
   684  
   685  func configureOrg(opt options, client *github.Client, orgName string, orgConfig org.Config) error {
   686  	// Ensure that metadata is configured correctly.
   687  	if !opt.fixOrg {
   688  		logrus.Infof("Skipping org metadata configuration")
   689  	} else if err := configureOrgMeta(client, orgName, orgConfig.Metadata); err != nil {
   690  		return err
   691  	}
   692  
   693  	invitees, err := orgInvitations(opt, client, orgName)
   694  	if err != nil {
   695  		return fmt.Errorf("failed to list %s invitations: %v", orgName, err)
   696  	}
   697  
   698  	// Invite/remove/update members to the org.
   699  	if !opt.fixOrgMembers {
   700  		logrus.Infof("Skipping org member configuration")
   701  	} else if err := configureOrgMembers(opt, client, orgName, orgConfig, invitees); err != nil {
   702  		return fmt.Errorf("failed to configure %s members: %v", orgName, err)
   703  	}
   704  
   705  	if !opt.fixTeams {
   706  		logrus.Infof("Skipping team and team member configuration")
   707  		return nil
   708  	}
   709  
   710  	// Find the id and current state of each declared team (create/delete as necessary)
   711  	githubTeams, err := configureTeams(client, orgName, orgConfig, opt.maximumDelta)
   712  	if err != nil {
   713  		return fmt.Errorf("failed to configure %s teams: %v", orgName, err)
   714  	}
   715  
   716  	for name, team := range orgConfig.Teams {
   717  		err := configureTeamAndMembers(opt, client, githubTeams, name, orgName, team, nil)
   718  		if err != nil {
   719  			return fmt.Errorf("failed to configure %s teams: %v", orgName, err)
   720  		}
   721  	}
   722  	return nil
   723  }
   724  
   725  func configureTeamAndMembers(opt options, client *github.Client, githubTeams map[string]github.Team, name, orgName string, team org.Team, parent *int) error {
   726  	gt, ok := githubTeams[name]
   727  	if !ok { // configureTeams is buggy if this is the case
   728  		return fmt.Errorf("%s not found in id list", name)
   729  	}
   730  
   731  	// Configure team metadata
   732  	err := configureTeam(client, orgName, name, team, gt, parent)
   733  	if err != nil {
   734  		return fmt.Errorf("failed to update %s metadata: %v", name, err)
   735  	}
   736  
   737  	// Configure team members
   738  	if !opt.fixTeamMembers {
   739  		logrus.Infof("Skipping %s member configuration", name)
   740  	} else if err = configureTeamMembers(client, gt.ID, team); err != nil {
   741  		return fmt.Errorf("failed to update %s members: %v", name, err)
   742  	}
   743  
   744  	for childName, childTeam := range team.Children {
   745  		err = configureTeamAndMembers(opt, client, githubTeams, childName, orgName, childTeam, &gt.ID)
   746  		if err != nil {
   747  			return fmt.Errorf("failed to update %s child teams: %v", name, err)
   748  		}
   749  	}
   750  
   751  	return nil
   752  }
   753  
   754  type editTeamClient interface {
   755  	EditTeam(team github.Team) (*github.Team, error)
   756  }
   757  
   758  // configureTeam patches the team name/description/privacy when values differ
   759  func configureTeam(client editTeamClient, orgName, teamName string, team org.Team, gt github.Team, parent *int) error {
   760  	// Do we need to reconfigure any team settings?
   761  	patch := false
   762  	if gt.Name != teamName {
   763  		patch = true
   764  	}
   765  	gt.Name = teamName
   766  	if team.Description != nil && gt.Description != *team.Description {
   767  		patch = true
   768  		gt.Description = *team.Description
   769  	} else {
   770  		gt.Description = ""
   771  	}
   772  	// doesn't have parent in github, but has parent in config
   773  	if gt.Parent == nil && parent != nil {
   774  		patch = true
   775  		gt.ParentTeamID = parent
   776  	}
   777  	if gt.Parent != nil { // has parent in github ...
   778  		if parent == nil { // ... but doesn't need one
   779  			patch = true
   780  			gt.Parent = nil
   781  			gt.ParentTeamID = parent
   782  		} else if gt.Parent.ID != *parent { // but it's different than the config
   783  			patch = true
   784  			gt.Parent = nil
   785  			gt.ParentTeamID = parent
   786  		}
   787  	}
   788  
   789  	if team.Privacy != nil && gt.Privacy != string(*team.Privacy) {
   790  		patch = true
   791  		gt.Privacy = string(*team.Privacy)
   792  
   793  	} else if team.Privacy == nil && (parent != nil || len(team.Children) > 0) && gt.Privacy != "closed" {
   794  		patch = true
   795  		gt.Privacy = github.PrivacyClosed // nested teams must be closed
   796  	}
   797  
   798  	if patch { // yes we need to patch
   799  		if _, err := client.EditTeam(gt); err != nil {
   800  			return fmt.Errorf("failed to edit %s team %d(%s): %v", orgName, gt.ID, gt.Name, err)
   801  		}
   802  	}
   803  	return nil
   804  }
   805  
   806  // teamMembersClient can list/remove/update people to a team.
   807  type teamMembersClient interface {
   808  	ListTeamMembers(id int, role string) ([]github.TeamMember, error)
   809  	ListTeamInvitations(id int) ([]github.OrgInvitation, error)
   810  	RemoveTeamMembership(id int, user string) error
   811  	UpdateTeamMembership(id int, user string, maintainer bool) (*github.TeamMembership, error)
   812  }
   813  
   814  func teamInvitations(client teamMembersClient, teamID int) (sets.String, error) {
   815  	invitees := sets.String{}
   816  	is, err := client.ListTeamInvitations(teamID)
   817  	if err != nil {
   818  		return nil, err
   819  	}
   820  	for _, i := range is {
   821  		if i.Login == "" {
   822  			continue
   823  		}
   824  		invitees.Insert(github.NormLogin(i.Login))
   825  	}
   826  	return invitees, nil
   827  }
   828  
   829  // configureTeamMembers will add/update people to the appropriate role on the team, and remove anyone else.
   830  func configureTeamMembers(client teamMembersClient, id int, team org.Team) error {
   831  	// Get desired state
   832  	wantMaintainers := sets.NewString(team.Maintainers...)
   833  	wantMembers := sets.NewString(team.Members...)
   834  
   835  	// Get current state
   836  	haveMaintainers := sets.String{}
   837  	haveMembers := sets.String{}
   838  
   839  	members, err := client.ListTeamMembers(id, github.RoleMember)
   840  	if err != nil {
   841  		return fmt.Errorf("failed to list %d members: %v", id, err)
   842  	}
   843  	for _, m := range members {
   844  		haveMembers.Insert(m.Login)
   845  	}
   846  
   847  	maintainers, err := client.ListTeamMembers(id, github.RoleMaintainer)
   848  	if err != nil {
   849  		return fmt.Errorf("failed to list %d maintainers: %v", id, err)
   850  	}
   851  	for _, m := range maintainers {
   852  		haveMaintainers.Insert(m.Login)
   853  	}
   854  
   855  	invitees, err := teamInvitations(client, id)
   856  	if err != nil {
   857  		return fmt.Errorf("failed to list %d invitees: %v", id, err)
   858  	}
   859  
   860  	adder := func(user string, super bool) error {
   861  		if invitees.Has(user) {
   862  			logrus.Infof("Waiting for %s to accept invitation to %d", user, id)
   863  			return nil
   864  		}
   865  		role := github.RoleMember
   866  		if super {
   867  			role = github.RoleMaintainer
   868  		}
   869  		tm, err := client.UpdateTeamMembership(id, user, super)
   870  		if err != nil {
   871  			logrus.WithError(err).Warnf("UpdateTeamMembership(%d, %s, %t) failed", id, user, super)
   872  		} else if tm.State == github.StatePending {
   873  			logrus.Infof("Invited %s to %d as a %s", user, id, role)
   874  		} else {
   875  			logrus.Infof("Set %s as a %s of %d", user, role, id)
   876  		}
   877  		return err
   878  	}
   879  
   880  	remover := func(user string) error {
   881  		err := client.RemoveTeamMembership(id, user)
   882  		if err != nil {
   883  			logrus.WithError(err).Warnf("RemoveTeamMembership(%d, %s) failed", id, user)
   884  		} else {
   885  			logrus.Infof("Removed %s from team %d", user, id)
   886  		}
   887  		return err
   888  	}
   889  
   890  	want := memberships{members: wantMembers, super: wantMaintainers}
   891  	have := memberships{members: haveMembers, super: haveMaintainers}
   892  	return configureMembers(have, want, invitees, adder, remover)
   893  }