hecks 3.1.3 → 3.2.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: 88b11874249737158a9451d66aa0842ef88fb3f1ff047cced68d97b2ce9fdc5f
4
- data.tar.gz: 2872e381ce8e7fe5d46ef7a7623134dc23d923e57bdf194758effedaa1cc4671
3
+ metadata.gz: c79ef6cb53e23b9faa0f452f116af208c659512f00716e47c7f3cdaad3d51e86
4
+ data.tar.gz: b693ea21f847607ca2964759f9e0cec9dc7c4f8e1ea341b6636e99f8b4b1d294
5
5
  SHA512:
6
- metadata.gz: 76a39ab361adfb4e07bb558cb917d46bf3d7b6bb21340ff9789cb7b6c10be47a3197df4f394e8da73129feeb5b8439d9401f5e83b8620e0a512fce966c891377
7
- data.tar.gz: 58f1514450a234f3807897401daf7db410739463a8603bf58b7d73e548f14424211b4d1c5f3af1dc7421b769f39582e15c6f22188932af3de054961eed2cb641
6
+ metadata.gz: 38d1467d95484bcf093d6671982bf5e4edc4fc1be8907d849a88df9b85ab2b9810f88ce9533a9d1d125187fd47c26191fbe3e104e90307ee6fb19b3cffa37546
7
+ data.tar.gz: 329631cf0d425b281c4a4770318d2956ae95ca7d2e0b12e2e0054bd2f0d72a3310a92d1ce2fa125d5a056c76026c27472230a0710335790267db173ada8e48db
@@ -665,6 +665,111 @@ Hecks.bluebook "Deploy" do
665
665
  trigger MakefileCheck::Flag, with: { refusal: :refusal }
666
666
  end
667
667
 
668
+ aggregate "CostCheck" do
669
+ description "One reading of what hosting costs a month, against a budget."
670
+
671
+ attribute :run, RunKey
672
+ attribute :budget, Budget
673
+ attribute :since, Since
674
+ attribute :report, Report, optional: true
675
+ attribute :refusal, Report, optional: true
676
+ identified_by :run
677
+
678
+ value_object "RunKey" do
679
+ attribute :value, String, pattern: '[^ \t\n\r]'
680
+
681
+ invariant("a run is named") { !value.to_s.empty? }
682
+ end
683
+
684
+ # Whole dollars a month.
685
+ value_object "Budget" do
686
+ attribute :value, Integer
687
+
688
+ invariant("a budget is positive") { value.positive? }
689
+ end
690
+
691
+ # The first day to count, as `YYYY-MM-DD`: the first complete day after the last change to
692
+ # what is running, so a bill that still carries the old setup is not read as the new one.
693
+ value_object "Since" do
694
+ attribute :value, String, pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}'
695
+ end
696
+
697
+ value_object "Report" do
698
+ attribute :value, String
699
+ end
700
+
701
+ lifecycle :status, default: "requested" do
702
+ transition "Pass" => "within_budget", from: "requested"
703
+ transition "Flag" => "flagged", from: "requested"
704
+ end
705
+
706
+ # The reading is the billed daily total from the first complete day since `since` up to
707
+ # yesterday, scaled to a month; today is left out because its bill is still partial.
708
+ command "Check" do
709
+ goal "Read what hosting has billed since a day and compare its monthly rate to a budget"
710
+
711
+ attribute :run, RunKey
712
+ attribute :budget, Budget
713
+ attribute :since, Since
714
+
715
+ sets :run
716
+ sets :budget
717
+ sets :since
718
+
719
+ emits "CostCheckRequested"
720
+ end
721
+
722
+ command "Pass" do
723
+ goal "Record that the monthly rate is within the budget, and what it was"
724
+
725
+ reference_to CostCheck
726
+ attribute :report, Report
727
+
728
+ sets :report
729
+
730
+ emits "CostWithinBudget"
731
+ end
732
+
733
+ command "Flag" do
734
+ goal "Record that the monthly rate is over the budget, or that it could not be read, and what was said"
735
+
736
+ reference_to CostCheck
737
+ attribute :refusal, Report
738
+
739
+ sets :refusal
740
+
741
+ emits "CostFlagged"
742
+ end
743
+
744
+ query "Verdict" do
745
+ description "What one reading found: its status, and the report or the reason it was flagged."
746
+ attribute :run, RunKey
747
+ where(run: :run)
748
+ order_by :run
749
+ end
750
+
751
+ query "Flagged" do
752
+ description "Every reading that was over its budget or could not be taken."
753
+ where(status: "flagged")
754
+ order_by :run
755
+ end
756
+ end
757
+
758
+ policy "MeasureWhenChecked" do
759
+ on "CostCheck.CostCheckRequested"
760
+ trigger CostCheck::CostExplorer::Measure, with: { run: :run }
761
+ end
762
+
763
+ policy "RecordTheCostWithinBudget" do
764
+ on "CostCheck.CostAnswered"
765
+ trigger CostCheck::Pass, with: { report: :report }
766
+ end
767
+
768
+ policy "RecordTheFlaggedCost" do
769
+ on "CostCheck.CostRefused"
770
+ trigger CostCheck::Flag, with: { refusal: :refusal }
771
+ end
772
+
668
773
  aggregate "TemplateComparison" do
669
774
  description "One offline comparison of two CloudFormation templates, by logical id."
670
775
 
@@ -35,6 +35,14 @@ Hecks.hecksagon "Deploy" do
35
35
  end
36
36
  end
37
37
 
38
+ Deploy::CostCheck.port "CostExplorer" do
39
+ asks "Measure", to: CostCheck do
40
+ attribute :run, RunKey
41
+ answers "CostAnswered"
42
+ refuses "CostRefused"
43
+ end
44
+ end
45
+
38
46
  Deploy::TemplateComparison.port "DeployToolchain" do
39
47
  asks "Compare", to: TemplateComparison do
40
48
  attribute :run, RunKey
@@ -6,6 +6,21 @@
6
6
  "verb": "Deploy::BoxTarget.Declare",
7
7
  "role": null
8
8
  },
9
+ {
10
+ "scope": "deploy:cost_check.check",
11
+ "verb": "Deploy::CostCheck.Check",
12
+ "role": null
13
+ },
14
+ {
15
+ "scope": "deploy:cost_check.flag",
16
+ "verb": "Deploy::CostCheck.Flag",
17
+ "role": null
18
+ },
19
+ {
20
+ "scope": "deploy:cost_check.pass",
21
+ "verb": "Deploy::CostCheck.Pass",
22
+ "role": null
23
+ },
9
24
  {
10
25
  "scope": "deploy:fargate_target.declare",
11
26
  "verb": "Deploy::FargateTarget.Declare",
@@ -0,0 +1,7 @@
1
+ # Required by name, not relative to this file: a fuzz or model check boots a copy of this directory,
2
+ # and the implementation must load once, from `lib/`, not from the copy.
3
+ require "hecks/hecks/adapters/cost_explorer"
4
+
5
+ Hecks.adapter "CostExplorer" do
6
+ port "CostExplorer"
7
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "date"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ # The `CostExplorer` port's adapter: reads what an AWS account has billed, with the `aws`
10
+ # command, and compares the monthly rate to a budget.
11
+ #
12
+ # It reads the daily unblended cost from the check's `since` day up to yesterday. Today is left
13
+ # out because its bill is still partial, and a day is counted whatever Cost Explorer marks it
14
+ # (a day stays "estimated" for a while after it ends). The rate is the mean daily cost scaled
15
+ # to an average month. A rate within the budget is answered with a one-line report; a rate over
16
+ # it, a span with no complete day, or a failed `aws` call is refused with a reason, which the
17
+ # runtime records on the check.
18
+ class CostExplorer
19
+ # Days in an average month, for scaling a daily mean to a monthly rate.
20
+ DAYS_IN_MONTH = 30.4375
21
+
22
+ # How many of the biggest services the report names.
23
+ TOP_SERVICES = 3
24
+
25
+ # @param aggregate [Object, nil] unused
26
+ # @param settings [Hash] `today:` pins the day the span ends before, for tests
27
+ # @param root [String, nil] unused
28
+ def initialize(aggregate: nil, settings: {}, root: nil)
29
+ @today = settings[:today]
30
+ end
31
+
32
+ # Reads the billed cost since a day and compares its monthly rate to the budget.
33
+ #
34
+ # @param held [Hash] the check's fields: `budget:` whole dollars a month and `since:` the
35
+ # first day to count (`YYYY-MM-DD`), each a value object or a plain value
36
+ # @return [Hash{Symbol => Hash}] `report:` the rate against the budget, the shape
37
+ # `CostCheck.Pass` takes
38
+ # @raise [RuntimeError] when no complete day has passed since `since`, `aws` fails, or the
39
+ # rate is over the budget
40
+ def measure(**held)
41
+ budget = Integer(plain(held[:budget]))
42
+ first = Date.iso8601(plain(held[:since]).to_s)
43
+ last = today
44
+ raise "no complete day since #{first}; today (#{last}) is still being billed" if first >= last
45
+
46
+ days = daily_costs(first, last)
47
+ line = report_line(first, last, days, budget)
48
+ raise "over budget: #{line}" if monthly_rate(days) > budget
49
+
50
+ { report: { value: line } }
51
+ end
52
+
53
+ private
54
+
55
+ # @return [Date] the day the span ends before: today, in UTC
56
+ def today = @today ? Date.iso8601(@today.to_s) : Time.now.utc.to_date
57
+
58
+ # @return [Hash{String => Hash{String => Float}}] day => service => dollars
59
+ def daily_costs(first, last)
60
+ out, err, status = Open3.capture3(
61
+ "aws", "ce", "get-cost-and-usage", "--time-period", "Start=#{first},End=#{last}",
62
+ "--granularity", "DAILY", "--metrics", "UnblendedCost",
63
+ "--group-by", "Type=DIMENSION,Key=SERVICE", "--output", "json"
64
+ )
65
+ raise "aws ce get-cost-and-usage failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
66
+
67
+ rows = JSON.parse(out).fetch("ResultsByTime")
68
+ raise "Cost Explorer returned no days for #{first}..#{last}" if rows.empty?
69
+
70
+ rows.to_h { |row| [row.dig("TimePeriod", "Start"), by_service(row)] }
71
+ end
72
+
73
+ def by_service(row)
74
+ row.fetch("Groups").to_h do |group|
75
+ [group.fetch("Keys").first, group.dig("Metrics", "UnblendedCost", "Amount").to_f]
76
+ end
77
+ end
78
+
79
+ # @return [Float] the mean daily total, scaled to an average month
80
+ def monthly_rate(days)
81
+ mean = days.values.sum { |services| services.values.sum } / days.size
82
+ mean * DAYS_IN_MONTH
83
+ end
84
+
85
+ def report_line(first, last, days, budget)
86
+ mean = days.values.sum { |services| services.values.sum } / days.size
87
+ biggest = totals_by_service(days).max_by(TOP_SERVICES) { |_name, dollars| dollars }
88
+ named = biggest.map { |name, dollars| "#{name} $#{format('%.2f', dollars / days.size)}/day" }.join(", ")
89
+ "#{first}..#{last - 1} (#{days.size} days): $#{format('%.2f', mean)}/day, " \
90
+ "$#{format('%.2f', monthly_rate(days))}/month against $#{budget}; biggest: #{named}"
91
+ end
92
+
93
+ def totals_by_service(days)
94
+ days.values.each_with_object(Hash.new(0.0)) do |services, totals|
95
+ services.each { |name, dollars| totals[name] += dollars }
96
+ end
97
+ end
98
+
99
+ def plain(argument) = argument.is_a?(Hash) ? argument[:value] : argument
100
+ end
101
+ end
102
+ end
@@ -368,6 +368,14 @@ Hecks.hecksagon "Hecks" do
368
368
  end
369
369
  end
370
370
 
371
+ Deploy::CostCheck.port "CostExplorer" do
372
+ asks "Measure", to: CostCheck do
373
+ attribute :run, RunKey
374
+ answers "CostAnswered"
375
+ refuses "CostRefused"
376
+ end
377
+ end
378
+
371
379
  Deploy::TemplateComparison.port "DeployToolchain" do
372
380
  asks "Compare", to: TemplateComparison do
373
381
  attribute :run, RunKey
@@ -62,7 +62,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
62
62
  "aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
63
63
  "docker compose -f compose.json pull --quiet",
64
64
  "docker compose -f compose.json up -d --remove-orphans",
65
- "sleep 20; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
65
+ "for i in $(seq 1 40); do docker compose -f compose.json ps --format \"{{.Status}}\" | grep -qE \"^Up (Less than a second|[0-4] seconds?)|Restarting|Created|Exited\" || break; sleep 1; done; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
66
66
  executionTimeout: ["900"]}')
67
67
 
68
68
  echo "==> rolling $BOX_STACK ($BOX) at $(date -u +%H:%M:%SZ)"
@@ -252,6 +252,7 @@ module Hecks
252
252
  # @return [void]
253
253
  def write(artifact, out_dir)
254
254
  written = Hecks::Projector.write(artifact, out_dir, as: :files)
255
+ written.each { |path| File.chmod(0o755, path) if path.end_with?(".sh") }
255
256
 
256
257
  written.each { |path| puts "wrote #{path}" }
257
258
  puts
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.1.3".freeze
4
+ VERSION = "3.2.0".freeze
5
5
  end
@@ -1 +1 @@
1
- 3.1.3
1
+ 3.2.0
@@ -592,8 +592,25 @@ pub async fn active_role(client: &Mutex<Client>, domain_ir: &Value, email: &str)
592
592
  .and_then(|(_, state)| role_of(state).map(String::from)))
593
593
  }
594
594
 
595
+ /// The Governance identity id linked to the member with `email`, read from
596
+ /// the live membership head. `None` when the member has not signed in yet.
597
+ pub async fn member_identity_id(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> anyhow::Result<Option<String>> {
598
+ let email = email.to_lowercase();
599
+ Ok(member_rows(client, domain_ir)
600
+ .await?
601
+ .iter()
602
+ .find(|(id, state)| id.to_lowercase() == email && !is_deleted(state))
603
+ .and_then(|(_, state)| linked_identity_id(state)))
604
+ }
605
+
606
+ // The id the sign-in flow wrote onto a Member row, or `None` before then.
607
+ fn linked_identity_id(state: &Value) -> Option<String> {
608
+ state.get("identity_id").and_then(|v| v.get("value")).and_then(|v| v.as_str()).map(str::to_string)
609
+ }
610
+
595
611
  /// Every admitted person as a JSON row: name, email, role, `linked`
596
- /// (signed in), `granted` (has access), and `disabled`.
612
+ /// (signed in), `granted` (has access), `disabled`, and `identity_id`
613
+ /// (the Governance identity id, `null` until the person has signed in).
597
614
  pub async fn all_people(client: &Mutex<Client>, domain_ir: &Value) -> anyhow::Result<Vec<Value>> {
598
615
  Ok(member_rows(client, domain_ir)
599
616
  .await?
@@ -610,6 +627,7 @@ pub async fn all_people(client: &Mutex<Client>, domain_ir: &Value) -> anyhow::Re
610
627
  "linked": linked,
611
628
  "granted": role.is_some() && !disabled,
612
629
  "disabled": disabled,
630
+ "identity_id": linked_identity_id(&state),
613
631
  })
614
632
  })
615
633
  .collect())
@@ -1206,9 +1224,15 @@ mod tests {
1206
1224
  let chris = people.iter().find(|p| p["email"] == "chris@example.com").unwrap();
1207
1225
  assert_eq!(chris["linked"], true);
1208
1226
  assert_eq!(chris["granted"], true);
1227
+ assert_eq!(chris["identity_id"], "id-1");
1209
1228
  let angie = people.iter().find(|p| p["email"] == "angie@example.com").unwrap();
1210
1229
  assert_eq!(angie["linked"], false);
1211
1230
  assert_eq!(angie["granted"], false);
1231
+ assert!(angie["identity_id"].is_null());
1232
+
1233
+ assert_eq!(member_identity_id(&db, &domain_ir, "Chris@Example.com").await.unwrap().as_deref(), Some("id-1"));
1234
+ assert_eq!(member_identity_id(&db, &domain_ir, "angie@example.com").await.unwrap(), None);
1235
+ assert_eq!(member_identity_id(&db, &domain_ir, "nobody@example.com").await.unwrap(), None);
1212
1236
  }
1213
1237
 
1214
1238
  #[tokio::test]
data/rust/host/src/web.rs CHANGED
@@ -369,7 +369,10 @@ async fn active_session_email(
369
369
 
370
370
  async fn accounts_me_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
371
371
  match active_session_email(domain_ir, cookies, secret, client).await {
372
- Ok(email) => respond(200, "application/json", &json!({"email": email}).to_string()),
372
+ Ok(email) => match auth::member_identity_id(client, domain_ir, &email).await {
373
+ Ok(identity_id) => respond(200, "application/json", &json!({"email": email, "identity_id": identity_id}).to_string()),
374
+ Err(e) => respond(500, "application/json", &json!({"error": format!("members lookup failed: {e}")}).to_string()),
375
+ },
373
376
  Err(response) => response,
374
377
  }
375
378
  }
@@ -434,7 +437,7 @@ pub(crate) async fn add_member_route(
434
437
  Ok(true) => respond(
435
438
  201,
436
439
  "application/json",
437
- &json!({"name": name, "email": email, "role": role, "linked": false, "granted": true, "disabled": false}).to_string(),
440
+ &json!({"name": name, "email": email, "role": role, "linked": false, "granted": true, "disabled": false, "identity_id": null}).to_string(),
438
441
  ),
439
442
  Ok(false) => json_error(500, &format!("the person was admitted but the {role} role was not granted")),
440
443
  Err(e) => json_error(500, &format!("the person was admitted but the {role} role was not granted: {e}")),
@@ -2173,6 +2176,7 @@ mod tests {
2173
2176
  assert_eq!(response["statusCode"], 200, "{response:?}");
2174
2177
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2175
2178
  assert_eq!(body["email"], "zed@example.com");
2179
+ assert_eq!(body["identity_id"], "id-1", "the signed-in account's Governance identity id");
2176
2180
 
2177
2181
  let empty = HashMap::new();
2178
2182
  assert_eq!(accounts_me_route(&domain_ir, &empty, secret, &client).await["statusCode"], 401);
@@ -2273,11 +2277,11 @@ mod tests {
2273
2277
  assert_eq!(names, ["amy", "Zed"], "sorted case-insensitively by name");
2274
2278
  assert_eq!(
2275
2279
  people[1],
2276
- json!({"name": "Zed", "email": "zed@example.com", "role": "Admin", "linked": true, "granted": true, "disabled": false})
2280
+ json!({"name": "Zed", "email": "zed@example.com", "role": "Admin", "linked": true, "granted": true, "disabled": false, "identity_id": "id-1"})
2277
2281
  );
2278
2282
  assert_eq!(
2279
2283
  people[0],
2280
- json!({"name": "amy", "email": "amy@example.com", "role": null, "linked": false, "granted": false, "disabled": false})
2284
+ json!({"name": "amy", "email": "amy@example.com", "role": null, "linked": false, "granted": false, "disabled": false, "identity_id": null})
2281
2285
  );
2282
2286
  }
2283
2287
 
@@ -2465,7 +2469,7 @@ mod tests {
2465
2469
  let created: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2466
2470
  assert_eq!(
2467
2471
  created,
2468
- json!({"name": "New Person", "email": "new@example.com", "role": "Admin", "linked": false, "granted": true, "disabled": false})
2472
+ json!({"name": "New Person", "email": "new@example.com", "role": "Admin", "linked": false, "granted": true, "disabled": false, "identity_id": null})
2469
2473
  );
2470
2474
 
2471
2475
  let listing = members_route(&domain_ir, &cookies, secret, &client).await;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.3
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
@@ -380,6 +380,8 @@ files:
380
380
  - lib/hecks/hecks/adapters/codebase/test_suite.rb
381
381
  - lib/hecks/hecks/adapters/codebase/tree.rb
382
382
  - lib/hecks/hecks/adapters/console_capture.rb
383
+ - lib/hecks/hecks/adapters/cost_explorer.adapter
384
+ - lib/hecks/hecks/adapters/cost_explorer.rb
383
385
  - lib/hecks/hecks/adapters/deploy_toolchain.adapter
384
386
  - lib/hecks/hecks/adapters/deploy_toolchain.rb
385
387
  - lib/hecks/hecks/adapters/finding_github.adapter