community/groups/groups_test.go

354 lines
10 KiB
Go

/*
Copyright 2019 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 (
"flag"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"unicode/utf8"
"k8s.io/apimachinery/pkg/util/sets"
)
var cfg GroupsConfig
var rConfig RestrictionsConfig
var groupsPath = flag.String("groups-path", "", "Directory containing groups.yaml files")
var restrictionsPath = flag.String("restrictions-path", "", "Path to the configuration file containing restrictions")
func TestMain(m *testing.M) {
flag.Parse()
var err error
if *restrictionsPath != "" && !filepath.IsAbs(*restrictionsPath) {
fmt.Printf("restrictions-path \"%s\" must be an absolute path\n", *restrictionsPath)
os.Exit(1)
}
if *restrictionsPath == "" {
baseDir, err := os.Getwd()
if err != nil {
fmt.Printf("Cannot get current working directory: %v\n", err)
os.Exit(1)
}
rPath := filepath.Join(baseDir, defaultRestrictionsFile)
restrictionsPath = &rPath
}
if err := rConfig.Load(*restrictionsPath); err != nil {
fmt.Printf("Could not load restrictions config: %v\n", err)
os.Exit(1)
}
if *groupsPath != "" && !filepath.IsAbs(*groupsPath) {
fmt.Printf("groups-path \"%s\" must be an absolute path\n", *groupsPath)
os.Exit(1)
}
if *groupsPath == "" {
*groupsPath, err = os.Getwd()
if err != nil {
fmt.Printf("Cannot get current working directory: %v\n", err)
os.Exit(1)
}
}
if err := cfg.Load(*groupsPath, &rConfig); err != nil {
fmt.Printf("Could not load groups config: %v\n", err)
os.Exit(1)
}
os.Exit(m.Run())
}
// TestMergedGroupsConfig tests that readGroupsConfig reads all
// groups.yaml files and the merged config does not contain any duplicates.
//
// It tests that the config is merged by checking that the final
// GroupsConfig contains at least one group that isn't in the
// root groups.yaml file.
func TestMergedGroupsConfig(t *testing.T) {
var containsMergedConfig bool
found := sets.String{}
dups := sets.String{}
for _, g := range cfg.Groups {
name := g.Name
if name == "steering" {
containsMergedConfig = true
}
if found.Has(name) {
dups.Insert(name)
}
found.Insert(name)
}
if !containsMergedConfig {
t.Errorf("Final GroupsConfig does not have merged configs from all groups.yaml files")
}
if n := len(dups); n > 0 {
t.Errorf("%d duplicate groups: %s", n, strings.Join(dups.List(), ", "))
}
}
// TestStagingEmailLength tests that the number of characters in the
// project name in emails used for staging repos does not exceed 18.
//
// This validation is needed because gcloud allows PROJECT_IDs of length
// between 6 and 30. So after discounting the "k8s-staging" prefix,
// we are left with 18 chars for the project name.
func TestStagingEmailLength(t *testing.T) {
var errs []error
for _, g := range cfg.Groups {
if strings.HasPrefix(g.EmailId, "k8s-infra-staging-") {
projectName := strings.TrimSuffix(strings.TrimPrefix(g.EmailId, "k8s-infra-staging-"), "@knative.team")
len := utf8.RuneCountInString(projectName)
if len > 18 {
errs = append(errs, fmt.Errorf("Number of characters in project name \"%s\" should not exceed 18; is: %d", projectName, len))
}
}
}
if len(errs) > 0 {
for _, err := range errs {
t.Error(err)
}
}
}
// TestDescriptionLength tests that the number of characters in the
// google groups description does not exceed 300.
//
// This validation is needed because gcloud allows apps:description
// with length no greater than 300
func TestDescriptionLength(t *testing.T) {
var errs []error
for _, g := range cfg.Groups {
description := g.Description
len := utf8.RuneCountInString(description)
//Ref: https://developers.google.com/admin-sdk/groups-settings/v1/reference/groups
if len > 300 {
errs = append(errs,
fmt.Errorf("Number of characters in description \"%s\" for group name \"%s\" "+
"should not exceed 300; is: %d", description, g.Name, len))
}
}
if len(errs) > 0 {
for _, err := range errs {
t.Error(err)
}
}
}
// Enforce conventions for all groups
func TestGroupConventions(t *testing.T) {
for _, g := range cfg.Groups {
// groups are easier to reason about if email and name match
if !(g.EmailId == g.Name+"@knative.team" || g.EmailId == g.Name+"@knative.dev") {
t.Errorf("group '%s': expected email '%s@knative.dev or %s@knative.team', got '%s'", g.Name, g.Name, g.Name, g.EmailId)
}
}
}
// Enforce conventions for all k8s-infra groups
func TestK8sInfraGroupConventions(t *testing.T) {
for _, g := range cfg.Groups {
if strings.HasPrefix(g.EmailId, "k8s-infra") {
// no owners because we want to prevent manual membership changes
if len(g.Owners) > 0 {
t.Errorf("group '%s': must have no owners, only members", g.Name)
}
// treat files here as source of truth for membership
reconcileMembers, ok := g.Settings["ReconcileMembers"]
if !ok || reconcileMembers != "true" {
t.Errorf("group '%s': must have settings.ReconcileMembers = true", g.Name)
}
}
}
}
// Enforce conventions for groups used by GKE Group-based RBAC
// - there must be a gke-security-groups@ group
// - its members must be k8s-infra-rbac-*@ groups (and vice-versa)
// - all groups involved must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW
func TestK8sInfraRBACGroupConventions(t *testing.T) {
rbacEmails := make(map[string]bool)
for _, g := range cfg.Groups {
if strings.HasPrefix(g.EmailId, "k8s-infra-rbac") {
rbacEmails[g.EmailId] = false
// this is necessary for group-based rbac to work
whoCanViewMembership, ok := g.Settings["WhoCanViewMembership"]
if !ok || whoCanViewMembership != "ALL_MEMBERS_CAN_VIEW" {
t.Errorf("group '%s': must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW", g.Name)
}
}
}
foundGKEGroup := false
for _, g := range cfg.Groups {
if g.EmailId == "gke-security-groups@knative.dev" {
foundGKEGroup = true
// this is necessary for group-based rbac to work
whoCanViewMembership, ok := g.Settings["WhoCanViewMembership"]
if !ok || whoCanViewMembership != "ALL_MEMBERS_CAN_VIEW" {
t.Errorf("group '%s': must have settings.WhoCanViewMembership = ALL_MEMBERS_CAN_VIEW", g.Name)
}
for _, email := range g.Members {
if _, ok := rbacEmails[email]; !ok {
t.Errorf("group '%s': invalid member '%s', must be a k8s-infra-rbac-*@knative.dev group", g.Name, email)
} else {
rbacEmails[email] = true
}
}
}
}
if !foundGKEGroup {
t.Errorf("group '%s' is missing", "gke-security-groups@knative.dev")
}
for email, found := range rbacEmails {
if !found {
t.Errorf("group '%s': must be a member of gke-security-groups@knative.dev", email)
}
}
}
// An e-mail address can only show up once within a given group, whether that
// be as a member, manager, or owner
func TestNoDuplicateMembers(t *testing.T) {
for _, g := range cfg.Groups {
members := map[string]bool{}
for _, m := range g.Members {
if _, ok := members[m]; ok {
t.Errorf("group '%s' cannot have duplicate member '%s'", g.EmailId, m)
}
members[m] = true
}
managers := map[string]bool{}
for _, m := range g.Managers {
if _, ok := members[m]; ok {
t.Errorf("group '%s' manager '%s' cannot also be listed as a member", g.EmailId, m)
}
if _, ok := managers[m]; ok {
t.Errorf("group '%s' cannot have duplicate manager '%s'", g.EmailId, m)
}
managers[m] = true
}
owners := map[string]bool{}
for _, m := range g.Owners {
if _, ok := members[m]; ok {
t.Errorf("group '%s' owner '%s' cannot also be listed as a member", g.EmailId, m)
}
if _, ok := managers[m]; ok {
t.Errorf("group '%s' owner '%s' cannot also be listed as a manager", g.EmailId, m)
}
if _, ok := owners[m]; ok {
t.Errorf("group '%s' cannot have duplicate owner '%s'", g.EmailId, m)
}
owners[m] = true
}
}
}
// NOTE: make very certain you know what you are doing if you change one
// // of these groups, we don't want to accidentally lock ourselves out
func TestHardcodedGroupsForParanoia(t *testing.T) {
groups := map[string][]string{
"kn-infra-gcp-org-admins@knative.dev": {
"cy@borg.dev",
"cy@knative.team",
"hh@cncf.io",
"hh@knative.team",
"dprotaso@gmail.com",
"jeffrey@cncf.io",
"dkrook@linuxfoundation.org",
"ksuszyns@redhat.com",
"mario@kubermatic.com",
},
}
found := make(map[string]bool)
for _, g := range cfg.Groups {
if expected, ok := groups[g.EmailId]; ok {
found[g.EmailId] = true
sort.Strings(expected)
actual := make([]string, len(g.Members))
copy(actual, g.Members)
sort.Strings(actual)
if !reflect.DeepEqual(expected, actual) {
t.Errorf("group '%s': expected members '%v', got '%v'", g.Name, expected, actual)
}
}
}
for email := range groups {
if _, ok := found[email]; !ok {
t.Errorf("group '%s' is missing, should be present", email)
}
}
}
// Setting AllowWebPosting should be set for every group which should support
// access to the group not only via gmail but also via web (you can see the list
// and history of threads and also use web interface to operate the group)
// More info:
//
// https://developers.google.com/admin-sdk/groups-settings/v1/reference/groups#allowWebPosting
func TestGroupsWhichShouldSupportHistory(t *testing.T) {
groups := map[string]struct{}{
"wg-leads@knative.team": {},
}
found := make(map[string]struct{})
for _, group := range cfg.Groups {
emailId := group.EmailId
found[emailId] = struct{}{}
if _, ok := groups[emailId]; ok {
allowedWebPosting, ok := group.Settings["AllowWebPosting"]
if !ok {
t.Errorf(
"group '%s': must have 'settings.allowedWebPosting = true'",
group.Name,
)
} else if allowedWebPosting != "true" {
t.Errorf(
"group '%s': must have 'settings.allowedWebPosting = true'"+
" but have 'settings.allowedWebPosting = %s' instead",
group.Name,
allowedWebPosting,
)
}
}
}
for email := range groups {
if _, ok := found[email]; !ok {
t.Errorf("group '%s' is missing, should be present", email)
}
}
}