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