chef-tlc-workflow 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +18 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +93 -0
- data/Rakefile +110 -0
- data/Vagrantfile +33 -0
- data/chef-tlc-workflow.gemspec +52 -0
- data/docs/ApplicationVsLibraryVsForkedCookbooks.md +20 -0
- data/docs/TmpLibrarianHelpers.md +21 -0
- data/docs/TmpWorkflowTasks.md +80 -0
- data/lib/chef-tlc-workflow.rb +5 -0
- data/lib/chef-tlc-workflow/helpers.rb +126 -0
- data/lib/chef-tlc-workflow/version.rb +3 -0
- data/lib/chef-workflow/tasks/tlc.rb +4 -0
- data/lib/chef-workflow/tasks/tlc/deps.rb +100 -0
- data/lib/chef-workflow/tasks/tlc/test.rb +15 -0
- data/test/Gemfile +7 -0
- data/test/ec2-bootstrap/.chef/bootstrap/omnibus-chef.sh +34 -0
- data/test/ec2-bootstrap/.gitignore +3 -0
- data/test/ec2-bootstrap/Mccloudfile +60 -0
- data/test/ec2-bootstrap/Rakefile +57 -0
- data/test/ec2-bootstrap/app_cookbooks.yml +12 -0
- data/test/esx-bootstrap/.chef/knife.rb +7 -0
- data/test/esx-bootstrap/.gitignore +4 -0
- data/test/esx-bootstrap/Rakefile +64 -0
- data/test/esx-bootstrap/app_cookbooks.yml +12 -0
- data/test/esx-bootstrap/nodes/33.33.77.10.json +6 -0
- data/test/local-bootstrap/.gitignore +4 -0
- data/test/local-bootstrap/Rakefile +50 -0
- data/test/local-bootstrap/Vagrantfile +31 -0
- data/test/local-bootstrap/app_cookbooks.yml +12 -0
- data/test/sample-app/CHANGELOG.md +12 -0
- data/test/sample-app/Cheffile +15 -0
- data/test/sample-app/README.md +20 -0
- data/test/sample-app/attributes/default.rb +2 -0
- data/test/sample-app/metadata.rb +10 -0
- data/test/sample-app/recipes/default.rb +27 -0
- data/test/sample-app/templates/default/sample.html.erb +10 -0
- metadata +331 -0
@@ -0,0 +1,126 @@
|
|
1
|
+
module ChefTLCWorkflow
|
2
|
+
|
3
|
+
module Helpers
|
4
|
+
|
5
|
+
#
|
6
|
+
# reads the direct dependencies defined in `metadata.rb` and
|
7
|
+
# returns a map of dependency => version, e.g.:
|
8
|
+
#
|
9
|
+
# { 'foo' => '1.0.0', 'bar' => '0.1.0' }
|
10
|
+
#
|
11
|
+
def self.read_metadata_deps
|
12
|
+
require 'chef/cookbook/metadata'
|
13
|
+
metadata = ::Chef::Cookbook::Metadata.new
|
14
|
+
metadata.from_file "metadata.rb"
|
15
|
+
metadata.dependencies
|
16
|
+
end
|
17
|
+
|
18
|
+
#
|
19
|
+
# reads the direct dependencies defined in `Cheffile` and
|
20
|
+
# returns a map of dependency => version, e.g.:
|
21
|
+
#
|
22
|
+
# { 'foo' => '1.0.0', 'bar' => '0.1.0' }
|
23
|
+
#
|
24
|
+
def self.read_cheffile_deps
|
25
|
+
require 'librarian/chef/environment'
|
26
|
+
env = ::Librarian::Chef::Environment.new
|
27
|
+
deps = env.spec.dependencies
|
28
|
+
Hash[deps.map { |dep| [dep.name, dep.requirement.to_s] }]
|
29
|
+
end
|
30
|
+
|
31
|
+
#
|
32
|
+
# resolves the dependencies as specified in `Cheffile` and
|
33
|
+
# returns a map of dependency => version (including the
|
34
|
+
# transitive ones), e.g.:
|
35
|
+
#
|
36
|
+
# { 'foo' => '1.0.0', 'bar' => '0.1.0', 'baz_which_depends_on_bar' => '1.5.0' }
|
37
|
+
#
|
38
|
+
def self.read_and_resolve_cheffile_deps
|
39
|
+
require 'librarian/chef/environment'
|
40
|
+
env = ::Librarian::Chef::Environment.new
|
41
|
+
deps = env.resolver.resolve(env.spec).manifests
|
42
|
+
Hash[deps.map { |dep| [dep.name, dep.version.to_s] }]
|
43
|
+
end
|
44
|
+
|
45
|
+
#
|
46
|
+
# returns the direct dependencies defined in `metadata.rb` as an
|
47
|
+
# array of triples:
|
48
|
+
#
|
49
|
+
# [[<cookbook_name>, <cookbook_version>, <location>], ...]
|
50
|
+
#
|
51
|
+
# where `<location>` is `nil`, unless the `locations_yml`
|
52
|
+
# parameter is given and the specified file contains a location
|
53
|
+
# mapping for `<cookbook_name>`
|
54
|
+
#
|
55
|
+
# Example usage in `Cheffile`:
|
56
|
+
#
|
57
|
+
# require 'chef-tlc-workflow/helpers'
|
58
|
+
#
|
59
|
+
# ChefTLCWorkflow::Helpers::from_metadata.each do |cb_name, cb_version, location|
|
60
|
+
# cookbook cb_name, cb_version, location
|
61
|
+
# end
|
62
|
+
#
|
63
|
+
def self.from_metadata(locations_yml = nil)
|
64
|
+
read_metadata_deps.to_a.map do |cb_name, cb_version|
|
65
|
+
if locations_yml
|
66
|
+
inferred_location = resolve_location_from_file(locations_yml, cb_name, cb_version)
|
67
|
+
else
|
68
|
+
inferred_location = nil
|
69
|
+
end
|
70
|
+
[cb_name, cb_version, inferred_location]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
#
|
75
|
+
# reads in the YAML file containing the application cookbooks and
|
76
|
+
# returns either all application cookbooks defined there or a specific
|
77
|
+
# ones if the `name` and/or `version` parameters are given to filter
|
78
|
+
# the selection.
|
79
|
+
#
|
80
|
+
# Example .yml file:
|
81
|
+
#
|
82
|
+
# - name: "sample-app"
|
83
|
+
# version: "0.1.0"
|
84
|
+
# git: "https://github.com/tknerr/sample-app-tlc.git"
|
85
|
+
# ref: "v0.1.0"
|
86
|
+
# - name: "sample-app"
|
87
|
+
# version: "0.2.0"
|
88
|
+
# path: "../sample-app"
|
89
|
+
#
|
90
|
+
#
|
91
|
+
def self.read_app_cookbooks(yml_file, name = nil, version = nil)
|
92
|
+
# TODO: validate format
|
93
|
+
app_cookbooks = YAML.load_file yml_file
|
94
|
+
app_cookbooks.select! { |ac| ac['name'] == name } if name
|
95
|
+
app_cookbooks.select! { |ac| ac['version'] == version } if version
|
96
|
+
app_cookbooks
|
97
|
+
end
|
98
|
+
|
99
|
+
#
|
100
|
+
# parse input string "<app-cookbook-name>@<version>" into
|
101
|
+
# a `[name, version]` pair by splitting at the '@' character
|
102
|
+
#
|
103
|
+
def self.parse_name_and_version(string)
|
104
|
+
if string == nil
|
105
|
+
[nil, nil]
|
106
|
+
else
|
107
|
+
string.split '@'
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def self.resolve_location_from_file(locations_yml, cb_name, cb_version)
|
114
|
+
cookbook_index = YAML.load_file(locations_yml)
|
115
|
+
if cookbook_index[cb_name]
|
116
|
+
default_opts = Hash.new
|
117
|
+
default_opts[:git] = cookbook_index[cb_name][:git] if cookbook_index[cb_name][:git]
|
118
|
+
default_opts[:ref] = cookbook_index[cb_name][:ref] if cookbook_index[cb_name][:ref]
|
119
|
+
version_specific_opts = cookbook_index[cb_name][cb_version] || {}
|
120
|
+
return default_opts.merge version_specific_opts
|
121
|
+
else
|
122
|
+
return nil
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
|
2
|
+
namespace :tlc do
|
3
|
+
namespace :deps do
|
4
|
+
|
5
|
+
require 'chef-tlc-workflow/helpers'
|
6
|
+
|
7
|
+
#
|
8
|
+
# resolve dependencies using librarian
|
9
|
+
#
|
10
|
+
task :resolve do
|
11
|
+
require 'fileutils'
|
12
|
+
FileUtils.rm_rf('Cheffile.lock')
|
13
|
+
sh "librarian-chef clean"
|
14
|
+
sh "librarian-chef install"
|
15
|
+
end
|
16
|
+
|
17
|
+
#
|
18
|
+
# check if dependencies in metadata.rb and Cheffile are consistent
|
19
|
+
#
|
20
|
+
task :check do
|
21
|
+
errors = []
|
22
|
+
metadata_deps = ChefTLCWorkflow::Helpers::read_metadata_deps
|
23
|
+
resolved_cheffile_deps = ChefTLCWorkflow::Helpers::read_and_resolve_cheffile_deps
|
24
|
+
resolved_cheffile_deps.each do | dep, version |
|
25
|
+
if metadata_deps.has_key?(dep)
|
26
|
+
metadata_ver = ::Gem::Requirement.new(metadata_deps[dep])
|
27
|
+
cheffile_ver = ::Gem::Requirement.new(version)
|
28
|
+
if metadata_ver != cheffile_ver
|
29
|
+
errors << "dependency version for '#{dep}' is inconsistent: '#{metadata_ver}' vs '#{cheffile_ver}'!"
|
30
|
+
end
|
31
|
+
else
|
32
|
+
errors << "dependency '#{dep}' is missing in metadata.rb!"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
puts errors.empty? ? "everything OK" : errors
|
36
|
+
end
|
37
|
+
|
38
|
+
#
|
39
|
+
# resolve an application cookbook from `app_cookbooks.yml` with all its dependencies
|
40
|
+
#
|
41
|
+
task :resolve_app_cookbook, [:app_cookbook] do |t, args|
|
42
|
+
name, version = ChefTLCWorkflow::Helpers::parse_name_and_version(args[:app_cookbook])
|
43
|
+
app_cookbooks = ChefTLCWorkflow::Helpers::read_app_cookbooks("app_cookbooks.yml", name, version)
|
44
|
+
app_cookbooks.each do |app_cookbook|
|
45
|
+
resolve_app_cookbook(app_cookbook)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
#
|
50
|
+
# resolve an application cookbook with all it's dependenices
|
51
|
+
# whilst honoring the application cookbook's Cheffile:
|
52
|
+
#
|
53
|
+
# 1. clone (:git,:ref) or copy (:path) the app cookbook to './tmp'
|
54
|
+
# 2. resolve dependencies (inlcuding app cookbook itself) as defined in
|
55
|
+
# the app cookbook's Cheffile to './cookbooks/<app-cookbook-name>-<version>'
|
56
|
+
#
|
57
|
+
#
|
58
|
+
def self.resolve_app_cookbook(app_cookbook)
|
59
|
+
name = app_cookbook['name']
|
60
|
+
version = app_cookbook['version']
|
61
|
+
git_loc = app_cookbook['git']
|
62
|
+
git_ref = app_cookbook['ref']
|
63
|
+
path_loc = app_cookbook['path']
|
64
|
+
has_git_loc = git_loc != nil
|
65
|
+
has_path_loc = path_loc != nil
|
66
|
+
|
67
|
+
fail "must specify either `git` or `path` location for #{name}" if !has_path_loc && !has_git_loc
|
68
|
+
fail "must not specify both `git` and `path` location for #{name}" if has_path_loc && has_git_loc
|
69
|
+
|
70
|
+
target_dir = "cookbooks/#{name}-#{version}"
|
71
|
+
tmp_dir = "tmp/tlc/#{name}-#{version}"
|
72
|
+
|
73
|
+
# clone / copy to temp dir
|
74
|
+
FileUtils.rm_rf tmp_dir
|
75
|
+
FileUtils.mkdir_p tmp_dir
|
76
|
+
if has_git_loc
|
77
|
+
sh "git clone -b #{git_ref || 'master'} #{git_loc} #{tmp_dir}"
|
78
|
+
elsif has_path_loc
|
79
|
+
FileUtils.cp_r cookbook_files_to_copy(path_loc), tmp_dir
|
80
|
+
end
|
81
|
+
|
82
|
+
# resolve deps from tmp_dir into target_dir
|
83
|
+
fail "No Cheffile found in '#{tmp_dir}'" unless File.exist? "#{tmp_dir}/Cheffile"
|
84
|
+
FileUtils.rm_rf target_dir
|
85
|
+
FileUtils.mkdir_p target_dir
|
86
|
+
sh "cd #{tmp_dir} && librarian-chef install --path #{File.absolute_path(target_dir)}"
|
87
|
+
|
88
|
+
# copy application cookbook itself if it was not reference in Cheffile using `:path => '.'`
|
89
|
+
app_cookbook_in_targetdir = "#{target_dir}/#{name}"
|
90
|
+
unless File.exist? app_cookbook_in_targetdir
|
91
|
+
FileUtils.mkdir_p app_cookbook_in_targetdir
|
92
|
+
FileUtils.cp_r Dir.glob("#{tmp_dir}/*"), app_cookbook_in_targetdir
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def self.cookbook_files_to_copy(path)
|
97
|
+
Dir.glob("#{path}/*").reject {|p| p.end_with? '/tmp' }
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
|
2
|
+
namespace :tlc do
|
3
|
+
namespace :test do
|
4
|
+
|
5
|
+
#
|
6
|
+
# destroy the default Vagrant VM, resolve dependencies and converge the default Vagrant VM
|
7
|
+
#
|
8
|
+
task :converge do
|
9
|
+
sh "vagrant destroy -f"
|
10
|
+
Rake::Task["tlc:deps:resolve"].invoke
|
11
|
+
sh "vagrant up"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
data/test/Gemfile
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
source :rubygems
|
2
|
+
|
3
|
+
source 'https://gems.gemfury.com/hUe8s8nSyzxs7JMMSZV8/' # vagrant-1.0.5.1
|
4
|
+
source 'https://gems.gemfury.com/psBbdHx94zqZrvxpiVmm/' # librarian-0.0.26.2
|
5
|
+
|
6
|
+
gem "chef-tlc-workflow", "0.1.0"
|
7
|
+
#:path => "#{File.dirname(__FILE__)}/../../chef-tlc-workflow"
|
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/bin/bash -ex
|
2
|
+
|
3
|
+
if which curl 2>/dev/null; then
|
4
|
+
HTTP_GET_CMD="curl -L"
|
5
|
+
else
|
6
|
+
HTTP_GET_CMD="wget -qO-"
|
7
|
+
fi
|
8
|
+
|
9
|
+
# read/parse user-data
|
10
|
+
USERDATA=`$HTTP_GET_CMD http://169.254.169.254/latest/user-data`
|
11
|
+
CHEF_VERSION=`echo $USERDATA | tr -s ',' '\n' | grep "chef_version" | cut -d'=' -f2`
|
12
|
+
FQDN=`echo $USERDATA | tr -s ',' '\n' | grep "fqdn" | cut -d'=' -f2`
|
13
|
+
|
14
|
+
# install Chef
|
15
|
+
if [ -z "$CHEF_VERSION" ]; then
|
16
|
+
$HTTP_GET_CMD https://www.opscode.com/chef/install.sh | sudo bash -s
|
17
|
+
else
|
18
|
+
$HTTP_GET_CMD https://www.opscode.com/chef/install.sh | sudo bash -s -- -v $CHEF_VERSION
|
19
|
+
fi
|
20
|
+
|
21
|
+
# set proper hostname
|
22
|
+
if [ "$FQDN" ]; then
|
23
|
+
HOSTNAME=`echo $FQDN | sed -r 's/\..*//'`
|
24
|
+
if [ "`grep "127.0.1.1" /etc/hosts`" ]; then
|
25
|
+
sudo sed -r -i "s/^(127[.]0[.]1[.]1[[:space:]]+).*$/\\1$FQDN $HOSTNAME/" /etc/hosts
|
26
|
+
else
|
27
|
+
sudo sed -r -i "s/^(127[.]0[.]0[.]1[[:space:]]+localhost[[:space:]]*)$/\\1\n127.0.1.1 $FQDN $HOSTNAME/" /etc/hosts
|
28
|
+
fi
|
29
|
+
sudo sed -i "s/.*$/$HOSTNAME/" /etc/hostname
|
30
|
+
sudo hostname -F /etc/hostname
|
31
|
+
fi
|
32
|
+
|
33
|
+
# XXX: ensure that file_cache_path configured in solo.rb exists (Mccloud does not create it)
|
34
|
+
sudo mkdir -p /var/chef-solo
|
@@ -0,0 +1,60 @@
|
|
1
|
+
Mccloud::Config.run do |config|
|
2
|
+
|
3
|
+
# identity / namespace
|
4
|
+
config.mccloud.prefix="mccloud"
|
5
|
+
config.mccloud.environment="tlc"
|
6
|
+
config.mccloud.identity=ENV['USERNAME']
|
7
|
+
|
8
|
+
# define AWS cloud provider for EU-West region
|
9
|
+
config.provider.define "aws-eu-west" do |provider_config|
|
10
|
+
provider_config.provider.flavor = :aws
|
11
|
+
provider_config.provider.options = { }
|
12
|
+
provider_config.provider.region = "eu-west-1"
|
13
|
+
provider_config.provider.check_keypairs = true
|
14
|
+
provider_config.provider.check_security_groups = true
|
15
|
+
provider_config.provider.namespace = "mccloud-tlc-#{ENV['USERNAME']}"
|
16
|
+
end
|
17
|
+
|
18
|
+
# ***********************************************
|
19
|
+
# VM Definitions
|
20
|
+
# ***********************************************
|
21
|
+
|
22
|
+
config.vm.define "sample-app" do |config|
|
23
|
+
|
24
|
+
# official Ubuntu 12.04 AMI
|
25
|
+
config.vm.ami = "ami-524e4726"
|
26
|
+
config.vm.provider= "aws-eu-west"
|
27
|
+
config.vm.flavor = "m1.small"
|
28
|
+
config.vm.zone = "eu-west-1c"
|
29
|
+
config.vm.user = "ubuntu"
|
30
|
+
|
31
|
+
# NOTE: the security groups must exist (e.g. create it beforehand via AWS console)
|
32
|
+
config.vm.security_groups = [ "mccloud", "http" ]
|
33
|
+
|
34
|
+
# see http://fog.io/1.1.2/rdoc/Fog/Compute/AWS/Servers.html
|
35
|
+
# and https://github.com/fog/fog/blob/v1.1.2/lib/fog/aws/requests/compute/run_instances.rb
|
36
|
+
config.vm.create_options = {
|
37
|
+
:user_data => "chef_version=10.18.2-1,fqdn=sample-app.example.com"
|
38
|
+
}
|
39
|
+
|
40
|
+
# NOTE: the keypair (for logging in to the VM) must exist (e.g. create it beforehand via AWS console)
|
41
|
+
config.vm.key_name = "mccloud-key-tlc"
|
42
|
+
config.vm.private_key_path = "#{ENV['HOME']}/.ssh/mccloud_rsa"
|
43
|
+
config.vm.public_key_path = "#{ENV['HOME']}/.ssh/mccloud_rsa.pub"
|
44
|
+
|
45
|
+
# bootstrap template (runs at first-boot only)
|
46
|
+
config.vm.bootstrap = ".chef/bootstrap/omnibus-chef.sh"
|
47
|
+
|
48
|
+
# provisioning sript (runs on each mccloud up or provision)
|
49
|
+
config.vm.provision :chef_solo do |chef|
|
50
|
+
chef.cookbooks_path = [ "cookbooks/sample-app-0.1.0" ]
|
51
|
+
chef.log_level = "info"
|
52
|
+
chef.add_recipe "sample-app"
|
53
|
+
chef.json.merge!({
|
54
|
+
:sample_app => {
|
55
|
+
:words_of_wisdom => "YEAH! I did it for the Lulz!"
|
56
|
+
}
|
57
|
+
})
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'chef-workflow/tasks/tlc/deps'
|
4
|
+
|
5
|
+
desc "resolve application cookbook with all its dependencies"
|
6
|
+
task :resolve_deps, [:app_cookbook] do |t, args|
|
7
|
+
Rake::Task["tlc:deps:resolve_app_cookbook"].invoke(args[:app_cookbook])
|
8
|
+
end
|
9
|
+
|
10
|
+
desc "bring up the mccloud \"VM\" as configured in the Mccloudfile"
|
11
|
+
task :up, [:vm_name] do |t, args|
|
12
|
+
vm_name = get_required_args(args, :vm_name)
|
13
|
+
sh "mccloud up #{vm_name}"
|
14
|
+
end
|
15
|
+
|
16
|
+
desc "provision the mccloud \"VM\" with the provisioners as defined in the Mccloudfile"
|
17
|
+
task :provision, [:vm_name] do |t, args|
|
18
|
+
vm_name = get_required_args(args, :vm_name)
|
19
|
+
sh "mccloud provision #{vm_name}"
|
20
|
+
end
|
21
|
+
|
22
|
+
desc "destroy the mccloud \"VM\" with the given name"
|
23
|
+
task :destroy, [:vm_name] do |t, args|
|
24
|
+
vm_name = get_required_args(args, :vm_name)
|
25
|
+
sh "mccloud destroy #{vm_name}"
|
26
|
+
end
|
27
|
+
|
28
|
+
desc "ssh into the mccloud \"VM\" with the given name"
|
29
|
+
task :ssh, [:vm_name] do |t, args|
|
30
|
+
vm_name = get_required_args(args, :vm_name)
|
31
|
+
sh "mccloud ssh #{vm_name}"
|
32
|
+
end
|
33
|
+
|
34
|
+
desc "show status of all mccloud \"VMs\" defined in the Mccloudfile"
|
35
|
+
task :status do
|
36
|
+
sh "mccloud status"
|
37
|
+
end
|
38
|
+
|
39
|
+
desc "returns the ip address of the mccloud \"VM\" with the given name"
|
40
|
+
task :get_ip, [:vm_name] do |t, args|
|
41
|
+
vm_name = get_required_args(args, :vm_name)
|
42
|
+
# TODO: filter out IP of given vm rather than printing status for all
|
43
|
+
sh "mccloud status"
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
#
|
48
|
+
# helper methods below
|
49
|
+
#
|
50
|
+
def get_required_args(args, *param_keys)
|
51
|
+
param_values = Array.new
|
52
|
+
param_keys.each do |param_key|
|
53
|
+
fail "parameter #{param_key.to_sym} is required" unless args[param_key.to_sym]
|
54
|
+
param_values << args[param_key.to_sym]
|
55
|
+
end
|
56
|
+
param_values.size == 1 ? param_values[0] : param_values
|
57
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
|
2
|
+
- name: "sample-app"
|
3
|
+
version: "0.1.0"
|
4
|
+
path: "../sample-app"
|
5
|
+
# git: "https://github.com/tknerr/sample-app-tlc.git"
|
6
|
+
# ref: "v0.1.0"
|
7
|
+
|
8
|
+
- name: "sample-app"
|
9
|
+
version: "0.2.0"
|
10
|
+
path: "../sample-app"
|
11
|
+
# git: "https://github.com/tknerr/sample-app-tlc.git"
|
12
|
+
# ref: "v0.2.0"
|