terraorg 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9dcc3e713e1ec4a3166adfd99ecb9cccb52efd37f489fc18ff2393be0b79ac1a
4
- data.tar.gz: 0d12305ad0d7f2aa129336010f719f0d1361c0d28b2fa9466c2c1205d280830b
3
+ metadata.gz: 46107b71a1eace06c51463513c5b495b9549ec19ebc911103ec0d7f236fec6f8
4
+ data.tar.gz: b05708c67d359a3040eca8724140603d5c4debd6e2f1a25c87034e8a9b60e7be
5
5
  SHA512:
6
- metadata.gz: 19df8bcc655abfe6eb89a7d5e8d50d7f76298a492b14cb2f37369b17b2f396822897d264d6f861f215faabd0e74af0dc72a23756cac1e319c34d36a60c915783
7
- data.tar.gz: c3b93acbeaa7f18f6f64c4343944649acf2630a99823b53fce3789c20fb1fa8a5be8bebf9159c2a48ae39eb96b5d4af1c669b23d879087a38104f36d330aeb09
6
+ metadata.gz: 9f9286df25676340a5e3a36221f5b7de4ed21cce63281850210f18b360474544e453939b8b4559dec6cb58dfa0b9ce5facca21570f03295cb5f9d0b5c56eff57
7
+ data.tar.gz: 196d63d8df921c54216511ee7604b6ec8f8813241609a1fb0c4fa5480d965c4d2e9f0b2042a0f55a46b1fbaa8cb633f50317608afbf68f71d42b8b00fc877f83
data/README.md CHANGED
@@ -34,7 +34,9 @@ Based on the org that this tool was originally designed for, orgs are expected
34
34
  to have three levels:
35
35
 
36
36
  * *squads*: the base unit of team-dom, containing people, who may be in
37
- different geographical regions.
37
+ different geographical regions. Teams contain _members_ (full time heads)
38
+ and _associates_ (typically part time floaters.) Any associate of a squad
39
+ must also have a home squad for which they are a full time member.
38
40
  * *platoons*: a unit which contains squads and exceptional people who are
39
41
  members of the platoon, but not part of any squad
40
42
  * *org*: The whole organization, including its manager, any exceptional squads
@@ -45,6 +47,10 @@ The tool generates groups for each granular unit of organization in Okta and G
45
47
  Suite in Terraform. With patching, it could be possible for more organizational
46
48
  systems to be supported.
47
49
 
50
+ ## Diagram
51
+
52
+ ![Diagram of org structure](img/diagram.png)
53
+
48
54
  ## How it works
49
55
 
50
56
  Firstly, take your entire existing organization and define it using the
@@ -120,6 +126,10 @@ information on how to configure the providers.
120
126
  [articulate/terraform-provider-okta]: https://github.com/articulate/terraform-provider-okta
121
127
  [DeviaVir/terraform-provider-gsuite]: https://github.com/DeviaVir/terraform-provider-gsuite
122
128
 
129
+ ## Running tests
130
+ There are a limited number of tests that can be invoked with
131
+ `ruby -I lib test/terraorg/model/org_test.rb `
132
+
123
133
  ## Suggested process
124
134
 
125
135
  At [LiveRamp], a pull request based workflow leveraging [Atlantis] is used to
@@ -31,14 +31,15 @@ ACTIONS = [
31
31
  'validate'
32
32
  ].freeze
33
33
 
34
+ STRICT_VALIDATION = ENV.fetch('TERRAORG_STRICT_VALIDATION', 'true')
34
35
  SQUADS_FILE = ENV.fetch('TERRAORG_SQUADS', 'squads.json')
35
36
  PLATOONS_FILE = ENV.fetch('TERRAORG_PLATOONS', 'platoons.json')
36
37
  ORG_FILE = ENV.fetch('TERRAORG_ROOT', 'org.json')
37
38
  CACHE_FILE = ENV.fetch('TERRAORG_OKTA_CACHE', 'okta_cache.json')
38
- GSUITE_DOMAIN = ENV.fetch('GSUITE_DOMAIN')
39
- SLACK_DOMAIN = ENV.fetch('SLACK_DOMAIN', 'yourcompany.slack.com')
40
- OKTA_ORG_NAME = ENV.fetch('OKTA_ORG_NAME')
41
- OKTA_API_TOKEN = ENV.fetch('OKTA_API_TOKEN')
39
+ GSUITE_DOMAIN = ENV.fetch('GSUITE_DOMAIN', 'fillmein_gsuite.com')
40
+ SLACK_DOMAIN = ENV.fetch('SLACK_DOMAIN', 'fillmein.slack.com')
41
+ OKTA_ORG_NAME = ENV.fetch('OKTA_ORG_NAME', 'fillmein_okta')
42
+ OKTA_API_TOKEN = ENV.fetch('OKTA_API_TOKEN', 'fillmein_okta_api_token')
42
43
  OKTA_BASE_URL = ENV.fetch('OKTA_BASE_URL', 'okta.com')
43
44
 
44
45
  action = ARGV[0]
@@ -95,7 +96,8 @@ platoons = Platoons.new(JSON.parse(platoons_data), squads, people, GSUITE_DOMAIN
95
96
  org_data = File.read(ORG_FILE)
96
97
  org = Org.new(JSON.parse(org_data), platoons, squads, people, GSUITE_DOMAIN)
97
98
 
98
- org.validate!
99
+ strict = (STRICT_VALIDATION == 'true')
100
+ org.validate!(strict: strict)
99
101
 
100
102
  case action
101
103
  when 'generate-squads-md'
@@ -15,7 +15,8 @@
15
15
  require 'terraorg/model/util'
16
16
 
17
17
  class Org
18
- MAX_SQUADS_PER_PERSON = 2
18
+ MAX_MEMBER_SQUADS_PER_PERSON = 1
19
+ MAX_ASSOCIATE_SQUADS_PER_PERSON = 3
19
20
  SCHEMA_VERSION = 'v1'.freeze
20
21
 
21
22
  def initialize(parsed_data, platoons, squads, people, gsuite_domain)
@@ -48,23 +49,33 @@ class Org
48
49
  @squads = squads
49
50
  end
50
51
 
51
- def validate!
52
+ def validate!(strict: true)
53
+ failure = false
54
+
52
55
  # Do not allow the JSON files to contain any people who have left.
53
- raise "Users have left the company: #{@people.inactive.map(&:id).join(', ')}" unless @people.inactive.empty?
56
+ unless @people.inactive.empty?
57
+ $stderr.puts "ERROR: Users have left the company, or are Suspended in Okta: #{@people.inactive.map(&:id).join(', ')}"
58
+ failure = true
59
+ end
54
60
 
55
61
  # Do not allow the org to be totally empty.
56
- raise 'Org has no platoons or exception squads' if @member_platoons.size + @member_exception_squads.size == 0
62
+ if @member_platoons.size + @member_exception_squads.size == 0
63
+ $stderr.puts 'ERROR: Org has no platoons or exception squads'
64
+ failure = true
65
+ end
57
66
 
58
67
  # Require all platoons to be part of the org.
59
68
  platoon_diff = Set.new(@platoons.all_names) - Set.new(@member_platoon_names)
60
69
  unless platoon_diff.empty?
61
- raise "Platoons are not used in the org: #{platoon_diff.to_a.sort}"
70
+ $stderr.puts "ERROR: Platoons are not used in the org: #{platoon_diff.to_a.sort}"
71
+ failure = true
62
72
  end
63
73
 
64
74
  # Require all squads to be used in the org.
65
75
  squad_diff = Set.new(@squads.all_names) - Set.new(@platoons.all_squad_names) - Set.new(@member_exception_squad_names)
66
76
  unless squad_diff.empty?
67
- raise "Squad(s) are not used in the org: #{squad_diff.to_a.sort}"
77
+ $stderr.puts "ERROR: Squad(s) are not used in the org: #{squad_diff.to_a.sort}"
78
+ failure = true
68
79
  end
69
80
 
70
81
  all_squads = (@member_platoons.map(&:member_squads) + @member_exception_squads).flatten
@@ -78,20 +89,37 @@ class Org
78
89
  count > 1
79
90
  end
80
91
  if !more_than_one_platoon.empty?
81
- raise "Squads are part of more than one platoon: #{more_than_one_platoon}"
92
+ $stderr.puts "ERROR: Squads are part of more than one platoon: #{more_than_one_platoon}"
93
+ failure = true
82
94
  end
83
95
 
84
- # Validate that a squad member belongs to at most two squads in the entire org
96
+ # Validate that a squad member belongs to some maximum number of squads
97
+ # across the entire org. A person can be an associate of other squads
98
+ # at a different count. See top of file for defined limits.
85
99
  squad_count = {}
86
- all_squads.map(&:teams).flatten.map(&:values).flatten.map(&:members).flatten.each do |member|
100
+ all_members = all_squads.map(&:teams).flatten.map(&:values).flatten.map(&:members).flatten
101
+ all_members.each do |member|
87
102
  squad_count[member.id] = squad_count.fetch(member.id, 0) + 1
88
103
  end
89
104
  more_than_max_squads = squad_count.select do |member, count|
90
- count > MAX_SQUADS_PER_PERSON
105
+ count > MAX_MEMBER_SQUADS_PER_PERSON
91
106
  end
92
107
  if !more_than_max_squads.empty?
93
- # TODO(joshk): Enforce after consulting with Sean
94
- $stderr.puts "WARNING: Members are part of more than #{MAX_SQUADS_PER_PERSON} squads: #{more_than_max_squads}"
108
+ $stderr.puts "ERROR: People are members of more than #{MAX_MEMBER_SQUADS_PER_PERSON} squads: #{more_than_max_squads}"
109
+ failure = true
110
+ end
111
+
112
+ associate_count = {}
113
+ all_associates = all_squads.map(&:teams).flatten.map(&:values).flatten.map(&:associates).flatten
114
+ all_associates.each do |assoc|
115
+ associate_count[assoc.id] = associate_count.fetch(assoc.id, 0) + 1
116
+ end
117
+ more_than_max_squads = associate_count.select do |_, count|
118
+ count > MAX_ASSOCIATE_SQUADS_PER_PERSON
119
+ end
120
+ if !more_than_max_squads.empty?
121
+ $stderr.puts "ERROR: People are associates of more than #{MAX_ASSOCIATE_SQUADS_PER_PERSON} squads: #{more_than_max_squads}"
122
+ failure = true
95
123
  end
96
124
 
97
125
  # Validate that a squad member is not also an org exception
@@ -100,8 +128,18 @@ class Org
100
128
  exceptions.member? member
101
129
  end
102
130
  if !exception_and_squad_member.empty?
103
- raise "Exception members are also squad members: #{exception_and_squad_member}"
131
+ $stderr.puts "ERROR: Exception members are also squad members: #{exception_and_squad_member}"
132
+ failure = true
133
+ end
134
+
135
+ # Validate that any associate is a member of some squad
136
+ associates_but_not_members = Set.new(all_associates.map(&:id)) - Set.new(all_members.map(&:id)) - exceptions
137
+ if !associates_but_not_members.empty?
138
+ $stderr.puts "ERROR: #{associates_but_not_members.map(&:id)} are associates of squads but not members of any squad"
139
+ failure = true
104
140
  end
141
+
142
+ raise "CRITICAL: Validation failed due to at least one error above" if failure && strict
105
143
  end
106
144
 
107
145
  def members
@@ -153,8 +191,8 @@ class Org
153
191
  md_lines = [
154
192
  '# Engineering Squads List',
155
193
  '',
156
- '|Platoon|Squad|PM|Mailing list|TS SME|Slack|# Engineers|Squad Manager|Eng Product Owner|Members|',
157
- '|---|---|---|---|---|---|---|---|---|---|',
194
+ '|Platoon|Squad|PM|Mailing list|TS SME|Slack|# Engineers|Squad Manager|Members|',
195
+ '|---|---|---|---|---|---|---|---|---|',
158
196
  ]
159
197
  md_lines += @member_platoons.map { |s| s.get_squads_psv_rows(@id) }
160
198
  md_lines += @member_exception_squads.map { |s| s.to_md('_No Platoon_', @id) }
@@ -164,13 +202,16 @@ class Org
164
202
  md_lines.join("\n")
165
203
  end
166
204
 
167
- def generate_tf
168
- tf = @member_platoons.map { |p| p.generate_tf(@id) }.join("\n")
169
- File.write('auto.platoons.tf', tf)
205
+ def generate_tf_platoons
206
+ @member_platoons.map { |p| p.generate_tf(@id) }.join("\n")
207
+ end
170
208
 
171
- tf = @member_exception_squads.map { |s| s.generate_tf(@id) }.join("\n")
172
- File.write('auto.exception_squads.tf', tf)
209
+ def generate_tf_squads
210
+ @member_exception_squads.map { |s| s.generate_tf(@id) }.join("\n")
211
+ end
173
212
 
213
+ def generate_tf_org
214
+ tf = ''
174
215
  # Roll all platoons and exception squads into the org.
175
216
  roll_up_to_org = \
176
217
  @member_exception_squads.map { |s| s.unique_name(@id, nil) } + \
@@ -210,14 +251,18 @@ EOF
210
251
  all_locations[@manager_location] = all_locations.fetch(@manager_location, Set.new).add(@manager)
211
252
 
212
253
  all_locations.each do |l, m|
254
+ description = "#{@name} organization members based in #{l} (terraorg)"
213
255
  name = "#{unique_name}-#{l.downcase}"
214
256
  tf += <<-EOF
215
257
  resource "okta_group" "#{name}" {
216
258
  name = "#{name}"
217
- description = "#{@name} organization members based in #{l} (terraorg)"
259
+ description = "#{description}"
218
260
  users = #{Util.persons_tf(m)}
219
261
  }
262
+
263
+ #{Util.gsuite_group_tf(name, @gsuite_domain, m, description)}
220
264
  EOF
265
+
221
266
  end
222
267
 
223
268
  # Generate a special GSuite group for all managers (org, platoon, squad
@@ -226,7 +271,17 @@ EOF
226
271
  all_managers = Set.new([@manager] + @platoons.all.map(&:manager) + @squads.all.map(&:manager).select { |m| m })
227
272
  manager_dl = "#{@id}-managers"
228
273
  tf += Util.gsuite_group_tf(manager_dl, @gsuite_domain, all_managers, "All managers of the #{@name} organization (terraorg)")
274
+ tf
275
+ end
276
+
277
+ def generate_tf
278
+ tf = generate_tf_platoons
279
+ File.write('auto.platoons.tf', tf)
280
+
281
+ tf = generate_tf_squads
282
+ File.write('auto.exception_squads.tf', tf)
229
283
 
284
+ tf = generate_tf_org
230
285
  File.write('auto.org.tf', tf)
231
286
  end
232
287
 
@@ -99,9 +99,9 @@ EOF
99
99
  # - Sort the squad ids lexically
100
100
  # - Sort the exceptions lexically
101
101
  def to_h
102
- obj = { 'id' => @id, 'name' => @name, 'manager' => @manager.id, 'squads' => @member_squads.map(&:id) }
102
+ obj = { 'id' => @id, 'name' => @name, 'manager' => @manager.id, 'squads' => @member_squads.map(&:id).sort }
103
103
  unless @member_exceptions.empty?
104
- obj['exceptions'] = @member_exceptions.map(&:id)
104
+ obj['exceptions'] = @member_exceptions.map(&:id).sort
105
105
  end
106
106
  unless @metadata.empty?
107
107
  obj['metadata'] = @metadata
@@ -12,6 +12,8 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ require 'countries'
16
+
15
17
  require 'terraorg/model/people'
16
18
  require 'terraorg/model/util'
17
19
 
@@ -19,23 +21,49 @@ class Squad
19
21
  attr_accessor :id, :name, :metadata, :teams
20
22
 
21
23
  class Team
22
- attr_accessor :location, :members
24
+ attr_accessor :location, :members, :associates
23
25
 
24
26
  def initialize(parsed_data, people)
25
- @location = parsed_data.fetch('location')
27
+ location = parsed_data.fetch('location')
28
+ country = ISO3166::Country.new(location)
29
+ raise "Location is invalid: #{location}" unless country
30
+ @location = country.alpha2
26
31
  @members = parsed_data.fetch('members', []).map do |n|
27
32
  people.get_or_create!(n)
28
33
  end
34
+ @associates = parsed_data.fetch('associates', []).map do |n|
35
+ people.get_or_create!(n)
36
+ end
37
+ end
38
+
39
+ def validate!
40
+ raise 'Subteam has no full time members' if @members.size == 0
41
+ # location validation done at initialize time
42
+ # associates can be empty
43
+
44
+ # associates and members must have zero intersection
45
+ associate_set = Set.new(@associates.map(&:id))
46
+ member_set = Set.new(@members.map(&:id))
47
+ raise 'A member cannot also be an associate of the same team' if associate_set.intersection(member_set)
29
48
  end
30
49
 
31
50
  # Output a canonical (sorted, formatted) version of this Team.
32
51
  # - Sort the members in each team
52
+ # - Only add an associates field if it's present
33
53
  def to_h
34
- { 'location' => @location, 'members' => @members.map(&:id).sort }
54
+ {
55
+ 'associates' => @associates.map(&:id).sort,
56
+ 'location' => @location,
57
+ 'members' => @members.map(&:id).sort,
58
+ }
59
+ end
60
+
61
+ def everyone
62
+ @associates + @members
35
63
  end
36
64
 
37
65
  def to_md
38
- "**#{@location}**: #{@members.map(&:name).sort.join(', ')}"
66
+ "**#{@location}**: #{@members.map(&:name).sort.join(', ')}, #{@associates.map { |m| "_#{m.name}_" }.sort.join(', ')}"
39
67
  end
40
68
  end
41
69
 
@@ -53,10 +81,18 @@ class Squad
53
81
  @teams = Hash[teams_arr.map { |t| [t.location, t] }]
54
82
  end
55
83
 
56
- def members(location: nil)
84
+ # Everyone including associates on all subteams in the squad.
85
+ def everyone(location: nil)
57
86
  @teams.select { |l, t|
58
87
  location == nil || l == location
59
- }.map { |l, t|
88
+ }.map { |_, t|
89
+ t.everyone
90
+ }.flatten
91
+ end
92
+
93
+ # Full-time members of all subteams in this squad
94
+ def members
95
+ @teams.map { |_, t|
60
96
  t.members
61
97
  }.flatten
62
98
  end
@@ -64,11 +100,11 @@ class Squad
64
100
  def get_acl_groups(org_id)
65
101
  # each geographically located subteam
66
102
  groups = Hash[@teams.map { |location, team|
67
- [unique_name(org_id, location), {'name' => "#{@name} squad members based in #{location}", 'members' => team.members}]
103
+ [unique_name(org_id, location), {'name' => "#{@name} squad members based in #{location}", 'members' => team.everyone}]
68
104
  }]
69
105
 
70
106
  # combination of all subteams
71
- groups[unique_name(org_id, nil)] = {'name' => "#{@name} squad worldwide members", 'members' => members}
107
+ groups[unique_name(org_id, nil)] = {'name' => "#{@name} squad worldwide members", 'members' => everyone}
72
108
 
73
109
  groups
74
110
  end
@@ -82,7 +118,7 @@ class Squad
82
118
  end
83
119
 
84
120
  def validate!
85
- raise 'Squad has no members' if members.size == 0
121
+ @teams.each(&:validate!)
86
122
  end
87
123
 
88
124
  def to_md(platoon_name, org_id)
@@ -94,11 +130,6 @@ class Squad
94
130
  sme = @people.get_or_create!(sme).name
95
131
  end
96
132
 
97
- epo = @metadata.fetch('epo', '')
98
- if !epo.empty?
99
- epo = @people.get_or_create!(epo).name
100
- end
101
-
102
133
  manager = @metadata.fetch('manager', '')
103
134
  if !manager.empty?
104
135
  manager = @people.get_or_create!(manager).name
@@ -110,8 +141,8 @@ class Squad
110
141
  if slack
111
142
  slack = "[#{slack}](https://#{@slack_domain}/app_redirect?channel=#{slack.gsub(/^#/, '')})"
112
143
  end
113
- # platoon name, squad name, PM, email list, SME, slack, # people, squad manager, eng product owner, members
114
- "|#{platoon_name}|#{@name}|#{pm}|[#{email}](#{email})|#{sme}|#{slack}|#{members.size}|#{manager}|#{epo}|#{subteam_members}|"
144
+ # platoon name, squad name, PM, email list, SME, slack, # full time members, squad manager, members
145
+ "|#{platoon_name}|#{@name}|#{pm}|[#{email}](#{email})|#{sme}|#{slack}|#{members.size}|#{manager}|#{subteam_members}|"
115
146
  end
116
147
 
117
148
  def generate_tf(org_id)
@@ -13,5 +13,5 @@
13
13
  # limitations under the License.
14
14
 
15
15
  module Terraorg
16
- VERSION = '0.1.0'
16
+ VERSION = '0.5.0'
17
17
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: terraorg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Kwan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-03-30 00:00:00.000000000 Z
11
+ date: 2020-10-12 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: countries
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: faraday
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +66,20 @@ dependencies:
52
66
  - - "~>"
53
67
  - !ruby/object:Gem::Version
54
68
  version: '0.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.14'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.14'
55
83
  description: Manage an organizational structure with Okta and G-Suite using Terraform
56
84
  email: joshk@triplehelix.org
57
85
  executables:
@@ -90,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
118
  - !ruby/object:Gem::Version
91
119
  version: '0'
92
120
  requirements: []
93
- rubygems_version: 3.0.3
121
+ rubygems_version: 3.0.8
94
122
  signing_key:
95
123
  specification_version: 4
96
124
  summary: terraorg