diff --git a/spec/cb/backup_spec.cr b/spec/cb/backup_spec.cr index 01727ae..dfad0c5 100644 --- a/spec/cb/backup_spec.cr +++ b/spec/cb/backup_spec.cr @@ -42,18 +42,18 @@ private class BackupTestClient < CB::Client end private def make_ba - CB::BackupCapture.new(BackupTestClient.new(TEST_TOKEN)) + CB::Action::BackupCapture.new(BackupTestClient.new(TEST_TOKEN)) end private def make_bl - CB::BackupList.new(BackupTestClient.new(TEST_TOKEN)) + CB::Action::BackupList.new(BackupTestClient.new(TEST_TOKEN)) end private def make_bt - CB::BackupToken.new(BackupTestClient.new(TEST_TOKEN)) + CB::Action::BackupToken.new(BackupTestClient.new(TEST_TOKEN)) end -describe CB::BackupCapture do +describe CB::Action::BackupCapture do it "validates that cluster_id is correct" do ba = make_ba ba.cluster_id = "afpvoqooxzdrriu6w3bhqo55c4" @@ -61,7 +61,7 @@ describe CB::BackupCapture do end end -describe CB::BackupList do +describe CB::Action::BackupList do it "validates that cluster_id is correct" do bl = make_bl bl.cluster_id = "afpvoqooxzdrriu6w3bhqo55c4" @@ -87,7 +87,7 @@ describe CB::BackupList do end end -describe CB::BackupToken do +describe CB::Action::BackupToken do it "validates that cluster_id is correct" do bt = make_bt bt.cluster_id = "afpvoqooxzdrriu6w3bhqo55c4" diff --git a/spec/cb/cluster_create_spec.cr b/spec/cb/cluster_create_spec.cr index e5c22c0..faa8c3c 100644 --- a/spec/cb/cluster_create_spec.cr +++ b/spec/cb/cluster_create_spec.cr @@ -27,10 +27,10 @@ private class ClusterCreateTestClient < CB::Client end private def make_cc - CB::ClusterCreate.new(ClusterCreateTestClient.new(TEST_TOKEN)) + CB::Action::ClusterCreate.new(ClusterCreateTestClient.new(TEST_TOKEN)) end -describe CB::ClusterCreate do +describe CB::Action::ClusterCreate do it "#run prints info about the cluster that was created" do cc = make_cc cc.output = output = IO::Memory.new diff --git a/spec/cb/cluster_upgrade_spec.cr b/spec/cb/cluster_upgrade_spec.cr index 7de6429..224bfd5 100644 --- a/spec/cb/cluster_upgrade_spec.cr +++ b/spec/cb/cluster_upgrade_spec.cr @@ -51,9 +51,9 @@ private class ClusterUpgradeTestClient < CB::Client end end -describe CB::UpgradeStart do +describe CB::Action::UpgradeStart do it "validates that required arguments are present" do - action = CB::UpgradeStart.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) + action = CB::Action::UpgradeStart.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ @@ -63,7 +63,7 @@ describe CB::UpgradeStart do end it "#run prints cluster upgrade started" do - action = CB::UpgradeStart.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) + action = CB::Action::UpgradeStart.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) action.output = output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -75,9 +75,9 @@ describe CB::UpgradeStart do end end -describe CB::UpgradeStatus do +describe CB::Action::UpgradeStatus do it "validates that required arguments are present" do - action = CB::UpgradeStatus.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) + action = CB::Action::UpgradeStatus.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ @@ -87,7 +87,7 @@ describe CB::UpgradeStatus do end it "#run no upgrades" do - action = CB::UpgradeStatus.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) + action = CB::Action::UpgradeStatus.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) action.output = output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -98,9 +98,9 @@ describe CB::UpgradeStatus do end end -describe CB::UpgradeCancel do +describe CB::Action::UpgradeCancel do it "validates that required arguments are present" do - action = CB::UpgradeCancel.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) + action = CB::Action::UpgradeCancel.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ @@ -110,7 +110,7 @@ describe CB::UpgradeCancel do end it "#run " do - action = CB::UpgradeCancel.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) + action = CB::Action::UpgradeCancel.new(ClusterUpgradeTestClient.new(TEST_TOKEN)) action.output = output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" diff --git a/spec/cb/cluster_uri_spec.cr b/spec/cb/cluster_uri_spec.cr index d2537be..3fe86b0 100644 --- a/spec/cb/cluster_uri_spec.cr +++ b/spec/cb/cluster_uri_spec.cr @@ -23,9 +23,9 @@ private class ClusterURITestClient < CB::Client end end -describe CB::ClusterURI do +describe CB::Action::ClusterURI do it "ensures 'default' if role not specified" do - action = CB::ClusterURI.new(ClusterURITestClient.new(TEST_TOKEN)) + action = CB::Action::ClusterURI.new(ClusterURITestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.call @@ -34,7 +34,7 @@ describe CB::ClusterURI do end it "#run errors on invalid role" do - action = CB::ClusterURI.new(ClusterURITestClient.new(TEST_TOKEN)) + action = CB::Action::ClusterURI.new(ClusterURITestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.role_name = "invalid" @@ -51,7 +51,7 @@ describe CB::ClusterURI do HTTP::Client::Response.new(HTTP::Status::BAD_REQUEST)) } - action = CB::ClusterURI.new(c) + action = CB::Action::ClusterURI.new(c) action.output = IO::Memory.new msg = /invalid input/ @@ -62,7 +62,7 @@ describe CB::ClusterURI do it "#run prints uri" do c = ClusterURITestClient.new(TEST_TOKEN) - action = CB::ClusterURI.new(c) + action = CB::Action::ClusterURI.new(c) action.output = output = IO::Memory.new action.call diff --git a/spec/cb/logdest_add_spec.cr b/spec/cb/logdest_add_spec.cr index 4d25908..611f98d 100644 --- a/spec/cb/logdest_add_spec.cr +++ b/spec/cb/logdest_add_spec.cr @@ -4,14 +4,14 @@ private class LogDestinationAddTestClient < CB::Client end private def make_lda - CB::LogDestinationAdd.new(LogDestinationAddTestClient.new(TEST_TOKEN)) + CB::Action::LogDestinationAdd.new(LogDestinationAddTestClient.new(TEST_TOKEN)) end private def expect_validation_err(lda, part) expect_cb_error(/Missing required argument.+#{part}/) { lda.validate } end -describe CB::LogDestinationAdd do +describe CB::Action::LogDestinationAdd do it "validates that required arguments are present" do lda = make_lda diff --git a/spec/cb/restart_spec.cr b/spec/cb/restart_spec.cr index 1086502..a58e84d 100644 --- a/spec/cb/restart_spec.cr +++ b/spec/cb/restart_spec.cr @@ -25,9 +25,9 @@ private class RestartTestClient < CB::Client end end -describe CB::Restart do +describe CB::Action::Restart do it "validates that required arguments are present" do - action = CB::Restart.new(RestartTestClient.new(TEST_TOKEN)) + action = CB::Action::Restart.new(RestartTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ expect_cb_error(msg) { action.validate } @@ -37,7 +37,7 @@ describe CB::Restart do end it "#run prints confirmation" do - action = CB::Restart.new(RestartTestClient.new(TEST_TOKEN)) + action = CB::Action::Restart.new(RestartTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" diff --git a/spec/cb/role_spec.cr b/spec/cb/role_spec.cr index 52db9ae..d1af0ec 100644 --- a/spec/cb/role_spec.cr +++ b/spec/cb/role_spec.cr @@ -29,9 +29,9 @@ private class RoleTestClient < CB::Client end end -describe CB::RoleCreate do +describe CB::Action::RoleCreate do it "validates that required arguments are present" do - action = CB::RoleCreate.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleCreate.new(RoleTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ @@ -41,7 +41,7 @@ describe CB::RoleCreate do end it "#run prints confirmation" do - action = CB::RoleCreate.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleCreate.new(RoleTestClient.new(TEST_TOKEN)) action.output = output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -52,9 +52,9 @@ describe CB::RoleCreate do end end -describe CB::RoleUpdate do +describe CB::Action::RoleUpdate do it "validates that required arguments are present" do - action = CB::RoleUpdate.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleUpdate.new(RoleTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ @@ -65,7 +65,7 @@ describe CB::RoleUpdate do end it "#run errors on invalid role" do - action = CB::RoleUpdate.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleUpdate.new(RoleTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -77,7 +77,7 @@ describe CB::RoleUpdate do end it "#run translates 'user' role" do - action = CB::RoleUpdate.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleUpdate.new(RoleTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -89,7 +89,7 @@ describe CB::RoleUpdate do end it "#run prints confirmation" do - action = CB::RoleUpdate.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleUpdate.new(RoleTestClient.new(TEST_TOKEN)) action.output = output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -101,9 +101,9 @@ describe CB::RoleUpdate do end end -describe CB::RoleDelete do +describe CB::Action::RoleDelete do it "validate that required arguments are present" do - action = CB::RoleDelete.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleDelete.new(RoleTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -111,7 +111,7 @@ describe CB::RoleDelete do end it "#run errors on invalid role" do - action = CB::RoleDelete.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleDelete.new(RoleTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -121,7 +121,7 @@ describe CB::RoleDelete do end it "#run translates 'user' role" do - action = CB::RoleDelete.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleDelete.new(RoleTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -133,7 +133,7 @@ describe CB::RoleDelete do end it "#run prints confirmation" do - action = CB::RoleDelete.new(RoleTestClient.new(TEST_TOKEN)) + action = CB::Action::RoleDelete.new(RoleTestClient.new(TEST_TOKEN)) action.output = output = IO::Memory.new action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" diff --git a/spec/cb/team_member_spec.cr b/spec/cb/team_member_spec.cr index 162e92f..ba10f3e 100644 --- a/spec/cb/team_member_spec.cr +++ b/spec/cb/team_member_spec.cr @@ -49,9 +49,9 @@ private class TeamMemberTestClient < CB::Client end end -describe CB::TeamMemberAdd do +describe CB::Action::TeamMemberAdd do it "validates that required arguments are present" do - action = CB::TeamMemberAdd.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberAdd.new(TeamMemberTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ expect_cb_error(msg) { action.validate } @@ -66,7 +66,7 @@ describe CB::TeamMemberAdd do end it "#run prints confirmation" do - action = CB::TeamMemberAdd.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberAdd.new(TeamMemberTestClient.new(TEST_TOKEN)) action.output = output = IO::Memory.new action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -78,16 +78,16 @@ describe CB::TeamMemberAdd do end end -describe CB::TeamMemberInfo do +describe CB::Action::TeamMemberInfo do it "validates that required arguments are present" do - action = CB::TeamMemberInfo.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberInfo.new(TeamMemberTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ expect_cb_error(msg) { action.call } end it "#run prints unknown member" do - action = CB::TeamMemberInfo.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberInfo.new(TeamMemberTestClient.new(TEST_TOKEN)) action.output = output = IO::Memory.new action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -100,16 +100,16 @@ describe CB::TeamMemberInfo do end end -describe CB::TeamMemberList do +describe CB::Action::TeamMemberList do it "validates that required arguments are present" do - action = CB::TeamMemberList.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberList.new(TeamMemberTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ expect_cb_error(msg) { action.call } end it "#run" do - action = CB::TeamMemberList.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberList.new(TeamMemberTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -118,16 +118,16 @@ describe CB::TeamMemberList do end end -describe CB::TeamMemberUpdate do +describe CB::Action::TeamMemberUpdate do it "validates that required arguments are present" do - action = CB::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ expect_cb_error(msg) { action.call } end it "validates argument conflicts" do - action = CB::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" action.account_id = "4pfqoxothfagnfdryk2og7noei" @@ -138,7 +138,7 @@ describe CB::TeamMemberUpdate do end it "#run" do - action = CB::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new # TODO (abrightwell): There's got to be a better way to test the output. For @@ -150,7 +150,7 @@ describe CB::TeamMemberUpdate do action.call.should_not be nil action.output.to_s.should_not eq "" - action = CB::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -160,7 +160,7 @@ describe CB::TeamMemberUpdate do end it "#run unknown team member" do - action = CB::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberUpdate.new(TeamMemberTestClient.new(TEST_TOKEN)) action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" action.email = "unknown@example.com" @@ -170,16 +170,16 @@ describe CB::TeamMemberUpdate do end end -describe CB::TeamMemberRemove do +describe CB::Action::TeamMemberRemove do it "validates that required arguments are present" do - action = CB::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) msg = /Missing required argument/ expect_cb_error(msg) { action.call } end it "validates argument conflicts" do - action = CB::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" action.account_id = "4pfqoxothfagnfdryk2og7noei" @@ -190,7 +190,7 @@ describe CB::TeamMemberRemove do end it "#run" do - action = CB::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -198,7 +198,7 @@ describe CB::TeamMemberRemove do action.call.should_not be nil action.output.to_s.should_not eq "" - action = CB::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) action.output = IO::Memory.new action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" @@ -208,7 +208,7 @@ describe CB::TeamMemberRemove do end it "#run unknown team member" do - action = CB::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) + action = CB::Action::TeamMemberRemove.new(TeamMemberTestClient.new(TEST_TOKEN)) action.team_id = "pkdpq6yynjgjbps4otxd7il2u4" action.email = "unknown@example.com" diff --git a/src/cb/action.cr b/src/cb/action/action.cr similarity index 95% rename from src/cb/action.cr rename to src/cb/action/action.cr index 3cec43e..ecd8075 100644 --- a/src/cb/action.cr +++ b/src/cb/action/action.cr @@ -1,11 +1,11 @@ require "log" -require "./client" +require "../client" -module CB +module CB::Action # Action is the base class for all actions performed by `cb`. abstract class Action Log = ::Log.for("Action") - Error = Program::Error + Error = CB::Program::Error property input : IO property output : IO @@ -22,7 +22,7 @@ module CB property {{property}} : String? def {{property}}=(str : String) - raise_arg_error {{description || property.stringify.gsub(/_/, " ")}}, str unless str =~ EID_PATTERN + raise_arg_error {{description || property.stringify.gsub(/_/, " ")}}, str unless str =~ CB::EID_PATTERN @{{property}} = str end end @@ -99,7 +99,7 @@ module CB # APIAction performs some action utilizing the API. abstract class APIAction < Action - property client : Client + property client : CB::Client def initialize(@client, @input = STDIN, @output = STDOUT) end diff --git a/src/cb/backup.cr b/src/cb/action/backup.cr similarity index 95% rename from src/cb/backup.cr rename to src/cb/action/backup.cr index 38e5196..1894b06 100644 --- a/src/cb/backup.cr +++ b/src/cb/action/backup.cr @@ -1,20 +1,6 @@ require "./action" module CB - class BackupCapture < APIAction - eid_setter cluster_id - - def run - check_required_args do |missing| - missing << "cluster id" unless cluster_id - end - - client.put "clusters/#{cluster_id}/actions/start-backup" - c = client.get_cluster cluster_id - output << "requested backup capture of " << c.name.colorize.t_name << "\n" - end - end - class Client jrecord Backup, name : String, @@ -54,6 +40,22 @@ module CB BackupToken.from_json resp.body end end +end + +module CB::Action + class BackupCapture < APIAction + eid_setter cluster_id + + def run + check_required_args do |missing| + missing << "cluster id" unless cluster_id + end + + client.put "clusters/#{cluster_id}/actions/start-backup" + c = client.get_cluster cluster_id + output << "requested backup capture of " << c.name.colorize.t_name << "\n" + end + end class BackupList < APIAction eid_setter cluster_id @@ -124,13 +126,13 @@ module CB output << "Type:".colorize.bold << " #{token.type}\n" output << "Repo Path:".colorize.bold << " #{token.repo_path}\n" output << "Stanza:".colorize.bold << " #{token.stanza}\n" - if cred.is_a?(Client::AWSBackrestCredential) + if cred.is_a?(CB::Client::AWSBackrestCredential) output << "S3 Bucket:".colorize.bold << " #{cred.s3_bucket}\n" output << "S3 Key:".colorize.bold << " #{cred.s3_key}\n" output << "S3 Key Secret:".colorize.bold << " #{cred.s3_key_secret}\n" output << "S3 Region:".colorize.bold << " #{cred.s3_region}\n" output << "S3 Token:".colorize.bold << " #{cred.s3_token}\n" - elsif cred.is_a?(Client::AzureBackrestCredential) + elsif cred.is_a?(CB::Client::AzureBackrestCredential) output << "Azure Account:".colorize.bold << " #{cred.azure_account}\n" output << "Azure Container:".colorize.bold << " #{cred.azure_container}\n" output << "Azure Key:".colorize.bold << " #{cred.azure_key}\n" @@ -144,7 +146,7 @@ module CB output << "[#{token.stanza}]\n" output << "repo1-type=#{token.type}\n" output << "repo1-path=#{token.repo_path}\n" - if cred.is_a?(Client::AWSBackrestCredential) + if cred.is_a?(CB::Client::AWSBackrestCredential) output << <<-AWS repo1-s3-bucket=#{cred.s3_bucket} repo1-s3-key=#{cred.s3_key} @@ -153,7 +155,7 @@ repo1-s3-token=#{cred.s3_token} repo1-s3-endpoint=s3.dualstack.#{cred.s3_region}.amazonaws.com repo1-s3-region=#{cred.s3_region} AWS - elsif cred.is_a?(Client::AzureBackrestCredential) + elsif cred.is_a?(CB::Client::AzureBackrestCredential) output << <<-AZURE repo1-azure-account=#{cred.azure_account} repo1-azure-container=#{cred.azure_container} diff --git a/src/cb/action/cluster_create.cr b/src/cb/action/cluster_create.cr new file mode 100644 index 0000000..fc00886 --- /dev/null +++ b/src/cb/action/cluster_create.cr @@ -0,0 +1,71 @@ +require "./action" + +module CB::Action + class ClusterCreate < APIAction + bool_setter ha + property name : String? + ident_setter plan + property platform : String? + i32_setter postgres_version + ident_setter region + i32_setter storage + eid_setter team, "team id" + eid_setter network, "network id" + eid_setter replica, "replica id" + eid_setter fork, "fork id" + property at : Time? + + def pre_validate + if (id = fork || replica) + source = client.get_cluster id + + self.name ||= "#{fork ? "Fork" : "Replica"} of #{source.name}" + self.platform ||= source.provider_id + self.region ||= source.region_id + self.storage ||= source.storage + self.plan ||= source.plan_id + else + self.storage ||= 100 + self.name ||= "Cluster #{Time.utc.to_s("%F %H_%M_%S")}" + end + end + + def run + validate + cluster = if fork + @client.fork_cluster self + elsif replica + @client.replicate_cluster self + else + @client.create_cluster self + end + @output.puts %(Created cluster #{cluster.id.colorize.t_id} "#{cluster.name.colorize.t_name}") + end + + def validate + pre_validate + check_required_args do |missing| + missing << "ha" if ha.nil? + missing << "name" unless name + missing << "plan" unless plan + missing << "platform" unless platform + missing << "region" unless region + missing << "storage" unless storage + missing << "team" unless team || fork || replica + end + end + + def at=(str : String) + self.at = Time.parse_rfc3339(str).to_utc + rescue Time::Format::Error + raise_arg_error "at (not RFC3339)", str + end + + def platform=(str : String) + str = str.downcase + str = "azure" if str == "azr" + raise_arg_error "platform", str unless str == "azure" || str == "gcp" || str == "aws" + @platform = str + end + end +end diff --git a/src/cb/action/cluster_destroy.cr b/src/cb/action/cluster_destroy.cr new file mode 100644 index 0000000..24cb9dd --- /dev/null +++ b/src/cb/action/cluster_destroy.cr @@ -0,0 +1,22 @@ +require "./action" + +module CB::Action + class ClusterDestroy < APIAction + eid_setter cluster_id + + def run + c = client.get_cluster cluster_id + output << "About to " << "delete".colorize.t_warn << " cluster " << c.name.colorize.t_name + team_name = team_name_for_cluster c + output << " from team #{team_name}" if team_name + output << ".\n Type the cluster's name to confirm: " + response = input.gets + if c.name == response + client.destroy_cluster cluster_id + output.puts "Cluster #{c.id.colorize.t_id} destroyed" + else + output.puts "Response did not match, did not destroy the cluster" + end + end + end +end diff --git a/src/cb/action/cluster_info.cr b/src/cb/action/cluster_info.cr new file mode 100644 index 0000000..bade43a --- /dev/null +++ b/src/cb/action/cluster_info.cr @@ -0,0 +1,44 @@ +require "./action" + +module CB::Action + class ClusterInfo < APIAction + eid_setter cluster_id + + def run + c = client.get_cluster cluster_id + print_team_slash_cluster c + + details = { + "state" => c.state, + "created" => c.created_at.to_rfc3339, + "plan" => "#{c.plan_id} (#{c.memory}GiB ram, #{c.cpu}vCPU)", + "version" => c.major_version, + "storage" => "#{c.storage}GiB", + "ha" => (c.is_ha ? "on" : "off"), + "platform" => c.provider_id, + "region" => c.region_id, + } + + if source = c.source_cluster_id + details["source cluster"] = source + end + + details["network"] = c.network_id if c.network_id + + pad = (details.keys.map(&.size).max || 8) + 2 + details.each do |k, v| + output << k.rjust(pad).colorize.bold << ": " + output << v << "\n" + end + + firewall_rules = client.get_firewall_rules cluster_id + output << "firewall".rjust(pad).colorize.bold << ": " + if firewall_rules.empty? + output << "no rules\n" + else + output << "allowed cidrs".colorize.underline << "\n" + end + firewall_rules.each { |fr| output << " "*(pad + 4) << fr.rule << "\n" } + end + end +end diff --git a/src/cb/action/cluster_list.cr b/src/cb/action/cluster_list.cr new file mode 100644 index 0000000..b294b29 --- /dev/null +++ b/src/cb/action/cluster_list.cr @@ -0,0 +1,21 @@ +require "./action" + +module CB::Action + class List < APIAction + def run + teams = client.get_teams + clusters = client.get_clusters(teams) + cluster_max = clusters.map(&.name.size).max? || 0 + + clusters.each do |cluster| + output << cluster.id.colorize.t_id + output << "\t" + output << cluster.name.ljust(cluster_max).colorize.t_name + output << "\t" + team_name = teams.find { |t| t.id == cluster.team_id }.try &.name || cluster.team_id + output << team_name.colorize.t_alt + output << "\n" + end + end + end +end diff --git a/src/cb/action/cluster_rename.cr b/src/cb/action/cluster_rename.cr new file mode 100644 index 0000000..755d98e --- /dev/null +++ b/src/cb/action/cluster_rename.cr @@ -0,0 +1,17 @@ +require "./action" + +module CB::Action + class ClusterRename < APIAction + eid_setter cluster_id + property new_name : String? + + def run + c = client.get_cluster cluster_id + print_team_slash_cluster c + + new_c = client.update_cluster cluster_id, {"name" => new_name} + + output << "renamed to " << new_c.name.colorize.t_name << "\n" + end + end +end diff --git a/src/cb/cluster_suspend_resume.cr b/src/cb/action/cluster_suspend_resume.cr similarity index 96% rename from src/cb/cluster_suspend_resume.cr rename to src/cb/action/cluster_suspend_resume.cr index 467deaf..3098d91 100644 --- a/src/cb/cluster_suspend_resume.cr +++ b/src/cb/action/cluster_suspend_resume.cr @@ -1,6 +1,6 @@ require "./action" -module CB +module CB::Action class ClusterSuspend < APIAction eid_setter cluster_id diff --git a/src/cb/action/cluster_upgrade.cr b/src/cb/action/cluster_upgrade.cr new file mode 100644 index 0000000..73d63d1 --- /dev/null +++ b/src/cb/action/cluster_upgrade.cr @@ -0,0 +1,80 @@ +require "./action" + +module CB::Action + abstract class Upgrade < APIAction + eid_setter cluster_id + property confirmed : Bool = false + + abstract def run + + def validate + check_required_args do |missing| + missing << "cluster" unless cluster_id + end + end + end + + # Action to start cluster upgrade. + class UpgradeStart < Upgrade + bool_setter ha + i32_setter postgres_version + i32_setter storage + property plan : String? + + def run + validate + + c = client.get_cluster cluster_id + + unless confirmed + output << "About to " << "upgrade".colorize.t_warn << " cluster " << c.name.colorize.t_name + output << ".\n Type the cluster's name to confirm: " + response = input.gets + + if c.name == response + self.confirmed = true + else + raise Error.new "Response did not match, did not upgrade the cluster" + end + end + + client.upgrade_cluster self + output.puts " Cluster #{c.id.colorize.t_id} upgrade started." + end + end + + # Action to cancel cluster upgrade. + class UpgradeCancel < Upgrade + def run + validate + + c = client.get_cluster cluster_id + print_team_slash_cluster c + + client.upgrade_cluster_cancel cluster_id + output << " upgrade cancelled\n".colorize.bold + end + end + + # Action to get the cluster upgrade status. + class UpgradeStatus < Upgrade + def run + validate + + c = client.get_cluster cluster_id + print_team_slash_cluster c + + operations = client.upgrade_cluster_status cluster_id + + if operations.empty? + output << " no upgrades in progress\n".colorize.bold + else + pad = (operations.map(&.flavor.size).max || 8) + 2 + operations.each do |op| + output << op.flavor.rjust(pad).colorize.bold << ": " + output << op.state << "\n" + end + end + end + end +end diff --git a/src/cb/action/cluster_uri.cr b/src/cb/action/cluster_uri.cr new file mode 100644 index 0000000..b956f6c --- /dev/null +++ b/src/cb/action/cluster_uri.cr @@ -0,0 +1,44 @@ +require "./action" + +module CB::Action + class ClusterURI < APIAction + eid_setter cluster_id + property role_name : String = "default" + + def run + # Ensure the role name + raise Error.new("invalid role: '#{@role_name}'") unless CB::VALID_CLUSTER_ROLES.includes? @role_name + if @role_name == "user" + @role_name = "u_#{client.get_account.id}" + end + + # Fetch the role. + role = client.get_role(cluster_id, @role_name) + + # Redact the password from the result. Redaction is handled by coloring the + # foreground and background the same color. This benfits the user by not + # allowing their password to be inadvertently exposed in a TTY session. But + # it still allows for it to be copied and pasted without requiring any + # special action from the user. + uri = role.uri.to_s + unless role.password.nil? + pw = role.password + uri = uri.gsub(pw, pw.colorize.black.on_black.to_s) if pw + end + + output << uri + rescue e : CB::Client::Error + msg = "unknown client error." + case + when e.bad_request? + msg = "invalid input." + when e.forbidden? + msg = "not allowed." + when e.not_found? + msg = "role '#{@role_name}' does not exist." + end + + raise Error.new msg + end + end +end diff --git a/src/cb/action/detach.cr b/src/cb/action/detach.cr new file mode 100644 index 0000000..f1977c9 --- /dev/null +++ b/src/cb/action/detach.cr @@ -0,0 +1,33 @@ +require "./action" + +module CB::Action + class Detach < APIAction + eid_setter cluster_id + property confirmed : Bool = false + + def run + validate + + c = client.get_cluster cluster_id + + unless confirmed + output << "About to " << "detach".colorize.t_warn << " cluster " << c.name.colorize.t_name + output << ".\n Type the cluster's name to confirm: " + response = input.gets + + if !(c.name == response) + raise Error.new "Response did not match, did not detach the cluster" + end + end + + client.detach_cluster cluster_id + output.puts "Cluster #{c.id.colorize.t_id} detached." + end + + def validate + check_required_args do |missing| + missing << "cluster" unless cluster_id + end + end + end +end diff --git a/src/cb/logdest_add.cr b/src/cb/action/logdest_add.cr similarity index 98% rename from src/cb/logdest_add.cr rename to src/cb/action/logdest_add.cr index c131e3f..7abc318 100644 --- a/src/cb/logdest_add.cr +++ b/src/cb/action/logdest_add.cr @@ -1,6 +1,6 @@ require "./action" -module CB +module CB::Action class LogDestinationAdd < APIAction eid_setter cluster_id i32_setter port diff --git a/src/cb/logdest_destroy.cr b/src/cb/action/logdest_destroy.cr similarity index 95% rename from src/cb/logdest_destroy.cr rename to src/cb/action/logdest_destroy.cr index 74ca147..83855f0 100644 --- a/src/cb/logdest_destroy.cr +++ b/src/cb/action/logdest_destroy.cr @@ -1,6 +1,6 @@ require "./action" -module CB +module CB::Action class LogDestinationDestroy < APIAction eid_setter cluster_id eid_setter logdest_id diff --git a/src/cb/logdest_list.cr b/src/cb/action/logdest_list.cr similarity index 98% rename from src/cb/logdest_list.cr rename to src/cb/action/logdest_list.cr index a77a6bb..cf86062 100644 --- a/src/cb/logdest_list.cr +++ b/src/cb/action/logdest_list.cr @@ -1,6 +1,6 @@ require "./action" -module CB +module CB::Action class LogDestinationList < APIAction eid_setter cluster_id diff --git a/src/cb/action/login.cr b/src/cb/action/login.cr new file mode 100644 index 0000000..6e51646 --- /dev/null +++ b/src/cb/action/login.cr @@ -0,0 +1,29 @@ +require "./action" + +module CB::Action + class Login < Action + def run + host = CB::HOST + + raise CB::Program::Error.new "No valid credentials found. Please login." unless output.tty? + hint = "from https://www.crunchybridge.com/account" if host == "api.crunchybridge.com" + output.puts "add credentials for #{host.colorize.t_name} #{hint}>" + output.print " application ID: " + id = input.gets + if id.nil? || id.empty? + STDERR.puts "#{"error".colorize.red.bold}: application ID must be present" + exit 1 + end + + print " application secret: " + secret = input.noecho { input.gets } + output.print "\n" + if secret.nil? || secret.empty? + STDERR.puts "#{"error".colorize.red.bold}: application secret must be present" + exit 1 + end + + CB::Creds.new(host, id, secret).store + end + end +end diff --git a/src/cb/logs.cr b/src/cb/action/logs.cr similarity index 80% rename from src/cb/logs.cr rename to src/cb/action/logs.cr index 2c019e3..d5628fc 100644 --- a/src/cb/logs.cr +++ b/src/cb/action/logs.cr @@ -1,14 +1,14 @@ require "./action" -require "./dirs" +require "../dirs" require "ssh2" -module CB +module CB::Action class Logs < APIAction eid_setter cluster_id def run - tk = Tempkey.for_cluster cluster_id, client: client + tk = CB::Tempkey.for_cluster cluster_id, client: client host = "p.#{cluster_id}.db.postgresbridge.com" socket = TCPSocket.new(host, 22, connect_timeout: 1) @@ -24,7 +24,7 @@ module CB output.write buffer.to_slice[0, read_bytes] end rescue e - raise Program::Error.new(cause: e) + raise CB::Program::Error.new(cause: e) end end end diff --git a/src/cb/action/manage_firewall.cr b/src/cb/action/manage_firewall.cr new file mode 100644 index 0000000..252f768 --- /dev/null +++ b/src/cb/action/manage_firewall.cr @@ -0,0 +1,73 @@ +module CB::Action + class ManageFirewall < APIAction + Error = CB::Program::Error + + eid_setter cluster_id + property to_add = [] of String + property to_remove = [] of String + + def add(cidr : String) + to_add << cidr + end + + def remove(cidr : String) + to_remove << cidr + end + + def run + raise Error.new "--cluster not set" unless cluster_id + remove_all + add_all + display_rules + end + + def remove_all + return if to_remove.empty? + current_rules = @client.get_firewall_rules(cluster_id) + + to_remove.uniq.each do |cidr| + cidr_str = cidr.colorize.t_name + if rule = current_rules.find { |r| r.rule == cidr } + output << "removing #{cidr_str} … " << remove_rule(rule) << "\n" + else + output << "not removing".colorize.t_warn << " #{cidr_str} — does not exist\n" + end + end + end + + def add_all + to_add.uniq.each do |cidr| + cidr_str = cidr.colorize.t_name + output << "adding #{cidr_str} … " << add_rule(cidr) << "\n" + end + end + + def display_rules + current_rules = @client.get_firewall_rules(cluster_id) + + pad = if output.tty? + output.puts "allowed cidrs:" + output.puts " none" if current_rules.empty? + " " + else + "" + end + + current_rules.each { |r| output.puts "#{pad}#{r.rule.colorize.t_name}" } + end + + def remove_rule(rule : CB::Client::FirewallRule) + @client.delete_firewall_rule cluster_id, rule.id + "done".colorize.t_success + rescue e : CB::Client::Error + output.print e + end + + def add_rule(cidr : String) + @client.add_firewall_rule cluster_id, cidr + "done".colorize.t_success + rescue e : CB::Client::Error + output.print e + end + end +end diff --git a/src/cb/action/psql.cr b/src/cb/action/psql.cr new file mode 100644 index 0000000..eae8edb --- /dev/null +++ b/src/cb/action/psql.cr @@ -0,0 +1,73 @@ +require "./action" + +module CB::Action + class Psql < APIAction + eid_setter cluster_id + property database : String? + + def run + c = client.get_cluster cluster_id + uri = client.get_role(cluster_id, "default").uri + raise Error.new "null uri" if uri.nil? + + database.tap { |db| uri.path = db if db } + + output << "connecting to " + team_name = print_team_slash_cluster c + + cert_path = ensure_cert c.team_id + psqlrc_path = build_psqlrc c, team_name + + args = ARGV.skip 1 + + Process.exec("psql", args, env: { + "PGHOST" => uri.hostname, + "PGUSER" => uri.user, + "PGPASSWORD" => uri.password, + "PGDATABASE" => uri.path.lchop('/'), + "PGPORT" => uri.port.to_s, + "PSQLRC" => psqlrc_path, + "PGSSLCERT" => "dontuse", + "PGSSLKEY" => "dontuse", + "PGSSLMODE" => "verify-ca", + "PGSSLROOTCERT" => cert_path, + }) + rescue e : File::NotFoundError + raise Error.new "The local psql command could not be found" + end + + def database=(str : String) + @database = str + end + + private def ensure_cert(team_id) : String + cert_dir = CB::Creds::CONFIG / "certs" + path = cert_dir / "#{team_id}.pem" + unless File.exists? path + Dir.mkdir_p cert_dir + File.open(path, "w", perm: 0o600) do |f| + f << client.get("teams/#{team_id}.pem").body + end + end + + path.to_s + end + + private def build_psqlrc(c, team_name) : String + psqlpromptname = String.build do |s| + s << "%[%033[32m%]#{team_name}%[%033m%]" << "/" if team_name + s << "%[%033[36m%]#{c.name}%[%033m%]" + end + + psqlrc = File.tempfile(c.id, "psqlrc") + File.copy("~/.psqlrc", psqlrc.path) if File.exists?("~/.psqlrc") + File.open(psqlrc.path, "a") do |f| + f.puts "\\set ON_ERROR_ROLLBACK interactive" + f.puts "\\set x auto" + f.puts "\\set PROMPT1 '#{psqlpromptname}/%[%033[33;1m%]%x%x%x%[%033[0m%]%[%033[1m%]%/%[%033[0m%]%R%# '" + end + + psqlrc.path.to_s + end + end +end diff --git a/src/cb/action/restart.cr b/src/cb/action/restart.cr new file mode 100644 index 0000000..5ac6a87 --- /dev/null +++ b/src/cb/action/restart.cr @@ -0,0 +1,38 @@ +require "./action" + +module CB::Action + class Restart < APIAction + eid_setter cluster_id + bool_setter confirmed + bool_setter full + + def run + validate + + c = client.get_cluster cluster_id + + unless confirmed + output << "About to " << "restart".colorize.t_warn << " cluster " << c.name.colorize.t_name + output << ".\n Type the cluster's name to confirm: " + response = input.gets + + if c.name == response + self.confirmed = true + else + raise Error.new "Response did not match, did not restart the cluster." + end + end + + service = full ? "server" : "postgres" + + client.restart_cluster cluster_id, service + output.puts "Cluster #{c.id.colorize.t_id} restarted." + end + + def validate + check_required_args do |missing| + missing << "cluster" unless cluster_id + end + end + end +end diff --git a/src/cb/action/role.cr b/src/cb/action/role.cr new file mode 100644 index 0000000..405622f --- /dev/null +++ b/src/cb/action/role.cr @@ -0,0 +1,78 @@ +require "./action" + +module CB + # Valid cluster role names. + VALID_CLUSTER_ROLES = Set{"application", "default", "postgres", "user"} +end + +module CB::Action + abstract class RoleAction < APIAction + eid_setter cluster_id + property role_name : String? + end + + # Action to create a cluster role for the calling user. + class RoleCreate < RoleAction + def validate + check_required_args do |missing| + missing << "cluster" unless cluster_id + end + end + + def run + validate + + role = client.create_role @cluster_id + output << "Role #{role.name} created on cluster #{@cluster_id}.\n" + end + end + + # Action to update a cluster role. + class RoleUpdate < RoleAction + bool_setter? read_only + bool_setter? rotate_password + + def validate + check_required_args do |missing| + missing << "cluster" unless cluster_id + missing << "name" unless role_name + end + end + + def run + validate + + # Ensure the role name + @role_name = "default" unless @role_name + raise Error.new("invalid role '#{@role_name}'") unless CB::VALID_CLUSTER_ROLES.includes? @role_name + if @role_name == "user" + @role_name = "u_" + client.get_account.id + end + + flavor = read_only ? "read" : "write" unless read_only.nil? + + role = client.update_role @cluster_id, @role_name, {flavor: flavor, rotate_password: rotate_password} + + output << "Role #{role.name} updated on cluster #{@cluster_id}.\n" + end + end + + class RoleDelete < RoleAction + def run + check_required_args do |missing| + missing << "cluster" unless cluster_id + missing << "name" unless role_name + end + + # Ensure the role name + @role_name = "default" unless @role_name + raise Error.new("invalid role '#{@role_name}'") unless CB::VALID_CLUSTER_ROLES.includes? @role_name + if @role_name == "user" + @role_name = "u_" + client.get_account.id + end + + role = client.delete_role @cluster_id, @role_name + output << "Role #{role.name} deleted from cluster #{@cluster_id}.\n" + end + end +end diff --git a/src/cb/action/scope.cr b/src/cb/action/scope.cr new file mode 100644 index 0000000..05fd2d9 --- /dev/null +++ b/src/cb/action/scope.cr @@ -0,0 +1,47 @@ +require "./action" +require "../scope_checks/*" + +module CB::Action + class Scope < APIAction + property cluster_id : String? + property checks : Array(::Scope::Check.class) = [] of ::Scope::Check.class + property database : String? + property suite : String? + + def run + check_required_args { |missing| missing << "cluster" unless cluster_id } + + uri = client.get_role(cluster_id, "default").uri + raise Error.new "null uri" if uri.nil? + + # Accept only SCRAM with Channel Binding. + # + # https://github.com/will/crystal-pg#authentication-methods + uri.query = "auth_methods=scram-sha-256-plus" + + if database.presence + uri.path = database.to_s + end + + self.suite = "quick" if checks.empty? && suite.nil? + + case suite + when "all" + self.checks = ::Scope::Check.all.map(&.type) - [::Scope::Mandelbrot] + when "quick" + self.checks += [::Scope::TableInfo, ::Scope::IndexHit, ::Scope::Blocking] + when nil + else + raise Error.new("unknown suite '#{suite.inspect}'") + end + + to_run = checks.uniq.sort_by!(&.name) + + DB.open(uri) do |db| + to_run.map(&.new(db)).each do |c| + @output << c << "\n" + end + end + end + end +end diff --git a/src/cb/action/team.cr b/src/cb/action/team.cr new file mode 100644 index 0000000..50b144c --- /dev/null +++ b/src/cb/action/team.cr @@ -0,0 +1,110 @@ +require "./action" + +module CB::Action + abstract class TeamAction < APIAction + eid_setter team_id + + private def team_details(t : CB::Client::Team) : String + String.build do |str| + str << "ID: \t" << t.id.colorize.t_id << "\n" + str << "Name: \t" << t.name.colorize.t_name << "\n" + str << "Role: \t" << t.role.to_s.titleize << "\n" + str << "Billing Email:\t" << t.billing_email << "\n" + str << "Enforce SSO: \t" << (t.enforce_sso.nil? ? "disabled" : t.enforce_sso) + end + end + end + + class TeamCreate < TeamAction + property name : String = "" + + def run + check_required_args do |missing| + missing << "name" if name.empty? + end + + team = client.create_team name + output << "Created team #{team}\n" + end + end + + class TeamList < TeamAction + def run + teams = client.get_teams + name_max = teams.map(&.name.size).max? || 0 + + teams.each do |team| + output << team.id.colorize.t_id << "\t" + output << team.name.ljust(name_max).colorize.t_name << "\t" + output << team.role.to_s.titleize << "\n" + end + end + end + + class TeamInfo < TeamAction + def run + team = client.get_team team_id + output << team_details(team) << "\n" + end + end + + class TeamUpdate < TeamAction + ident_setter name + + # TODO: (abrightwell) - would be really nice to have some validation on this + # property. I briefly looked into implementing a macro for it, but it an email + # regex is non-trivial. Need to determine what would be 'good enough' for our + # purposes here. So I'm going to come back to this one in a future update. For + # now, we'll rely on the API validation of the field. + property billing_email : String? + bool_setter? enforce_sso + bool_setter confirmed + + def run + check_required_args do |missing| + missing << "team" unless team_id + end + + unless confirmed + t = client.get_team team_id + + output.printf "About to %s team %s.\n", "update".colorize.t_warn, t.name.colorize.t_name + output.printf " Type the team's name to confirm: " + response = input.gets + + raise Error.new "Response did not match, did not update the team" unless response == t.name + end + + team = client.update_team team_id, { + "billing_email" => billing_email, + "enforce_sso" => enforce_sso, + "name" => name, + } + + output << team_details(team) << "\n" + end + end + + class TeamDestroy < TeamAction + bool_setter confirmed + + def run + check_required_args do |missing| + missing << "team" unless team_id + end + + unless confirmed + t = client.get_team team_id + + output.printf "About to %s team %s.\n", "delete".colorize.t_warn, t.name.colorize.t_name + output.printf " Type the team's name to confirm: " + response = input.gets + + raise Error.new "Response did not match, did not delete the team." unless response == t.name + end + + team = client.destroy_team team_id + output << "Deleted team #{team}\n" + end + end +end diff --git a/src/cb/action/team_cert.cr b/src/cb/action/team_cert.cr new file mode 100644 index 0000000..f32d0b3 --- /dev/null +++ b/src/cb/action/team_cert.cr @@ -0,0 +1,18 @@ +require "./action" + +module CB::Action + class TeamCert < APIAction + eid_setter team_id + + def run + cert = client.get("teams/#{team_id}.pem").body + output.puts cert + rescue e : CB::Client::Error + if e.not_found? + STDERR << "error".colorize.t_warn << ": No public cert found.\n" + else + raise e + end + end + end +end diff --git a/src/cb/action/team_member.cr b/src/cb/action/team_member.cr new file mode 100644 index 0000000..d978438 --- /dev/null +++ b/src/cb/action/team_member.cr @@ -0,0 +1,184 @@ +require "./action" + +module CB + TEAM_ROLE_MEMBER = "member" + TEAM_ROLE_MANAGER = "manager" + TEAM_ROLE_ADMIN = "admin" + + # Valid team member roles. + VALID_TEAM_ROLES = Set{ + TEAM_ROLE_ADMIN, + TEAM_ROLE_MANAGER, + TEAM_ROLE_MEMBER, + } +end + +module CB::Action + # Superclass for all TeamMember related `Action`s}. + # + # Provides common properties and functionality specific to team member managment + # actions. + abstract class TeamMemberAction < APIAction + eid_setter team_id + eid_setter account_id + property email : String? + + private def validate_account_email + check_required_args do |missing| + missing << "team" unless team_id + missing << "account" unless account_id || email + end + + raise Error.new "Must only use '--account' or '--email' but not both." if account_id && email + end + + private def get_member_by_email(team_id, email) + members = client.list_team_members(team_id) + members.find { |tm| tm.email == email } + end + + private def team_member_details(tm : CB::Client::TeamMember) : String + String.build do |str| + str << "Email: \t" << tm.email.colorize.t_name << '\n' + str << "Team ID: \t" << tm.team_id.colorize.t_id << '\n' + str << "Account ID:\t" << tm.account_id.colorize.t_id << '\n' + str << "Role: \t" << tm.role.titleize + end + end + end + + # Action to add an account to a team as a member. + class TeamMemberAdd < TeamMemberAction + property role : String = CB::TEAM_ROLE_MEMBER + + def validate + valid = check_required_args do |missing| + missing << "team" unless team_id + missing << "email" unless email + missing << "role" if role.empty? + end + + raise Error.new("invalid role '#{@role}'") unless CB::VALID_TEAM_ROLES.includes? @role + + valid + end + + def run + validate + + team_member = client.create_team_member( + @team_id, + CB::Client::TeamMemberCreateParams.new(email.to_s, role), + ) + + output << "Added " << team_member.email.colorize.green + output << " to team " << team_member.team_id.colorize.t_id + output << " as role '" << team_member.role.colorize.t_name + output << "'.\n" + end + end + + # Action to list the current members of a team. + class TeamMemberList < TeamMemberAction + def validate + check_required_args do |missing| + missing << "team" unless team_id + end + end + + def run + validate + + team_members = client.list_team_members(team_id) + email_max = team_members.map(&.email.size).max? || 0 + + # Only personal teams can be without members. So, it should be safe to + # assume that if the request returns an empty list that it is 'personal' + # team. However, not wanting umptions to shoulder this one alone in the + # case that we are wrong, we'll just simply respond that the requested team + # doesn't have any members. Making no claims to it being personal or + # otherwise. Which should be good enough in all cases, however unlikely + # they might be. + if team_members.empty? + output << "Team #{team_id.colorize.t_id} has no team members.\n" + else + team_members.each do |member| + output << member.account_id.colorize.t_id << '\t' + output << member.email.ljust(email_max).colorize.t_name << '\t' + output << member.role.titleize << '\n' + end + end + end + end + + # Action to show information detail about a specific team member. + # + # Allows for both account ID and email of the team member to be provided, + # however, the account ID will take precedence over the email. Therefore, if + # supplying both will raise a validation error. + class TeamMemberInfo < TeamMemberAction + def run + validate_account_email + + tm = if @account_id.nil? + get_member_by_email(team_id, email) + else + client.get_team_member(team_id, @account_id) + end + + if tm.nil? + # TODO (abrightwell): move this to an error above, similar to update. + output << "Unknown team member.\n" + else + output << team_member_details(tm) << '\n' + end + end + end + + # Action to update a team member. + # + # Allows for both account ID and email of the team member to be provided, + # however, the account ID will take precedence over the email. Therefore, if + # supplying both will raise a validation error. + class TeamMemberUpdate < TeamMemberAction + setter role : String? + + def run + validate_account_email + + unless @role.nil? + raise Error.new("invalid role '#{@role}'") unless CB::VALID_TEAM_ROLES.includes? @role + end + + if account_id.nil? + tm = get_member_by_email(team_id, @email) if account_id.nil? + raise Error.new "Unknown team member '#{@email}'." if tm.nil? + @account_id = tm.account_id + end + + updated = client.update_team_member(team_id, @account_id, @role) + output << team_member_details(updated) << '\n' unless updated.nil? + end + end + + # Action to remove a user as member from a team. + # + # Allows for both account ID and email of the team member to be provided, + # however, the account ID will take precedence over the email. Therefore, if + # supplying both will raise a validation error. + class TeamMemberRemove < TeamMemberAction + def run + validate_account_email + + if account_id.nil? + tm = get_member_by_email(team_id, @email) if account_id.nil? + raise Error.new "Unknown team member '#{@email}'." if tm.nil? + @account_id = tm.account_id + end + + removed = client.remove_team_member(team_id, account_id) + team = client.get_team(team_id) + output << "Removed #{removed.email.colorize.t_name} from team #{team}.\n" unless removed.nil? + end + end +end diff --git a/src/cb/tempkey.cr b/src/cb/action/tempkey.cr similarity index 100% rename from src/cb/tempkey.cr rename to src/cb/action/tempkey.cr diff --git a/src/cb/action/token.cr b/src/cb/action/token.cr new file mode 100644 index 0000000..53d7f02 --- /dev/null +++ b/src/cb/action/token.cr @@ -0,0 +1,25 @@ +require "./action" + +module CB::Action + class Token < Action + enum Format + Default + Header + end + + property token : CB::Token + property format : Format = Format::Default + + def initialize(@token, @input, @output) + end + + def run + case @format + when "header" + output << "Authorization: Bearer #{token.token}" + when "default" + output << token.token + end + end + end +end diff --git a/src/cb/action/whoami.cr b/src/cb/action/whoami.cr new file mode 100644 index 0000000..c39d28a --- /dev/null +++ b/src/cb/action/whoami.cr @@ -0,0 +1,11 @@ +require "./action" + +module CB::Action + class WhoAmI < APIAction + def run + output << "user id: ".colorize.t_id << client.token.user_id << "\n" + output << " name: ".colorize.t_id << client.token.name << "\n" + output << " host: ".colorize.t_id << client.host << "\n" + end + end +end diff --git a/src/cb/cluster_create.cr b/src/cb/cluster_create.cr deleted file mode 100644 index a83c277..0000000 --- a/src/cb/cluster_create.cr +++ /dev/null @@ -1,69 +0,0 @@ -require "./action" - -class CB::ClusterCreate < CB::APIAction - bool_setter ha - property name : String? - ident_setter plan - property platform : String? - i32_setter postgres_version - ident_setter region - i32_setter storage - eid_setter team, "team id" - eid_setter network, "network id" - eid_setter replica, "replica id" - eid_setter fork, "fork id" - property at : Time? - - def pre_validate - if (id = fork || replica) - source = client.get_cluster id - - self.name ||= "#{fork ? "Fork" : "Replica"} of #{source.name}" - self.platform ||= source.provider_id - self.region ||= source.region_id - self.storage ||= source.storage - self.plan ||= source.plan_id - else - self.storage ||= 100 - self.name ||= "Cluster #{Time.utc.to_s("%F %H_%M_%S")}" - end - end - - def run - validate - cluster = if fork - @client.fork_cluster self - elsif replica - @client.replicate_cluster self - else - @client.create_cluster self - end - @output.puts %(Created cluster #{cluster.id.colorize.t_id} "#{cluster.name.colorize.t_name}") - end - - def validate - pre_validate - check_required_args do |missing| - missing << "ha" if ha.nil? - missing << "name" unless name - missing << "plan" unless plan - missing << "platform" unless platform - missing << "region" unless region - missing << "storage" unless storage - missing << "team" unless team || fork || replica - end - end - - def at=(str : String) - self.at = Time.parse_rfc3339(str).to_utc - rescue Time::Format::Error - raise_arg_error "at (not RFC3339)", str - end - - def platform=(str : String) - str = str.downcase - str = "azure" if str == "azr" - raise_arg_error "platform", str unless str == "azure" || str == "gcp" || str == "aws" - @platform = str - end -end diff --git a/src/cb/cluster_destroy.cr b/src/cb/cluster_destroy.cr deleted file mode 100644 index 3134e03..0000000 --- a/src/cb/cluster_destroy.cr +++ /dev/null @@ -1,20 +0,0 @@ -require "./action" - -class CB::ClusterDestroy < CB::APIAction - eid_setter cluster_id - - def run - c = client.get_cluster cluster_id - output << "About to " << "delete".colorize.t_warn << " cluster " << c.name.colorize.t_name - team_name = team_name_for_cluster c - output << " from team #{team_name}" if team_name - output << ".\n Type the cluster's name to confirm: " - response = input.gets - if c.name == response - client.destroy_cluster cluster_id - output.puts "Cluster #{c.id.colorize.t_id} destroyed" - else - output.puts "Response did not match, did not destroy the cluster" - end - end -end diff --git a/src/cb/cluster_info.cr b/src/cb/cluster_info.cr deleted file mode 100644 index 8567f80..0000000 --- a/src/cb/cluster_info.cr +++ /dev/null @@ -1,42 +0,0 @@ -require "./action" - -class CB::ClusterInfo < CB::APIAction - eid_setter cluster_id - - def run - c = client.get_cluster cluster_id - print_team_slash_cluster c - - details = { - "state" => c.state, - "created" => c.created_at.to_rfc3339, - "plan" => "#{c.plan_id} (#{c.memory}GiB ram, #{c.cpu}vCPU)", - "version" => c.major_version, - "storage" => "#{c.storage}GiB", - "ha" => (c.is_ha ? "on" : "off"), - "platform" => c.provider_id, - "region" => c.region_id, - } - - if source = c.source_cluster_id - details["source cluster"] = source - end - - details["network"] = c.network_id if c.network_id - - pad = (details.keys.map(&.size).max || 8) + 2 - details.each do |k, v| - output << k.rjust(pad).colorize.bold << ": " - output << v << "\n" - end - - firewall_rules = client.get_firewall_rules cluster_id - output << "firewall".rjust(pad).colorize.bold << ": " - if firewall_rules.empty? - output << "no rules\n" - else - output << "allowed cidrs".colorize.underline << "\n" - end - firewall_rules.each { |fr| output << " "*(pad + 4) << fr.rule << "\n" } - end -end diff --git a/src/cb/cluster_list.cr b/src/cb/cluster_list.cr deleted file mode 100644 index 126c219..0000000 --- a/src/cb/cluster_list.cr +++ /dev/null @@ -1,19 +0,0 @@ -require "./action" - -class CB::List < CB::APIAction - def run - teams = client.get_teams - clusters = client.get_clusters(teams) - cluster_max = clusters.map(&.name.size).max? || 0 - - clusters.each do |cluster| - output << cluster.id.colorize.t_id - output << "\t" - output << cluster.name.ljust(cluster_max).colorize.t_name - output << "\t" - team_name = teams.find { |t| t.id == cluster.team_id }.try &.name || cluster.team_id - output << team_name.colorize.t_alt - output << "\n" - end - end -end diff --git a/src/cb/cluster_rename.cr b/src/cb/cluster_rename.cr deleted file mode 100644 index b5076d2..0000000 --- a/src/cb/cluster_rename.cr +++ /dev/null @@ -1,15 +0,0 @@ -require "./action" - -class CB::ClusterRename < CB::APIAction - eid_setter cluster_id - property new_name : String? - - def run - c = client.get_cluster cluster_id - print_team_slash_cluster c - - new_c = client.update_cluster cluster_id, {"name" => new_name} - - output << "renamed to " << new_c.name.colorize.t_name << "\n" - end -end diff --git a/src/cb/cluster_upgrade.cr b/src/cb/cluster_upgrade.cr deleted file mode 100644 index d6a834a..0000000 --- a/src/cb/cluster_upgrade.cr +++ /dev/null @@ -1,78 +0,0 @@ -require "./action" - -abstract class CB::Upgrade < CB::APIAction - eid_setter cluster_id - property confirmed : Bool = false - - abstract def run - - def validate - check_required_args do |missing| - missing << "cluster" unless cluster_id - end - end -end - -# Action to start cluster upgrade. -class CB::UpgradeStart < CB::Upgrade - bool_setter ha - i32_setter postgres_version - i32_setter storage - property plan : String? - - def run - validate - - c = client.get_cluster cluster_id - - unless confirmed - output << "About to " << "upgrade".colorize.t_warn << " cluster " << c.name.colorize.t_name - output << ".\n Type the cluster's name to confirm: " - response = input.gets - - if c.name == response - self.confirmed = true - else - raise Error.new "Response did not match, did not upgrade the cluster" - end - end - - client.upgrade_cluster self - output.puts " Cluster #{c.id.colorize.t_id} upgrade started." - end -end - -# Action to cancel cluster upgrade. -class CB::UpgradeCancel < CB::Upgrade - def run - validate - - c = client.get_cluster cluster_id - print_team_slash_cluster c - - client.upgrade_cluster_cancel cluster_id - output << " upgrade cancelled\n".colorize.bold - end -end - -# Action to get the cluster upgrade status. -class CB::UpgradeStatus < CB::Upgrade - def run - validate - - c = client.get_cluster cluster_id - print_team_slash_cluster c - - operations = client.upgrade_cluster_status cluster_id - - if operations.empty? - output << " no upgrades in progress\n".colorize.bold - else - pad = (operations.map(&.flavor.size).max || 8) + 2 - operations.each do |op| - output << op.flavor.rjust(pad).colorize.bold << ": " - output << op.state << "\n" - end - end - end -end diff --git a/src/cb/cluster_uri.cr b/src/cb/cluster_uri.cr deleted file mode 100644 index 4f89a46..0000000 --- a/src/cb/cluster_uri.cr +++ /dev/null @@ -1,42 +0,0 @@ -require "./action" - -class CB::ClusterURI < CB::APIAction - eid_setter cluster_id - property role_name : String = "default" - - def run - # Ensure the role name - raise Error.new("invalid role: '#{@role_name}'") unless VALID_CLUSTER_ROLES.includes? @role_name - if @role_name == "user" - @role_name = "u_#{client.get_account.id}" - end - - # Fetch the role. - role = client.get_role(cluster_id, @role_name) - - # Redact the password from the result. Redaction is handled by coloring the - # foreground and background the same color. This benfits the user by not - # allowing their password to be inadvertently exposed in a TTY session. But - # it still allows for it to be copied and pasted without requiring any - # special action from the user. - uri = role.uri.to_s - unless role.password.nil? - pw = role.password - uri = uri.gsub(pw, pw.colorize.black.on_black.to_s) if pw - end - - output << uri - rescue e : Client::Error - msg = "unknown client error." - case - when e.bad_request? - msg = "invalid input." - when e.forbidden? - msg = "not allowed." - when e.not_found? - msg = "role '#{@role_name}' does not exist." - end - - raise Error.new msg - end -end diff --git a/src/cb/detach.cr b/src/cb/detach.cr deleted file mode 100644 index cb4a646..0000000 --- a/src/cb/detach.cr +++ /dev/null @@ -1,31 +0,0 @@ -require "./action" - -class CB::Detach < CB::APIAction - eid_setter cluster_id - property confirmed : Bool = false - - def run - validate - - c = client.get_cluster cluster_id - - unless confirmed - output << "About to " << "detach".colorize.t_warn << " cluster " << c.name.colorize.t_name - output << ".\n Type the cluster's name to confirm: " - response = input.gets - - if !(c.name == response) - raise Error.new "Response did not match, did not detach the cluster" - end - end - - client.detach_cluster cluster_id - output.puts "Cluster #{c.id.colorize.t_id} detached." - end - - def validate - check_required_args do |missing| - missing << "cluster" unless cluster_id - end - end -end diff --git a/src/cb/login.cr b/src/cb/login.cr deleted file mode 100644 index f63db4c..0000000 --- a/src/cb/login.cr +++ /dev/null @@ -1,27 +0,0 @@ -require "./action" - -class CB::Login < CB::Action - def run - host = CB::HOST - - raise CB::Program::Error.new "No valid credentials found. Please login." unless output.tty? - hint = "from https://www.crunchybridge.com/account" if host == "api.crunchybridge.com" - output.puts "add credentials for #{host.colorize.t_name} #{hint}>" - output.print " application ID: " - id = input.gets - if id.nil? || id.empty? - STDERR.puts "#{"error".colorize.red.bold}: application ID must be present" - exit 1 - end - - print " application secret: " - secret = input.noecho { input.gets } - output.print "\n" - if secret.nil? || secret.empty? - STDERR.puts "#{"error".colorize.red.bold}: application secret must be present" - exit 1 - end - - Creds.new(host, id, secret).store - end -end diff --git a/src/cb/manage_firewall.cr b/src/cb/manage_firewall.cr deleted file mode 100644 index 34e7b8e..0000000 --- a/src/cb/manage_firewall.cr +++ /dev/null @@ -1,71 +0,0 @@ -class CB::ManageFirewall < CB::APIAction - Error = Program::Error - - eid_setter cluster_id - property to_add = [] of String - property to_remove = [] of String - - def add(cidr : String) - to_add << cidr - end - - def remove(cidr : String) - to_remove << cidr - end - - def run - raise Error.new "--cluster not set" unless cluster_id - remove_all - add_all - display_rules - end - - def remove_all - return if to_remove.empty? - current_rules = @client.get_firewall_rules(cluster_id) - - to_remove.uniq.each do |cidr| - cidr_str = cidr.colorize.t_name - if rule = current_rules.find { |r| r.rule == cidr } - output << "removing #{cidr_str} … " << remove_rule(rule) << "\n" - else - output << "not removing".colorize.t_warn << " #{cidr_str} — does not exist\n" - end - end - end - - def add_all - to_add.uniq.each do |cidr| - cidr_str = cidr.colorize.t_name - output << "adding #{cidr_str} … " << add_rule(cidr) << "\n" - end - end - - def display_rules - current_rules = @client.get_firewall_rules(cluster_id) - - pad = if output.tty? - output.puts "allowed cidrs:" - output.puts " none" if current_rules.empty? - " " - else - "" - end - - current_rules.each { |r| output.puts "#{pad}#{r.rule.colorize.t_name}" } - end - - def remove_rule(rule : Client::FirewallRule) - @client.delete_firewall_rule cluster_id, rule.id - "done".colorize.t_success - rescue e : Client::Error - output.print e - end - - def add_rule(cidr : String) - @client.add_firewall_rule cluster_id, cidr - "done".colorize.t_success - rescue e : Client::Error - output.print e - end -end diff --git a/src/cb/program.cr b/src/cb/program.cr index c4ec1ae..6db1d74 100644 --- a/src/cb/program.cr +++ b/src/cb/program.cr @@ -1,5 +1,6 @@ require "./creds" require "./token" +require "./action/*" class CB::Program class Error < Exception @@ -23,7 +24,7 @@ class CB::Program if c = @creds return c end - @cred = Creds.for_host(CB::HOST) || CB::Login.new.run + @cred = Creds.for_host(CB::HOST) || Action::Login.new.run end def token : CB::Token diff --git a/src/cb/psql.cr b/src/cb/psql.cr deleted file mode 100644 index 75876cb..0000000 --- a/src/cb/psql.cr +++ /dev/null @@ -1,71 +0,0 @@ -require "./action" - -class CB::Psql < CB::APIAction - eid_setter cluster_id - property database : String? - - def run - c = client.get_cluster cluster_id - uri = client.get_role(cluster_id, "default").uri - raise Error.new "null uri" if uri.nil? - - database.tap { |db| uri.path = db if db } - - output << "connecting to " - team_name = print_team_slash_cluster c - - cert_path = ensure_cert c.team_id - psqlrc_path = build_psqlrc c, team_name - - args = ARGV.skip 1 - - Process.exec("psql", args, env: { - "PGHOST" => uri.hostname, - "PGUSER" => uri.user, - "PGPASSWORD" => uri.password, - "PGDATABASE" => uri.path.lchop('/'), - "PGPORT" => uri.port.to_s, - "PSQLRC" => psqlrc_path, - "PGSSLCERT" => "dontuse", - "PGSSLKEY" => "dontuse", - "PGSSLMODE" => "verify-ca", - "PGSSLROOTCERT" => cert_path, - }) - rescue e : File::NotFoundError - raise Error.new "The local psql command could not be found" - end - - def database=(str : String) - @database = str - end - - private def ensure_cert(team_id) : String - cert_dir = CB::Creds::CONFIG / "certs" - path = cert_dir / "#{team_id}.pem" - unless File.exists? path - Dir.mkdir_p cert_dir - File.open(path, "w", perm: 0o600) do |f| - f << client.get("teams/#{team_id}.pem").body - end - end - - path.to_s - end - - private def build_psqlrc(c, team_name) : String - psqlpromptname = String.build do |s| - s << "%[%033[32m%]#{team_name}%[%033m%]" << "/" if team_name - s << "%[%033[36m%]#{c.name}%[%033m%]" - end - - psqlrc = File.tempfile(c.id, "psqlrc") - File.copy("~/.psqlrc", psqlrc.path) if File.exists?("~/.psqlrc") - File.open(psqlrc.path, "a") do |f| - f.puts "\\set ON_ERROR_ROLLBACK interactive" - f.puts "\\set x auto" - f.puts "\\set PROMPT1 '#{psqlpromptname}/%[%033[33;1m%]%x%x%x%[%033[0m%]%[%033[1m%]%/%[%033[0m%]%R%# '" - end - - psqlrc.path.to_s - end -end diff --git a/src/cb/restart.cr b/src/cb/restart.cr deleted file mode 100644 index 8305c0c..0000000 --- a/src/cb/restart.cr +++ /dev/null @@ -1,36 +0,0 @@ -require "./action" - -class CB::Restart < CB::APIAction - eid_setter cluster_id - bool_setter confirmed - bool_setter full - - def run - validate - - c = client.get_cluster cluster_id - - unless confirmed - output << "About to " << "restart".colorize.t_warn << " cluster " << c.name.colorize.t_name - output << ".\n Type the cluster's name to confirm: " - response = input.gets - - if c.name == response - self.confirmed = true - else - raise Error.new "Response did not match, did not restart the cluster." - end - end - - service = full ? "server" : "postgres" - - client.restart_cluster cluster_id, service - output.puts "Cluster #{c.id.colorize.t_id} restarted." - end - - def validate - check_required_args do |missing| - missing << "cluster" unless cluster_id - end - end -end diff --git a/src/cb/role.cr b/src/cb/role.cr deleted file mode 100644 index 0811764..0000000 --- a/src/cb/role.cr +++ /dev/null @@ -1,76 +0,0 @@ -require "./action" - -module CB - # Valid cluster role names. - VALID_CLUSTER_ROLES = Set{"application", "default", "postgres", "user"} -end - -abstract class CB::RoleAction < CB::APIAction - eid_setter cluster_id - property role_name : String? -end - -# Action to create a cluster role for the calling user. -class CB::RoleCreate < CB::RoleAction - def validate - check_required_args do |missing| - missing << "cluster" unless cluster_id - end - end - - def run - validate - - role = client.create_role @cluster_id - output << "Role #{role.name} created on cluster #{@cluster_id}.\n" - end -end - -# Action to update a cluster role. -class CB::RoleUpdate < CB::RoleAction - bool_setter? read_only - bool_setter? rotate_password - - def validate - check_required_args do |missing| - missing << "cluster" unless cluster_id - missing << "name" unless role_name - end - end - - def run - validate - - # Ensure the role name - @role_name = "default" unless @role_name - raise Error.new("invalid role '#{@role_name}'") unless VALID_CLUSTER_ROLES.includes? @role_name - if @role_name == "user" - @role_name = "u_" + client.get_account.id - end - - flavor = read_only ? "read" : "write" unless read_only.nil? - - role = client.update_role @cluster_id, @role_name, {flavor: flavor, rotate_password: rotate_password} - - output << "Role #{role.name} updated on cluster #{@cluster_id}.\n" - end -end - -class CB::RoleDelete < CB::RoleAction - def run - check_required_args do |missing| - missing << "cluster" unless cluster_id - missing << "name" unless role_name - end - - # Ensure the role name - @role_name = "default" unless @role_name - raise Error.new("invalid role '#{@role_name}'") unless VALID_CLUSTER_ROLES.includes? @role_name - if @role_name == "user" - @role_name = "u_" + client.get_account.id - end - - role = client.delete_role @cluster_id, @role_name - output << "Role #{role.name} deleted from cluster #{@cluster_id}.\n" - end -end diff --git a/src/cb/scope.cr b/src/cb/scope.cr deleted file mode 100644 index bb0a835..0000000 --- a/src/cb/scope.cr +++ /dev/null @@ -1,45 +0,0 @@ -require "./action" -require "./scope_checks/*" - -class CB::Scope < CB::APIAction - property cluster_id : String? - property checks : Array(::Scope::Check.class) = [] of ::Scope::Check.class - property database : String? - property suite : String? - - def run - check_required_args { |missing| missing << "cluster" unless cluster_id } - - uri = client.get_role(cluster_id, "default").uri - raise Error.new "null uri" if uri.nil? - - # Accept only SCRAM with Channel Binding. - # - # https://github.com/will/crystal-pg#authentication-methods - uri.query = "auth_methods=scram-sha-256-plus" - - if database.presence - uri.path = database.to_s - end - - self.suite = "quick" if checks.empty? && suite.nil? - - case suite - when "all" - self.checks = ::Scope::Check.all.map(&.type) - [::Scope::Mandelbrot] - when "quick" - self.checks += [::Scope::TableInfo, ::Scope::IndexHit, ::Scope::Blocking] - when nil - else - raise Error.new("unknown suite '#{suite.inspect}'") - end - - to_run = checks.uniq.sort_by!(&.name) - - DB.open(uri) do |db| - to_run.map(&.new(db)).each do |c| - @output << c << "\n" - end - end - end -end diff --git a/src/cb/team.cr b/src/cb/team.cr deleted file mode 100644 index 781ec0a..0000000 --- a/src/cb/team.cr +++ /dev/null @@ -1,108 +0,0 @@ -require "./action" - -abstract class CB::TeamAction < CB::APIAction - eid_setter team_id - - private def team_details(t : CB::Client::Team) : String - String.build do |str| - str << "ID: \t" << t.id.colorize.t_id << "\n" - str << "Name: \t" << t.name.colorize.t_name << "\n" - str << "Role: \t" << t.role.to_s.titleize << "\n" - str << "Billing Email:\t" << t.billing_email << "\n" - str << "Enforce SSO: \t" << (t.enforce_sso.nil? ? "disabled" : t.enforce_sso) - end - end -end - -class CB::TeamCreate < CB::TeamAction - property name : String = "" - - def run - check_required_args do |missing| - missing << "name" if name.empty? - end - - team = client.create_team name - output << "Created team #{team}\n" - end -end - -class CB::TeamList < CB::TeamAction - def run - teams = client.get_teams - name_max = teams.map(&.name.size).max? || 0 - - teams.each do |team| - output << team.id.colorize.t_id << "\t" - output << team.name.ljust(name_max).colorize.t_name << "\t" - output << team.role.to_s.titleize << "\n" - end - end -end - -class CB::TeamInfo < CB::TeamAction - def run - team = client.get_team team_id - output << team_details(team) << "\n" - end -end - -class CB::TeamUpdate < CB::TeamAction - ident_setter name - - # TODO: (abrightwell) - would be really nice to have some validation on this - # property. I briefly looked into implementing a macro for it, but it an email - # regex is non-trivial. Need to determine what would be 'good enough' for our - # purposes here. So I'm going to come back to this one in a future update. For - # now, we'll rely on the API validation of the field. - property billing_email : String? - bool_setter? enforce_sso - bool_setter confirmed - - def run - check_required_args do |missing| - missing << "team" unless team_id - end - - unless confirmed - t = client.get_team team_id - - output.printf "About to %s team %s.\n", "update".colorize.t_warn, t.name.colorize.t_name - output.printf " Type the team's name to confirm: " - response = input.gets - - raise Error.new "Response did not match, did not update the team" unless response == t.name - end - - team = client.update_team team_id, { - "billing_email" => billing_email, - "enforce_sso" => enforce_sso, - "name" => name, - } - - output << team_details(team) << "\n" - end -end - -class CB::TeamDestroy < CB::TeamAction - bool_setter confirmed - - def run - check_required_args do |missing| - missing << "team" unless team_id - end - - unless confirmed - t = client.get_team team_id - - output.printf "About to %s team %s.\n", "delete".colorize.t_warn, t.name.colorize.t_name - output.printf " Type the team's name to confirm: " - response = input.gets - - raise Error.new "Response did not match, did not delete the team." unless response == t.name - end - - team = client.destroy_team team_id - output << "Deleted team #{team}\n" - end -end diff --git a/src/cb/team_cert.cr b/src/cb/team_cert.cr deleted file mode 100644 index 9cf5099..0000000 --- a/src/cb/team_cert.cr +++ /dev/null @@ -1,16 +0,0 @@ -require "./action" - -class CB::TeamCert < CB::APIAction - eid_setter team_id - - def run - cert = client.get("teams/#{team_id}.pem").body - output.puts cert - rescue e : Client::Error - if e.not_found? - STDERR << "error".colorize.t_warn << ": No public cert found.\n" - else - raise e - end - end -end diff --git a/src/cb/team_member.cr b/src/cb/team_member.cr deleted file mode 100644 index b967b03..0000000 --- a/src/cb/team_member.cr +++ /dev/null @@ -1,182 +0,0 @@ -require "./action" - -module CB - TEAM_ROLE_MEMBER = "member" - TEAM_ROLE_MANAGER = "manager" - TEAM_ROLE_ADMIN = "admin" - - # Valid team member roles. - VALID_TEAM_ROLES = Set{ - TEAM_ROLE_ADMIN, - TEAM_ROLE_MANAGER, - TEAM_ROLE_MEMBER, - } -end - -# Superclass for all TeamMember related `Action`s}. -# -# Provides common properties and functionality specific to team member managment -# actions. -abstract class CB::TeamMemberAction < CB::APIAction - eid_setter team_id - eid_setter account_id - property email : String? - - private def validate_account_email - check_required_args do |missing| - missing << "team" unless team_id - missing << "account" unless account_id || email - end - - raise Error.new "Must only use '--account' or '--email' but not both." if account_id && email - end - - private def get_member_by_email(team_id, email) - members = client.list_team_members(team_id) - members.find { |tm| tm.email == email } - end - - private def team_member_details(tm : CB::Client::TeamMember) : String - String.build do |str| - str << "Email: \t" << tm.email.colorize.t_name << '\n' - str << "Team ID: \t" << tm.team_id.colorize.t_id << '\n' - str << "Account ID:\t" << tm.account_id.colorize.t_id << '\n' - str << "Role: \t" << tm.role.titleize - end - end -end - -# Action to add an account to a team as a member. -class CB::TeamMemberAdd < CB::TeamMemberAction - property role : String = TEAM_ROLE_MEMBER - - def validate - valid = check_required_args do |missing| - missing << "team" unless team_id - missing << "email" unless email - missing << "role" if role.empty? - end - - raise Error.new("invalid role '#{@role}'") unless VALID_TEAM_ROLES.includes? @role - - valid - end - - def run - validate - - team_member = client.create_team_member( - @team_id, - Client::TeamMemberCreateParams.new(email.to_s, role), - ) - - output << "Added " << team_member.email.colorize.green - output << " to team " << team_member.team_id.colorize.t_id - output << " as role '" << team_member.role.colorize.t_name - output << "'.\n" - end -end - -# Action to list the current members of a team. -class CB::TeamMemberList < CB::TeamMemberAction - def validate - check_required_args do |missing| - missing << "team" unless team_id - end - end - - def run - validate - - team_members = client.list_team_members(team_id) - email_max = team_members.map(&.email.size).max? || 0 - - # Only personal teams can be without members. So, it should be safe to - # assume that if the request returns an empty list that it is 'personal' - # team. However, not wanting umptions to shoulder this one alone in the - # case that we are wrong, we'll just simply respond that the requested team - # doesn't have any members. Making no claims to it being personal or - # otherwise. Which should be good enough in all cases, however unlikely - # they might be. - if team_members.empty? - output << "Team #{team_id.colorize.t_id} has no team members.\n" - else - team_members.each do |member| - output << member.account_id.colorize.t_id << '\t' - output << member.email.ljust(email_max).colorize.t_name << '\t' - output << member.role.titleize << '\n' - end - end - end -end - -# Action to show information detail about a specific team member. -# -# Allows for both account ID and email of the team member to be provided, -# however, the account ID will take precedence over the email. Therefore, if -# supplying both will raise a validation error. -class CB::TeamMemberInfo < CB::TeamMemberAction - def run - validate_account_email - - tm = if @account_id.nil? - get_member_by_email(team_id, email) - else - client.get_team_member(team_id, @account_id) - end - - if tm.nil? - # TODO (abrightwell): move this to an error above, similar to update. - output << "Unknown team member.\n" - else - output << team_member_details(tm) << '\n' - end - end -end - -# Action to update a team member. -# -# Allows for both account ID and email of the team member to be provided, -# however, the account ID will take precedence over the email. Therefore, if -# supplying both will raise a validation error. -class CB::TeamMemberUpdate < CB::TeamMemberAction - setter role : String? - - def run - validate_account_email - - unless @role.nil? - raise Error.new("invalid role '#{@role}'") unless VALID_TEAM_ROLES.includes? @role - end - - if account_id.nil? - tm = get_member_by_email(team_id, @email) if account_id.nil? - raise Error.new "Unknown team member '#{@email}'." if tm.nil? - @account_id = tm.account_id - end - - updated = client.update_team_member(team_id, @account_id, @role) - output << team_member_details(updated) << '\n' unless updated.nil? - end -end - -# Action to remove a user as member from a team. -# -# Allows for both account ID and email of the team member to be provided, -# however, the account ID will take precedence over the email. Therefore, if -# supplying both will raise a validation error. -class CB::TeamMemberRemove < CB::TeamMemberAction - def run - validate_account_email - - if account_id.nil? - tm = get_member_by_email(team_id, @email) if account_id.nil? - raise Error.new "Unknown team member '#{@email}'." if tm.nil? - @account_id = tm.account_id - end - - removed = client.remove_team_member(team_id, account_id) - team = client.get_team(team_id) - output << "Removed #{removed.email.colorize.t_name} from team #{team}.\n" unless removed.nil? - end -end diff --git a/src/cb/token.cr b/src/cb/token.cr index dc83fdc..c37b9f9 100644 --- a/src/cb/token.cr +++ b/src/cb/token.cr @@ -1,31 +1,5 @@ require "./cacheable" -# TODO (abrightwell): We had to explicitly qualify this class name as an -# `Action` due to conflicts with the below `Token` struct. Would be great to -# potentially namespace actions under `CB::Action` or something. Something -# perhaps worth considering. -class CB::TokenAction < CB::Action - enum Format - Default - Header - end - - property token : Token - property format : Format = Format::Default - - def initialize(@token, @input, @output) - end - - def run - case @format - when "header" - output << "Authorization: Bearer #{token.token}" - when "default" - output << token.token - end - end -end - struct CB::Token Cacheable.include key: host getter host : String diff --git a/src/cb/whoami.cr b/src/cb/whoami.cr deleted file mode 100644 index bd78ee7..0000000 --- a/src/cb/whoami.cr +++ /dev/null @@ -1,9 +0,0 @@ -require "./action" - -class CB::WhoAmI < CB::APIAction - def run - output << "user id: ".colorize.t_id << client.token.user_id << "\n" - output << " name: ".colorize.t_id << client.token.name << "\n" - output << " host: ".colorize.t_id << client.host << "\n" - end -end diff --git a/src/cli.cr b/src/cli.cr index a675040..a11cbb3 100755 --- a/src/cli.cr +++ b/src/cli.cr @@ -1,5 +1,6 @@ #!/usr/bin/env crystal require "./cb" +require "./cb/action" require "./ext/option_parser" require "raven" @@ -11,7 +12,7 @@ end PROG = CB::Program.new macro set_action(cl) - action = CB::{{cl}}.new PROG.client, PROG.input, PROG.output + action = CB::Action::{{cl}}.new PROG.client, PROG.input, PROG.output end def show_deprecated(msg : String) @@ -52,7 +53,7 @@ op = OptionParser.new do |parser| parser.on("login", "Store API key") do parser.banner = "cb login" - action = CB::Login.new + action = CB::Action::Login.new end parser.on("list", "List clusters") do @@ -440,9 +441,9 @@ op = OptionParser.new do |parser| parser.on("token", "Return a bearer token for use in the api") do parser.banner = "cb token [-H]" - token = action = CB::TokenAction.new PROG.token, PROG.input, PROG.output + token = action = CB::Action::Token.new PROG.token, PROG.input, PROG.output - parser.on("-H", "Authorization header format") { token.format = CB::TokenAction::Format::Header } + parser.on("-H", "Authorization header format") { token.format = CB::Action::Token::Format::Header } end parser.on("version", "Show the version") do