ecsutil 0.1.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.
@@ -0,0 +1,24 @@
1
+ require "ecsutil/helpers"
2
+ require "ecsutil/aws"
3
+ require "ecsutil/vault"
4
+ require "ecsutil/shared"
5
+
6
+ module ECSUtil
7
+ module Commands
8
+ end
9
+
10
+ class Command
11
+ include ECSUtil::Helpers
12
+ include ECSUtil::AWS
13
+ include ECSUtil::Vault
14
+ include ECSUtil::Shared
15
+
16
+ attr_reader :config, :action, :args
17
+
18
+ def initialize(config, action = nil, args = [])
19
+ @config = config
20
+ @action = action
21
+ @args = args
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,70 @@
1
+ class ECSUtil::Commands::DeployCommand < ECSUtil::Command
2
+ def run
3
+ confirm
4
+
5
+ load_active_task_definitions
6
+ load_secrets
7
+ load_services
8
+
9
+ register_tasks
10
+ register_scheduled_tasks
11
+ register_services
12
+
13
+ deregister_tasks
14
+ deregister_scheduled_tasks
15
+ deregister_services
16
+ end
17
+
18
+ protected
19
+
20
+ def register_tasks
21
+ config["tasks"].each_pair do |name, task_config|
22
+ step_info "Registering task definition: #{name}"
23
+ task_def = generate_task_definition(config, name)
24
+ result = register_task_definition(task_def)
25
+ arn = result["taskDefinitionArn"]
26
+
27
+ config["tasks"][name]["arn"] = arn
28
+ step_info "Registered #{name}: #{arn}"
29
+ end
30
+ end
31
+
32
+ def register_scheduled_tasks
33
+ config["scheduled_tasks"].each_pair do |name, schedule|
34
+ step_info "Creating event rule for #{name}"
35
+
36
+ task = config["tasks"][schedule["task"]]
37
+ rule_name = sprintf("%s-%s-%s", config["app"], config["env"], name)
38
+ rule_exp = schedule["expression"]
39
+
40
+ rule_data = generate_event_rule(
41
+ name: rule_name,
42
+ expression: rule_exp,
43
+ enabled: schedule.key?("enabled") ? schedule["enabled"] == true : true
44
+ )
45
+
46
+ result = put_rule(rule_data)
47
+ config["scheduled_tasks"][name]["rule_name"] = rule_name
48
+ config["scheduled_tasks"][name]["rule_arn"] = result["RuleArn"]
49
+
50
+ step_info "Creating event target for #{name}"
51
+ rule_targets = generate_event_target(config, schedule["task"], name)
52
+ put_targets(rule_targets)
53
+ end
54
+
55
+ def register_services
56
+ config["services"].each_pair do |service_name, service|
57
+ full_name = sprintf("%s-%s-%s", config["app"], config["env"], service_name)
58
+ service["exists"] = @existing_services.include?(full_name)
59
+
60
+ if service["exists"]
61
+ step_info "Updating service #{service_name}"
62
+ update_service(config, service_name)
63
+ else
64
+ step_info "Creating service #{service_name}"
65
+ create_service(config, service_name)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,19 @@
1
+ class ECSUtil::Commands::DestroyCommand < ECSUtil::Command
2
+ def run
3
+ confirm "DANGER: You are about to deprovision all tasks and services! Proceed?"
4
+ confirm "Are you absolutely sure?"
5
+
6
+ config["tasks"] = {}
7
+ config["scheduled_tasks"] = {}
8
+ config["services"] = {}
9
+
10
+ load_active_task_definitions
11
+ load_secrets
12
+ load_services
13
+
14
+ deregister_tasks
15
+ deregister_scheduled_tasks
16
+ deregister_services
17
+ deregister_secrets
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ class ECSUtil::Commands::HelpCommand < ECSUtil::Command
2
+ def run
3
+ puts [
4
+ "Usage: escutil <stage> <command>",
5
+ "Available commands:",
6
+ "* deploy - Perform a deployment",
7
+ "* run - Run a task",
8
+ "* scale - Change service quantities",
9
+ "* status - Show current status",
10
+ "* secrets - Manage secrets",
11
+ "* destroy - Delete all cloud resources"
12
+ ].join("\n")
13
+ end
14
+ end
@@ -0,0 +1,94 @@
1
+ require "securerandom"
2
+
3
+ class ECSUtil::Commands::InitCommand < ECSUtil::Command
4
+ def run
5
+ check_dependencies
6
+ init_password_file
7
+ check_password_contents
8
+ check_gitignore
9
+ init_secrets
10
+ init_terraform
11
+ end
12
+
13
+ private
14
+
15
+ def password_path
16
+ @password_path ||= config.secrets_vaultpass
17
+ end
18
+
19
+ def gitignore_path
20
+ @gitignore_path ||= File.join(Dir.pwd, ".gitignore")
21
+ end
22
+
23
+ def secrets_path
24
+ @secrets_path ||= File.join(Dir.pwd, "deploy", config.stage, "secrets")
25
+ end
26
+
27
+ def terraform_path
28
+ @terraform_path ||= File.join(Dir.pwd, "terraform", config.stage)
29
+ end
30
+
31
+ def check_dependencies
32
+ step_info "Checking AWS CLI"
33
+ if `which aws`.strip.empty?
34
+ terminate("AWS CLI is not installed")
35
+ end
36
+ end
37
+
38
+ def init_password_file
39
+ if !password_path || password_path && !File.exists?(password_path)
40
+ create_password_file
41
+ end
42
+ end
43
+
44
+ def create_password_file
45
+ step_info "Vault password file not found at #{password_path}, creating..."
46
+ File.write(password_path, SecureRandom.hex(20))
47
+ end
48
+
49
+ def check_password_contents
50
+ if File.read(password_path).strip.empty?
51
+ terminate "Your vault password file is empty!"
52
+ end
53
+ end
54
+
55
+ def check_gitignore
56
+ unless File.exists?(gitignore_path)
57
+ step_info "Creating .gitignore file"
58
+ FileUtils.touch(gitignore_path)
59
+ end
60
+
61
+ data = File.read(gitignore_path)
62
+ return if data.include?("vaultpass")
63
+
64
+ step_info "Adding vaultpass to .gitignore"
65
+ data += "\nvaultpass"
66
+ File.write(gitignore_path, data.strip + "\n")
67
+ end
68
+
69
+ def init_secrets
70
+ step_info "Setting up secrets file at #{secrets_path}"
71
+
72
+ FileUtils.mkdir_p(File.dirname(secrets_path))
73
+
74
+ if File.exists?(secrets_path)
75
+ step_info "Secrets file already exists, skipping..."
76
+ return
77
+ end
78
+
79
+ vault_write(secrets_path, config.secrets_vaultpass, "# This is your secrets file")
80
+ end
81
+
82
+ def init_terraform
83
+ step_info "Checking if Terraform is installed"
84
+ if `which terraform`.strip.empty?
85
+ step_info "Terraform is not found, skipping..."
86
+ return
87
+ end
88
+
89
+ unless File.exists?(terraform_path)
90
+ step_info "Setting up Terraform directory at #{terraform_path}"
91
+ FileUtils.mkdir_p(terraform_path)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,47 @@
1
+ class ECSUtil::Commands::RunCommand < ECSUtil::Command
2
+ def run
3
+ task_name = args.shift
4
+ terminate("Task name is required") unless task_name
5
+
6
+ task = config["tasks"][task_name]
7
+ family = sprintf("%s-%s-%s", config["app"], config["env"], task_name)
8
+
9
+ arn = load_active_task_definitions.find do |task_arn|
10
+ task_arn.split("/", 2).last.start_with?(family)
11
+ end
12
+
13
+ if !arn
14
+ terminate "Cant find task definition for #{family}"
15
+ end
16
+
17
+ opts = {
18
+ startedBy: config["user"],
19
+ cluster: config["cluster"],
20
+ taskDefinition: arn,
21
+ launchType: "FARGATE",
22
+ networkConfiguration: {
23
+ awsvpcConfiguration: {
24
+ subnets: [config["subnets"]].flatten,
25
+ securityGroups: [task["security_groups"]].flatten,
26
+ "assignPublicIp": "ENABLED"
27
+ }
28
+ }
29
+ }
30
+
31
+ if args && args.any?
32
+ step_info "Using override command: #{args}"
33
+
34
+ opts[:overrides] = {
35
+ containerOverrides: [
36
+ {
37
+ name: task_name,
38
+ command: args,
39
+ }
40
+ ]
41
+ }
42
+ end
43
+
44
+ step_info "Running task using #{arn}"
45
+ aws_call("ecs", "run-task", opts)
46
+ end
47
+ end
@@ -0,0 +1,24 @@
1
+ class ECSUtil::Commands::ScaleCommand < ECSUtil::Command
2
+ def run
3
+ services = config["services"] || {}
4
+ terminate("No services found") if services.empty?
5
+
6
+ services.each_pair do |service_name, service|
7
+ info = describe_service(config, service_name)
8
+ service["exists"] = true
9
+ config["tasks"][service["task"]]["arn"] = info["taskDefinition"]
10
+
11
+ if info["runningCount"] != service["desired_count"]
12
+ step_info "Scaling #{service_name} from %d to %d",
13
+ info["runningCount"],
14
+ service["desired_count"]
15
+
16
+ update_service(config, service_name)
17
+ else
18
+ step_info "Scaling is skipped on #{service_name}. Requested: %d, actual %d",
19
+ service["desired_count"],
20
+ info["runningCount"]
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,91 @@
1
+ class ECSUtil::Commands::SecretsCommand < ECSUtil::Command
2
+ def run
3
+ case action
4
+ when nil, "show"
5
+ show_local_secrets
6
+ when "edit"
7
+ edit_secrets
8
+ when "push"
9
+ push_secrets
10
+ when "live"
11
+ load_secrets
12
+ show_live_secrets
13
+ when "delete"
14
+ confirm
15
+ load_secrets
16
+ deregister_secrets
17
+ else
18
+ fail "Invalid action: #{action}"
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def load_local_secrets
25
+ vault_read(config["secrets_file"], config["secrets_vaultpass"])
26
+ end
27
+
28
+ def show_local_secrets
29
+ step_info "Loading secrets from %s", config["secrets_file"]
30
+ puts load_local_secrets
31
+ end
32
+
33
+ def show_live_secrets
34
+ if config["secrets_data"].empty?
35
+ puts "No secrets found for prefix #{config["secrets_prefix"]}"
36
+ return
37
+ end
38
+
39
+ config["secrets_data"].each do |secret|
40
+ printf("%s=%s\n", secret[:key], secret[:value])
41
+ end
42
+ end
43
+
44
+ def edit_secrets
45
+ step_info "Editing secrets file %s", config["secrets_file"]
46
+ vault_edit(config["secrets_file"], config["secrets_vaultpass"])
47
+ end
48
+
49
+ def push_secrets
50
+ confirm("Will push secrets live to #{config["secrets_prefix"]}")
51
+ load_secrets
52
+
53
+ local = parse_env_data(load_local_secrets)
54
+ live = config["secrets_data"].map { |item| [item[:key], item[:value]] }.to_h
55
+
56
+ added_count = 0
57
+ skipped_count = 0
58
+ removed_count = 0
59
+
60
+ local.each_pair do |key, value|
61
+ if live[key] == value
62
+ step_info "Skipping #{key}, already set"
63
+ skipped_count += 1
64
+ next
65
+ end
66
+
67
+ step_info "Setting #{key} to #{value}"
68
+ aws_call("ssm", "put-parameter", {
69
+ Type: "SecureString",
70
+ Name: sprintf("%s/%s", config["secrets_prefix"], key),
71
+ Value: value,
72
+ KeyId: config["secrets_key"],
73
+ Overwrite: true
74
+ })
75
+ added_count += 1
76
+ end
77
+
78
+ config["secrets_data"].each do |secret|
79
+ if !local[secret[:key]]
80
+ step_info "Removing #{secret[:key]}"
81
+ aws_call("ssm", "delete-parameter", "--name=#{secret[:name]}")
82
+ removed_count += 1
83
+ end
84
+ end
85
+
86
+ step_info "Skipped: %d, Added: %d, Removed: %d\n",
87
+ skipped_count,
88
+ added_count,
89
+ removed_count
90
+ end
91
+ end
@@ -0,0 +1,41 @@
1
+ class ECSUtil::Commands::StatusCommand < ECSUtil::Command
2
+ def run
3
+ step_info "Fetching active task definitions..."
4
+ active_task_definitions.each do |name|
5
+ puts name
6
+ end
7
+
8
+ step_info "Fetching services..."
9
+ fetch_active_services.each do |service|
10
+ deployment = service["deployments"].first || {}
11
+
12
+ printf(
13
+ "%s STATUS=%s DESIRED=%d PENDING=%d RUNNING=%d\n",
14
+ service["serviceName"],
15
+ service["status"],
16
+ deployment["desiredCount"],
17
+ deployment["pendingCount"],
18
+ deployment["runningCount"]
19
+ )
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def active_task_definitions
26
+ list_active_task_definitions.select do |arn|
27
+ arn.include?(config.namespace)
28
+ end
29
+ end
30
+
31
+ def active_services
32
+ list_services(config.cluster).select do |name|
33
+ name.include?(config.namespace)
34
+ end
35
+ end
36
+
37
+ def fetch_active_services
38
+ names = active_services
39
+ names.any? ? describe_services(config, names) : []
40
+ end
41
+ end
@@ -0,0 +1,60 @@
1
+ require "yaml"
2
+ require "hashie"
3
+
4
+ module ECSUtil
5
+ class Config < Hashie::Mash
6
+ # Do not show any Hashie warnings
7
+ disable_warnings
8
+
9
+ def self.read(path, stage, outputs = {})
10
+ data = File.read(path).gsub(/(\$tf.([\w]+))+/i) do |m|
11
+ key = $2
12
+ fail "Terraform output key #{key} not found!" unless outputs[key]
13
+ outputs[key]
14
+ end
15
+
16
+ result = YAML.load(data).tap do |config|
17
+ fail "App name is required" unless config["app"]
18
+ fail "App env is required" unless config["env"]
19
+ fail "Cluster is required" unless config["cluster"]
20
+ fail "Repository is required" unless config["repository"]
21
+
22
+ # Check AWS configuration
23
+ if !config["aws_profile"] && !ENV["AWS_PROFILE"]
24
+ fail "AWS profile is not set! Set 'aws_profile' var in config or use AWS_PROFILE env var!"
25
+ end
26
+
27
+ # Override environment variable in case if it was set
28
+ ENV["AWS_PROFILE"] = config["aws_profile"]
29
+
30
+ # Set stage and namespace
31
+ config["user"] ||= `whoami`.strip
32
+ config["stage"] ||= stage
33
+ config["namespace"] ||= sprintf("%s-%s", config["app"], config["env"])
34
+
35
+ # Set default sections
36
+ config["tasks"] ||= {}
37
+ config["scheduled_tasks"] ||= {}
38
+ config["services"] ||= {}
39
+
40
+ # Parent dir
41
+ parent_dir = File.expand_path(File.join(File.dirname(path), ".."))
42
+
43
+ # Set secrets
44
+ config.merge!(
45
+ "secrets_vaultpass" => File.join(parent_dir, "vaultpass"),
46
+ "secrets_file" => File.join(File.dirname(path), "#{stage}/secrets"),
47
+ "secrets_key" => outputs["kms_key"],
48
+ "secrets_prefix" => sprintf("/%s", config["namespace"]),
49
+ "secrets_data" => {}
50
+ )
51
+
52
+ # Set default vars
53
+ config["git_commit"] ||= `git rev-parse HEAD`.strip
54
+ config["git_branch"] ||= `git rev-parse --abbrev-ref HEAD`.strip
55
+ end
56
+
57
+ Config.new(result)
58
+ end
59
+ end
60
+ end