Skip to content

Commit 8ac7342

Browse files
committed
feat(contrib/linode): add Linode provision scripts and docs
1 parent 428ecc5 commit 8ac7342

10 files changed

Lines changed: 1051 additions & 0 deletions

File tree

contrib/linode/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
linode-user-data.yaml

contrib/linode/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Provision a Deis Cluster on Linode
2+
3+
Please refer to the instructions at http://docs.deis.io/en/latest/installing_deis/linode/.

contrib/linode/apply-firewall.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env python
2+
"""
3+
Apply a "Security Group" to the members of an etcd cluster.
4+
5+
Usage: apply-firewall.py
6+
"""
7+
import os
8+
import re
9+
import string
10+
import argparse
11+
from threading import Thread
12+
import uuid
13+
14+
import colorama
15+
from colorama import Fore, Style
16+
import paramiko
17+
import requests
18+
import sys
19+
import yaml
20+
21+
22+
def get_nodes_from_args(args):
23+
if args.discovery_url is not None:
24+
return get_nodes_from_discovery_url(args.discovery_url)
25+
26+
return get_nodes_from_discovery_url(get_discovery_url_from_user_data())
27+
28+
29+
def get_nodes_from_discovery_url(discovery_url):
30+
try:
31+
nodes = []
32+
json = requests.get(discovery_url).json()
33+
discovery_nodes = json['node']['nodes']
34+
for node in discovery_nodes:
35+
value = node['value']
36+
ip = re.search('([0-9]{1,3}\.){3}[0-9]{1,3}', value).group(0)
37+
nodes.append(ip)
38+
return nodes
39+
except:
40+
raise IOError('Could not load nodes from discovery url ' + discovery_url)
41+
42+
43+
def get_discovery_url_from_user_data():
44+
name = 'linode-user-data.yaml'
45+
log_info('Loading discovery url from ' + name)
46+
try:
47+
current_dir = os.path.dirname(__file__)
48+
user_data_file = file(os.path.abspath(os.path.join(current_dir, name)), 'r')
49+
user_data = yaml.safe_load(user_data_file)
50+
return user_data['coreos']['etcd']['discovery']
51+
except:
52+
raise IOError('Could not load discovery url from ' + name)
53+
54+
55+
def validate_ip_address(ip):
56+
return True if re.match('([0-9]{1,3}\.){3}[0-9]{1,3}', ip) else False
57+
58+
59+
def get_firewall_contents(node_ips, private=False):
60+
rules_template_text = """*filter
61+
:INPUT DROP [0:0]
62+
:FORWARD DROP [0:0]
63+
:OUTPUT ACCEPT [0:0]
64+
:DOCKER - [0:0]
65+
:Firewall-INPUT - [0:0]
66+
-A INPUT -j Firewall-INPUT
67+
-A FORWARD -j Firewall-INPUT
68+
-A Firewall-INPUT -i lo -j ACCEPT
69+
-A Firewall-INPUT -p icmp --icmp-type echo-reply -j ACCEPT
70+
-A Firewall-INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT
71+
-A Firewall-INPUT -p icmp --icmp-type time-exceeded -j ACCEPT
72+
# Ping
73+
-A Firewall-INPUT -p icmp --icmp-type echo-request -j ACCEPT
74+
# Accept any established connections
75+
-A Firewall-INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
76+
# Enable the traffic between the nodes of the cluster
77+
-A Firewall-INPUT -s $node_ips -j ACCEPT
78+
# Allow connections from docker container
79+
-A Firewall-INPUT -i docker0 -j ACCEPT
80+
# Accept ssh, http, https and git
81+
-A Firewall-INPUT -m conntrack --ctstate NEW -m multiport$multiport_private -p tcp --dports 22,2222,80,443 -j ACCEPT
82+
# Log and drop everything else
83+
-A Firewall-INPUT -j REJECT
84+
COMMIT
85+
"""
86+
87+
multiport_private = ' -s 192.168.0.0/16' if private else ''
88+
89+
rules_template = string.Template(rules_template_text)
90+
return rules_template.substitute(node_ips=string.join(node_ips, ','), multiport_private=multiport_private)
91+
92+
93+
def apply_rules_to_all(host_ips, rules, private_key):
94+
pkey = detect_and_create_private_key(private_key)
95+
96+
threads = []
97+
for ip in host_ips:
98+
t = Thread(target=apply_rules, args=(ip, rules, pkey))
99+
t.setDaemon(False)
100+
t.start()
101+
threads.append(t)
102+
for thread in threads:
103+
thread.join()
104+
105+
106+
def detect_and_create_private_key(private_key):
107+
private_key_text = private_key.read()
108+
private_key.seek(0)
109+
if '-----BEGIN RSA PRIVATE KEY-----' in private_key_text:
110+
return paramiko.RSAKey.from_private_key(private_key)
111+
elif '-----BEGIN DSA PRIVATE KEY-----' in private_key_text:
112+
return paramiko.DSSKey.from_private_key(private_key)
113+
else:
114+
raise ValueError('Invalid private key file ' + private_key.name)
115+
116+
117+
def apply_rules(host_ip, rules, private_key):
118+
# connect to the server via ssh
119+
ssh = paramiko.SSHClient()
120+
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
121+
ssh.connect(host_ip, username='core', allow_agent=False, look_for_keys=False, pkey=private_key)
122+
123+
# copy the rules to the temp directory
124+
temp_file = '/tmp/' + str(uuid.uuid4())
125+
126+
ssh.open_sftp()
127+
sftp = ssh.open_sftp()
128+
sftp.open(temp_file, 'w').write(rules)
129+
130+
# move the rules in to place and enable and run the iptables-restore.service
131+
commands = [
132+
'sudo mv ' + temp_file + ' /var/lib/iptables/rules-save',
133+
'sudo chown root:root /var/lib/iptables/rules-save',
134+
'sudo systemctl enable iptables-restore.service',
135+
'sudo systemctl start iptables-restore.service'
136+
]
137+
138+
for command in commands:
139+
stdin, stdout, stderr = ssh.exec_command(command)
140+
stdout.channel.recv_exit_status()
141+
142+
ssh.close()
143+
144+
log_success('Applied rule to ' + host_ip)
145+
146+
147+
def main():
148+
colorama.init()
149+
150+
parser = argparse.ArgumentParser(description='Apply a "Security Group" to a Deis cluster')
151+
parser.add_argument('--private-key', required=True, type=file, dest='private_key', help='Cluster SSH Private Key')
152+
parser.add_argument('--private', action='store_true', dest='private', help='Only allow access to the cluster from the private network')
153+
parser.add_argument('--discovery-url', dest='discovery_url', help='Etcd discovery url')
154+
parser.add_argument('--hosts', nargs='+', dest='hosts', help='The IP addresses of the hosts to apply rules to')
155+
args = parser.parse_args()
156+
157+
nodes = get_nodes_from_args(args)
158+
hosts = args.hosts if args.hosts is not None else nodes
159+
160+
node_ips = []
161+
for ip in nodes:
162+
if validate_ip_address(ip):
163+
node_ips.append(ip)
164+
else:
165+
log_warning('Invalid IP will not be added to security group: ' + ip)
166+
167+
if not len(node_ips) > 0:
168+
raise ValueError('No valid IP addresses in security group.')
169+
170+
host_ips = []
171+
for ip in hosts:
172+
if validate_ip_address(ip):
173+
host_ips.append(ip)
174+
else:
175+
log_warning('Host has invalid IP address: ' + ip)
176+
177+
if not len(host_ips) > 0:
178+
raise ValueError('No valid host addresses.')
179+
180+
log_info('Generating iptables rules...')
181+
rules = get_firewall_contents(node_ips, args.private)
182+
log_success('Generated rules:')
183+
log_debug(rules)
184+
185+
log_info('Applying rules...')
186+
apply_rules_to_all(host_ips, rules, args.private_key)
187+
log_success('Done!')
188+
189+
190+
def log_debug(message):
191+
print(Style.DIM + Fore.MAGENTA + message + Fore.RESET + Style.RESET_ALL)
192+
193+
194+
def log_info(message):
195+
print(Fore.CYAN + message + Fore.RESET)
196+
197+
198+
def log_warning(message):
199+
print(Fore.YELLOW + message + Fore.RESET)
200+
201+
202+
def log_success(message):
203+
print(Style.BRIGHT + Fore.GREEN + message + Fore.RESET + Style.RESET_ALL)
204+
205+
206+
def log_error(message):
207+
print(Style.BRIGHT + Fore.RED + message + Fore.RESET + Style.RESET_ALL)
208+
209+
if __name__ == "__main__":
210+
try:
211+
main()
212+
except Exception as e:
213+
log_error(e.message)
214+
sys.exit(1)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python
2+
"""
3+
Create CoreOS user-data by merging contrib/coreos/user-data and contrib/linode/linode-user-data-template
4+
5+
Usage: create-linode-user-data.py
6+
"""
7+
import base64
8+
import sys
9+
import os
10+
import collections
11+
import argparse
12+
13+
import yaml
14+
import colorama
15+
from colorama import Fore, Style
16+
import requests
17+
18+
19+
def combine_dicts(orig_dict, new_dict):
20+
for key, val in new_dict.iteritems():
21+
if isinstance(val, collections.Mapping):
22+
tmp = combine_dicts(orig_dict.get(key, {}), val)
23+
orig_dict[key] = tmp
24+
elif isinstance(val, list):
25+
orig_dict[key] = (orig_dict.get(key, []) + val)
26+
else:
27+
orig_dict[key] = new_dict[key]
28+
return orig_dict
29+
30+
31+
def get_file(name, mode="r", abspath=False):
32+
current_dir = os.path.dirname(__file__)
33+
34+
if abspath:
35+
return file(os.path.abspath(os.path.join(current_dir, name)), mode)
36+
else:
37+
return file(os.path.join(current_dir, name), mode)
38+
39+
40+
def main():
41+
colorama.init()
42+
43+
parser = argparse.ArgumentParser(description='Create Linode User Data')
44+
parser.add_argument('--public-key', action='append', required=True, type=file, dest='public_key_files', help='Authorized SSH Keys')
45+
parser.add_argument('--etcd-token', required=False, default=None, dest='etcd_token', help='Etcd Token')
46+
args = parser.parse_args()
47+
48+
etcd_token = args.etcd_token
49+
if etcd_token is None:
50+
etcd_token = generate_etcd_token()
51+
else:
52+
if not validate_etcd_token(args.etcd_token):
53+
raise ValueError('Invalid Etcd Token. You can generate a new token at https://discovery.etcd.io/new.')
54+
55+
public_keys = []
56+
for public_key_file in args.public_key_files:
57+
public_key = public_key_file.read()
58+
if validate_public_key(public_key):
59+
public_keys.append(public_key)
60+
else:
61+
log_warning('Invalid public key: ' + public_key_file.name)
62+
63+
if not len(public_keys) > 0:
64+
raise ValueError('Must supply at least one valid public key')
65+
66+
linode_user_data = get_file("linode-user-data.yaml", "w", True)
67+
linode_template = get_file("linode-user-data-template.yaml")
68+
coreos_template = get_file("../coreos/user-data.example")
69+
70+
configuration_linode_template = yaml.safe_load(linode_template)
71+
configuration_coreos_template = yaml.safe_load(coreos_template)
72+
73+
configuration = combine_dicts(configuration_coreos_template, configuration_linode_template)
74+
configuration['coreos']['etcd']['discovery'] = 'https://discovery.etcd.io/' + str(etcd_token)
75+
configuration['ssh_authorized_keys'] = public_keys
76+
77+
with linode_user_data as outfile:
78+
outfile.write("#cloud-config\n\n" + yaml.safe_dump(configuration, default_flow_style=False, default_style='|'))
79+
log_success('Wrote Linode user data to ' + linode_user_data.name)
80+
81+
82+
def validate_public_key(key):
83+
try:
84+
type, key_string, comment = key.split()
85+
data = base64.decodestring(key_string)
86+
return data[4:11] == type
87+
except:
88+
return False
89+
90+
91+
def generate_etcd_token():
92+
log_info('Generating new Etcd token...')
93+
data = requests.get('https://discovery.etcd.io/new').text
94+
token = data.replace('https://discovery.etcd.io/', '')
95+
log_success('Generated new token: ' + token)
96+
return token
97+
98+
99+
def validate_etcd_token(token):
100+
try:
101+
int(token, 16)
102+
return True
103+
except:
104+
return False
105+
106+
107+
def log_info(message):
108+
print(Fore.CYAN + message + Fore.RESET)
109+
110+
111+
def log_warning(message):
112+
print(Fore.YELLOW + message + Fore.RESET)
113+
114+
115+
def log_success(message):
116+
print(Style.BRIGHT + Fore.GREEN + message + Fore.RESET + Style.RESET_ALL)
117+
118+
119+
def log_error(message):
120+
print(Style.BRIGHT + Fore.RED + message + Fore.RESET + Style.RESET_ALL)
121+
122+
123+
if __name__ == "__main__":
124+
try:
125+
main()
126+
except Exception as e:
127+
log_error(e.message)
128+
sys.exit(1)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#cloud-config
2+
hostname: $hostname
3+
coreos:
4+
fleet:
5+
metadata: name=%H
6+
units:
7+
- name: 00-eth0.network
8+
runtime: false
9+
content: |
10+
[Match]
11+
Name=eth0
12+
13+
[Network]
14+
Address=$public_ipv4/24
15+
Address=$private_ipv4/17
16+
Gateway=$gateway.1
17+
DNS=72.14.179.5
18+
DNS=8.8.8.8
19+
DNS=208.67.222.222
20+
- name: disable-ipv6.service
21+
command: start
22+
content: |
23+
[Unit]
24+
Description=Disable IPv6. The notion of a security group does not exist on Linode. To make firewalling each Linode easier, disable IPv6.
25+
Before=network.target
26+
27+
[Service]
28+
Type=oneshot
29+
ExecStart=/bin/sh -c "sysctl -w net.ipv6.conf.all.disable_ipv6=1"
30+
# use ntpd rather than timesyncd. timesyncd was not keeping the nodes close enough to keep ceph happy
31+
- name: systemd-timesyncd.service
32+
command: stop
33+
mask: true
34+
- name: ntpd.service
35+
command: start
36+
enable: true
37+
write_files:
38+
- path: /etc/environment
39+
content: |
40+
COREOS_PUBLIC_IPV4=$public_ipv4
41+
COREOS_PRIVATE_IPV4=$private_ipv4
42+
- path: /etc/systemd/system/ntpd.service.d/debug.conf
43+
content: |
44+
[Service]
45+
ExecStart=
46+
ExecStart=/usr/sbin/ntpd -g -n -f /var/lib/ntp/ntp.drift

0 commit comments

Comments
 (0)