forked from earthcubeprojects-chords/chords
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathchords_control
executable file
·2002 lines (1705 loc) · 70.5 KB
/
chords_control
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/local/bin/python
"""
The CHORDS management script. Use to manage the CHORDS portal configuration,
running/stopping the portal, and updating the portal software.
The CHORDS configuration is kept in a YAML file (default chords.yml).
This file contains standard configuration items, followed by user
created non-standard options. The later are for developer use only.
There is a one-to-one corresponence between the CHORDS configuration file
and .env. The .env simply simply contains environment variables which
will be passed to docker-compose. In most cases, .env is created from
the configuration file. However, the "backwards" command allows the configuration
file to be created from .env.
In configuration mode (-c), the existing configuration will be
read from the configuration file, the user will be prompted for changes,
and the configuration file will be re-written. For each configuration item,
the user may select the current value (hit enter), select the default
value (enter a period), or change the value (enter a new value). The configuration
may be initialized to complete default vaues by using the -d switch in conjunction
with -c.
If the CHORDs configuration file does not exist, then it will be created. Thus,
to create an initial default configuration, use:
./chords_control --config --default
A .env file is also created by the configuration mode. It contains
environment variable commands, with one for each configuration item.
This .env file is used by docker-compose. The non-standard options will
be included in .env, allowing developers to test additional environment variables,
without having to edit the standardoptions specified in this script.
The -r and -s switches are used to run/stop a portal.
The -u switch updates the portal software by pulling the docker images.
Use -t to see the current portal status.
The devmode (-m) enables development mode, where the current directory
is mounted as the CHORDS Rails source.
The the --renew option makes a backup copy of this script, and pulls down
a new version from github.
"""
from __future__ import print_function
from builtins import str
from builtins import object
import os
import stat
import sys
import re
import shutil
import datetime
import argparse
import subprocess
import json
import platform
import tempfile
import string
import glob
import getpass
import socket
from collections import OrderedDict
from collections import namedtuple
# sh is not available on Windows, and the Linux commands are not there either!
if platform.system() != "Windows":
import sh
# The definitions of standard configuration items. These are 5-tuples:
# [0]: The configuration item description. This will be printed as a prompt
# during configuration, and included in the configuration file as a description.
# [1]: The name of the environment variable to be set in the .env file.
# [2]: The default value.
# [3]: Verify:True or False. If True, the user response is compared to the choices available,
# verifying that a valid response was entered.
# [4]: If [3] is true, this is a list of valid choices. It may be modified/populated
# dynamically by the script
#
# When you need to add required keys to the configuration, just define them here.
STD_CONFIG_ITEMS = [
[
"SSL is only required for systems that need to provide https (a really good idea).\n"
"You will need to obtain a DNS name which points at your CHORDS instance,\n"
"and obtain an SSL certificate (which your CHORDS instance will facilitate).\n"
"Should SSL be enabled?",
"SSL_ENABLED",
"false",
True,
[
"true",
"false"
]
],
[
"The email that will be registered (with Letsencrypt), for SSL certification.\n"
"Leave blank if SSL is not enabled.",
"SSL_EMAIL",
"",
False,
[]
],
[
"The DNS resolvable host name. Leave blank if SSL is not enabled.",
"SSL_HOST",
"",
False,
[]
],
[
"The PASSWORD for sysadmin access to CHORDS, mysql and influxdb in docker.\n"
"(NOTE: This not the CHORDS website admin login)\n"
"Replace this with a secure password.",
"CHORDS_ADMIN_PW",
"chords_ec_demo",
False,
[]
],
[
"The PASSWORD for read-only access to influxdb.",
"CHORDS_GUEST_PW",
"guest",
False,
[]
],
[
"An EMAIL ACCOUNT that will send CHORDS password reset instructions, \n"
"Grafana alerts, etc. DO NOT use a personal or business account for this; \n"
"instead set up an account specifically for CHORDS (e.g. at gmail).",
"CHORDS_EMAIL_ADDRESS",
False,
[]
],
[
"The PASSWORD for the email account that will send CHORDS password reset instructions, \n"
"Grafana alerts, etc. DO NOT use a personal or business account for this; \n"
"instead set up an account specifically for CHORDS (e.g. at gmail).",
"CHORDS_EMAIL_PASSWORD",
"unknown",
False,
[]
],
[
"The EMAIL SERVER that can relay CHORDS password reset instructions, \n"
"Grafana alerts, etc. You must have an account on this service.",
"CHORDS_EMAIL_SERVER",
"smtp.gmail.com",
False,
[]
],
[
"The SERVER PORT for the email server that can relay CHORDS password reset instructions, \n"
"Grafana alerts, etc. You must have an account on this service.",
"CHORDS_EMAIL_PORT",
"587",
False,
[]
],
[
"The PASSWORD for admin access to Grafana.\n"
"Once Grafana is initialized with this password,\n"
"it can only be changed from the Grafana admin web page.\n"
"Replace this with a secure password.",
"GRAFANA_ADMIN_PW",
"admin",
False,
[]
],
[
"A SECRET KEY BASE for Rails. Generate a\n"
"secure value (e.g. 'openssl rand -hex 32').",
"SECRET_KEY_BASE",
"aaaaaaaaaaa",
False,
[]
],
[
"The time series DATABASE RETENTION DURATION, e.g. 168h or 52w.\n"
"Use \"inf\" for permanent. This value can be changed on\n"
"successive restarts of a portal. Note: making it shorter\n"
"will trim the existing time series database.",
"DB_RETENTION",
"inf",
False,
[]
],
[
"The VERSION TAG of the desired CHORDS release.",
"DOCKER_TAG",
"latest",
True,
[]
],
[
"The GIT BRANCH where the docker-compose recipe will be fetched.\n"
"Use \"master\" unless you have a valid reason to choose otherwise.",
"GIT_BRANCH",
"master",
True,
[]
],
[
"CHORDS HTTP port.\n"
"(Typically only changed if there are port conflicts or firewall restrictions).",
"CHORDS_HTTP_PORT",
"80",
False,
[]
],
[
"Grafana port.\n"
"(Typically only changed if there are port conflicts or firewall restrictions).",
"GRAFANA_HTTP_PORT",
"3000",
False,
[]
],
[
"PROXY URL (e.g. http://proxy.myorg.com:8080).\n"
"Leave blank if not needed.",
"PROXY",
"",
False,
[]
],
[
"Enable InfluxData kapacitor.\n"
"This is experimental.In general, do NOT enable,\n"
"as it opens a security hole.",
"KAPACITOR_ENABLED",
"false",
True,
[
"true",
"false"
]
],
[
"The RAILS ENVIRONMENT. Unlikely to to be anything other than \"production\".",
"RAILS_ENV",
"production",
True,
[
"production",
"development",
"test"
]
],
[
"The number of NGINX WORKERS. 4 is a good value",
"WORKERS",
4,
False,
[]
]
]
# If set True, add some diagnostic printing.
VERBOSE = False
# Terminal control characters
TERM_BOLD_ON = '\033[1m'
TERM_BOLD_OFF = '\033[0m'
if os.name == 'nt':
TERM_BOLD_ON = ''
TERM_BOLD_OFF = ''
# Platform characteristics
OS_NAME = os.name
OS_ARCH = platform.uname()[4]
#####################################################################
def create_backup_file(filename, print_note=True):
"""
Create a backup copy of the file. A timestamp is included
in the new file name. If print_note is True, print
out a friendly message indicating that a backup copy was made.
"""
# No need to backup a file that doesn't already exist or is zero size
if not os.path.isfile(filename):
return
if os.stat(filename).st_size == 0:
return
# Create the backup file name
fsplit = os.path.splitext(filename)
backupfile = fsplit[0] + "-" + datetime.datetime.now().strftime('%Y-%m-%d-%H%M%S')
if fsplit[1]:
backupfile = backupfile + fsplit[1]
# Copy the existing foile to the backup
shutil.copyfile(filename, backupfile)
# Make it accesible only to the use
os.chmod(backupfile, stat.S_IRUSR | stat.S_IWUSR)
if print_note:
print("*** " + filename + " has been backed up to " + backupfile + ".")
#####################################################################
class CommandArgs(object):
"""
Manage the command line options.
The options are collated in a dictionary keyed on the option long name.
The option dictionary will have None for options that aren't present.
"""
def __init__(self):
description = """
CHORDS configuration and operation management.
In configuration mode, you are prompted for configuration options. These will be
saved in the configuration file (default chords.yml) and in a corresponding .env file.
Backup copies will be made of the existing configuration files. Use the --default option
to set the configuration to default values.
"""
epilog = """
At least one and only one of --config, --env, --backwards, --run, --stop,
--restart, --update, --backup, --restore or --renew can be specified.
--default must be accompanied by --config.
To create an initial default configuration:
'python chords_control --config --default'
chords_control is not available on Windows.
""" + "This operating system is " + OS_NAME + ", the architecture is " + OS_ARCH + "."
parser = argparse.ArgumentParser(description=description, epilog=epilog, formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument("--run", help="run CHORDS", default=False, action="store_true")
parser.add_argument("--stop", help="stop CHORDS", default=False, action="store_true")
parser.add_argument("--restart", help="restart CHORDS", default=False, action="store_true")
parser.add_argument("--update", help="update", default=False, action="store_true")
parser.add_argument("--file", help="configuration file (default chords.yml)", default="chords.yml", action="store")
parser.add_argument("--config", help="prompt for configuration and write config/.env files", default=False, action="store_true")
parser.add_argument("--default", help="set all configuration values to defaults", default=False, action="store_true")
parser.add_argument("--status", help="status", default=False, action="store_true")
parser.add_argument("--backup", help="Dump a CHORDS portal to a backup file", default=False, action="store_true")
parser.add_argument("--restore", help="Restore a portal from a CHORDS backup file", default=None, action="store")
parser.add_argument("--env", help="read config file and write CHORDS .env file", default=False, action="store_true")
parser.add_argument("--backwards", help="read CHORDS .env file and write config file (use with caution!)", default=False, action="store_true")
parser.add_argument("--proxy", help="proxy for curl (e.g. proxy.myorg.com:8080)", default="", action="store")
parser.add_argument("--devmode", help="run containers in source code development mode", default=False, action="store_true")
parser.add_argument("--renew", help="replace chords_control with a new version (use with caution!)", default=False, action="store_true")
parser.add_argument("--verbose", help="enable VERBOSE", default=False, action="store_true")
# If no switches, print the help.
if not sys.argv[1:]:
parser.print_help()
parser.exit()
# Parse the command line.
args = parser.parse_args()
self.options = vars(args)
# Make sure that at most only one of these args was specified
opt = self.options
if [opt['config'],
opt['run'],
opt['stop'],
opt['restart'],
opt['update'],
opt['env'],
opt['backwards'],
opt['renew'],
opt['backup'],
opt['restore']
].count(True) > 1:
print(epilog)
exit(1)
if opt['default'] and not opt['config']:
print(epilog)
exit(1)
def get_options(self):
"""
Return the dictionary of existing options.
"""
return self.options
#####################################################################
class ChordsConfig(OrderedDict):
"""
Manage a CHORDS configuration. It is associated with the CHORDS configuration
file, which is a YAML document containing comments and key:value pairs.
Note: more complex YAML structuring is not supported.
"""
def __init__(self, configfile):
OrderedDict.__init__(self)
# Initialize the configuration item descriptions. These are
# used as comments in the output configuration file.
self.init_config_items()
# Fetch the configuration key:value pairs from the configuration file.
# Add them to self.
self.get_pairs(configfile)
def get_pairs(self, config_file):
"""
Return the configuration key:value pairs from the configuration file.
"""
# Get the current configuration file
if not os.path.isfile(config_file):
cfile = open(OPTIONS["file"], 'w')
cfile.close()
print(config_file, 'has been created')
items = config_items(config_file)
tmp_config = OrderedDict()
# Collect all of the standard items. Add them if they
# weren't in the file
for key in list(self.config_items.keys()):
if key in list(items.keys()):
tmp_config[key] = items[key]
del items[key]
else:
tmp_config[key] = self.config_items[key]['default']
for key, value in items.items():
# Append any remaining items that aren't in the standard list
tmp_config[key] = value
for key in list(tmp_config.keys()):
self[key] = tmp_config[key]
def init_config_items(self):
"""
Create a collection of config items which must be in the final configuration.
These items define the default values which will be used if they haven't already been
set in a configuration.
"""
self.config_items = OrderedDict()
for i in STD_CONFIG_ITEMS:
self.config_items[i[1]] = ConfigItem(description=i[0], default=i[2], verify=i[3], choices=i[4])
def to_yml(self):
"""
Create the YML version of the configuration. Line terminators will be included.
The standard items are wrtten first, followed by the extras.
"""
yml = ''
for key in list(self.keys()):
if key in list(self.config_items.keys()):
descripts = self.config_items[key]['description'].split('\n')
for descript in descripts:
yml = yml + '# ' + descript + '\n'
yml = yml + key + ': ' + str(self[key]) + '\n'
yml = yml + '#' + '\n' + '# Non-standard options.' + '\n'
for key in list(self.keys()):
if key not in list(self.config_items.keys()):
yml = yml + key + ': ' + str(self[key]) + '\n'
return yml
def query_values(self, usedefault):
"""
Go through the configuration, asking the user if they want to
change the values. The response can be a return, to accept the
value, a new value to replace the value, or a period to use the default value.
The items found in config_items are done first, followed by all other
items.
"""
print("Enter:\n Return to keep the current value, or\n Period (.) to select to the default value, or a\n New value.")
# Process config items that are part of the standard configuration
for key in list(self.keys()):
print()
if key in list(self.config_items.keys()):
description = self.config_items[key]['description']
print()
# If verify is enabled for this item, then we need to populate the valid choices.
if self.config_items[key]['verify']:
description = description + ' Valid choices are: '
for choice in self.config_items[key]['choices']:
description = description + '\'' + choice + '\' '
while True:
self.query_value(key=key,
usedefault = usedefault,
description = description,
defaultval = self.config_items[key]['default'])
if not self.config_items[key]['verify']:
break
else:
if self[key] in self.config_items[key]['choices']:
break
else:
print('>>>> You entered an invalid value. Please try again (or use ctrl-C to exit)')
# Process items that extras which are not part of the standard configuration
for key in list(self.keys()):
if key not in list(self.config_items.keys()):
self.query_value(key=key, usedefault=usedefault)
def query_value(self, key, usedefault, description=None, defaultval=None):
"""
Query the user for a replacement value.
"""
if description:
print(description)
print(key, end=' ')
if defaultval:
print("(default: " + str(defaultval) + ")", end=' ')
print(TERM_BOLD_ON + '[' + str(self[key]) + ']'+ TERM_BOLD_OFF + '? ', end=' ')
sys.stdout.flush()
if not usedefault:
response = sys.stdin.readline().strip()
else:
response = "."
print(".", end=' ')
sys.stdout.flush()
# An empty line means retain value
if response != "":
# A period means use the default, if it is avaiable
if response == ".":
self[key] = defaultval
# Other replace with the user entered value
else:
self[key] = response
if usedefault:
print()
def write_yml_file(self, configfile):
"""
Write the configuration to the file. The whole configuration is written,
starting with the elements listed in the config_items. Configuration items
are prefixed with the comments provided in the config_items.
If specified, a backup copy of the original file is created.
"""
print()
create_backup_file(configfile)
config_file = open(configfile, "w")
config_file.write(self.to_yml())
config_file.close()
os.chmod(configfile, stat.S_IRUSR | stat.S_IWUSR)
print("*** " + OPTIONS["file"] + " has been written with the new configuration.")
def write_env_file(self):
"""
Write the configuration to the .env file, in environment
notation.
A backup copy of the original file is created.
"""
create_backup_file(".env")
env_file = open(".env", 'w')
for key in list(self.keys()):
env_file.write(key + "=" + str(self[key])+"\n")
env_file.close()
os.chmod(".env", stat.S_IRUSR | stat.S_IWUSR)
print("*** .env has been written with the new configuration.")
#####################################################################
class ConfigItem(OrderedDict):
"""
An expected configuration item. The default value and description
are recorded.
"""
def __init__(self, default, description, verify, choices):
OrderedDict.__init__(self)
self["default"] = default
self["description"] = description
self["verify"] = verify
self["choices"] = choices
#####################################################################
class ChordsGit(object):
"""
Manage CHORDS git activities.
"""
def __init__(self, proxy=""):
# The path for the docker-compose configuration.
self.proxy = proxy
def fetch(self, git_branch, files):
"""
Fetch files from the CHORDS git repository.
git_branch - the source brance
files - a single file or a list of files
"""
if not isinstance(files, list):
files = [files]
for file_name in files:
file_url = 'https://raw.githubusercontent.com/earthcubeprojects-chords/chords/'+ git_branch + '/' + file_name
# if OS_ARCH[:3] == 'arm':
# docker_compose_yml = 'https://raw.githubusercontent.com/earthcubeprojects-chords/chords/' + git_branch + '/rpi-docker-compose.yml'
proxy_switch = []
if self.proxy != '':
proxy_switch = ['-xxx', self.proxy]
print('Downloading ' + file_url + '.', end=' ')
sys.stdout.flush()
curl_cmd = [
'curl', '-O', '-s', file_url
]
if OS_NAME == 'nt':
curl_cmd[1:1] = ['-k']
# Insert proxy switch
curl_cmd[1:1] = proxy_switch
os_cmd(cmd=curl_cmd)
print()
def branches(self):
"""
Return the avaiable branches for github.com/earthcubeprojects-chords/chords.
"""
git_cmd = [
'git', 'ls-remote', '--heads', 'https://github.com/earthcubeprojects-chords/chords'
]
heads = [e.replace('refs/heads/','') for e in os_cmd(cmd=git_cmd, printlines=False)[0].split()[1::2]]
if 'gh-pages' in heads:
heads.remove('gh-pages')
return heads
#####################################################################
class Docker(object):
"""
Manage docker activities. docker-compose.yml and docker-compose-dev.yml are
expected in the working directory.
"""
def __init__(self, proxy=""):
self.docker_compose_yml = 'docker-compose.yml'
self.dockerhub_tags_uri = "https://registry.hub.docker.com/v1/repositories/earthcubechords/chords/tags"
if OS_ARCH[:3] == 'arm':
self.docker_compose_yml = 'rpi-docker-compose.yml'
self.dockerhub_tags_uri = "https://registry.hub.docker.com/v1/repositories/earthcubechords/rpi-chords/tags"
# Determine the path for docker-compose
if OS_NAME == 'nt':
self.docker_compose_cmd = 'docker-compose'
else:
cmd_choices = ['/usr/local/bin/docker-compose', '/usr/bin/docker-compose', '/bin/docker-compose']
found_cmd = False
for choice in cmd_choices:
self.docker_compose_cmd = choice
if check_file_exists(self.docker_compose_cmd, print_warning=False, exit_on_failure=False):
found_cmd = True
break
if not found_cmd:
print('docker-compose was not found in', cmd_choices)
print('You must install docker-compose before proceeding.')
exit(1)
if not check_file_exists(self.docker_compose_yml, exit_on_failure=False):
git_branch = choose_branch(self.docker_compose_yml)
print(git_branch)
ChordsGit(proxy=proxy).fetch(git_branch=git_branch, files=self.docker_compose_yml)
def running_containers(self):
"""
Return an array containing a dictionary for each currently running container.
The dictionaries keys are a subset of the output columns from docker ps. They are:
name:
runningfor
status
createdat
image
"""
ps_cmd = [
'docker',
'ps',
'--format',
'\"name\":\"{{.Names}}\", \"runningfor\":\"{{.RunningFor}}\", \"status\":\"{{.Status}}\", \"createdat\":\"{{.CreatedAt}}\", \"image\":\"{{.Image}}\"']
ps_result, _ = os_cmd(cmd=ps_cmd, printlines=False)
ps_result = ps_result.split('\n')
containers = []
for result in ps_result:
result = result.strip()
if result != '':
if not re.search("WARNING", result):
result = '{' + result + '}'
containers.append(json.loads(result))
# Convert the json unicode to bytes
return containers
def docker_compose_up(self, devmode=False):
"""
Bring the containers up with docker-compose.
"""
# Make sure that .env exists
if not os.path.isfile('.env'):
print('*** The environment file .env is missing. Use chords_control to create it.')
exit (1)
# Find out the release that we are running
d_tag = grep('DOCKER_TAG', '.env')
d_tag = d_tag[0].split('=')
if len(d_tag) == 2:
print('*** Running the \'' + d_tag[1] + '\' release of CHORDS', end=' ')
if not devmode:
print('.')
else:
print(', in development mode.')
else:
print('.env file is incorrectly formatted (DOCKER_TAG is missing)')
exit (1)
if not devmode:
up_cmd = [
self.docker_compose_cmd,
'-f', self.docker_compose_yml, '-p', 'chords', 'up', '-d']
else:
up_cmd = [
self.docker_compose_cmd,
'-p', 'chords',
'-f', 'docker-compose.yml', '-f', 'docker-compose-dev.yml',
'up', '-d']
os_cmd(cmd=up_cmd, err_msg='Unable to start containers')
def down(self):
"""
Take the containers down with docker-compose.
"""
dn_cmd = [
self.docker_compose_cmd,
'-f', self.docker_compose_yml, '-p', 'chords', 'down']
os_cmd(cmd=dn_cmd, err_msg='Unable to stop containers')
def pull(self):
"""
Pull docker images.
"""
# We need docker-compose.yml
check_file_exists(self.docker_compose_yml)
print("*** Pulling Docker images. This may take awhile...")
pull_cmd = [
self.docker_compose_cmd,
'-f', self.docker_compose_yml, 'pull']
os_cmd(cmd=pull_cmd, err_msg='Unable to pull Docker images')
print("*** ...Docker pull is finished.")
def tags(self):
"""
Return an array of tag names for dockerhub images.
"""
curl_cmd = [
'curl', '-f', '-s', self.dockerhub_tags_uri
]
if OS_NAME == 'nt':
curl_cmd[1:1] = ['-k']
curl_result, _ = os_cmd(
cmd=curl_cmd,
err_msg='Unable to fetch the Docker tags',
printlines=False)
tags = []
for tag in json.loads(curl_result):
tags.append(tag["name"])
return tags
#####################################################################
class ChordsSslError(Exception):
"""
Usage: Raise ChordsSslError("error msg").
"""
#####################################################################
class ChordsSSL(object):
"""
Manage SSL certificates.
The complete chain of certificate management activities
is trigger just by instantiating this class.
"""
def __init__(self, ssl_host, ssl_email):
self.ssl_host = ssl_host
self.ssl_email = ssl_email
self.cert_dir = "/etc/letsencrypt/live/"+self.ssl_host
print("*** Beginning SSL management. This is a complicated activity, with many steps.")
print()
print ("""
CHORDS should not be automatically restarted by a Linux
service during the SSL process. You should disable or stop
any such service before proceeding.""")
print()
reply = prompt("Have you stopped any CHORDS auto-restart services?", ['y', 'Y', 'n', 'N'])
if reply == 'n':
# Don't create cert now
print()
print("You will have to continue the SSL configuration later.")
print()
return
if ssl_host == "" or ssl_email == "":
raise ChordsSslError("When SSL is enabled, you must provide SSL_HOST and SSL_EMAIL. Rerun with --config to specify these.")
print ("*** Checking DNS for your hostname...")
self.host_dns()
print ("*** Checking to see if you already have a valid SSL certificate...")
result = self.check_cert()
if result["valid_cert"]:
print ("You have a valid certificate!")
else:
if result["test_cert"]:
print ("You appear to have a test certificate.")
reply = prompt("Do you wish to replace it with a valid certificate?", ['y', 'Y', 'n', 'N'])
if reply == 'n':
# Don't create cert now
print()
print("""
The test certificate will be retained. The CHORDS portal will be using an
inoperative certificate. We suggest that you rerun the configuration
and set SSL_ENABLED to false.""")
print()
return
# Create a new certificate
print ("We need to create a new certificate.")
self.create_cert()
print()
def create_cert(self):
"""
Create a new certificate.
CHORDS must not be running.
A staging certificate will be created first, just to insure
that the process is working.
"""
print ("*** Creating the SSL certificate")
self.stop_nginx()
# docker_check() returns an empty string if the named instance is running.
instances = [docker_check([s])=='' for s in ["chords_app", "chords_nginx", "chords_certbot"]]
if True in instances:
raise ChordsSslError(
"SSL certificate creation can't be done while one of the CHORDS \n"
"services (chords_app, chords_nginx, or chords_certbot) is running. \n"
"Use python chords_control --stop."
)
self.make_dummy_cert()
self.make_dh_params()
self.request_cert(create_test_cert = True)
reply = prompt("""
If the above text contains the phrase
"Congratulations! Your certificate and chain have been saved at ..."
then the test certificate was successfully created.
Did the test cert get created properly?""", ['y', 'Y', 'n', 'N'])
if reply == 'n':
print ("Test certificate creation failed. Try again later.")
return
print ("""
Test certificates can be created as often as you like.
However, you can only create 5 real certificates each WEEK.
If the certificate creation process is failing, do not
use up your quota of requests!
""")
reply = prompt("Do you want to create a real certificate", ['y', 'Y', 'n', 'N'])
if reply == 'n':
print ("Test certificate creation failed. Try again later.")
return
# Create the new cert
self.request_cert(create_test_cert = False)
def host_dns(self):
"""
Determine the IP address of self.ssl_host.
Raise ChordsSslError if self.ssl_host cannot be located.
"""
try:
out = socket.gethostbyname(self.ssl_host)
print("Good news! Your host name (" + self.ssl_host + ") is registered with DNS as " + out + ".")
print()
except:
raise ChordsSslError("The SSL_HOST " + self.ssl_host + " could not be located. Ensure that you have a DNS name assigned and that it is pointing to this computer.")
def check_cert(self):
"""
Return a dictionary containing the certificate status.
"""
retval = {"valid_cert": False, "test_cert": False, "expired": None, "notBefore": None, "notAfter": None}
# Ask certbot if there are any certificates.
result = self.cert_inquire()
print(result, "\n")
retval["valid_cert"] = (not
("No certs found." in result or "invalid" in result or "error" in result) or
"(VALID:" in result)
retval["test_cert"] = ("INVALID: TEST_CERT" in result)
if retval["test_cert"]:
retval["valid_cert"] = False
if retval["valid_cert"]:
# Use openssl to get the timestamps from the certificate.
cert_path = self.cert_dir + "/fullchain.pem"
cmd = ["run", "--no-deps", "--entrypoint", "openssl x509 -dates -noout -in %s" % cert_path, "certbot"]
try:
result = docker_compose(cmd).split('\n')
not_before = [s.split("=")[1] for s in result if "notBefore" in s]
not_after = [s.split("=")[1] for s in result if "notAfter" in s]
if not (retval["notBefore"] and retval["notAfter"]):
retval["notBefore"] = datetime.datetime.strptime(not_before[0], '%b %d %H:%M:%S %Y %Z')
retval["notAfter"] = datetime.datetime.strptime(not_after[0], '%b %d %H:%M:%S %Y %Z')
retval["expired"] = datetime.datetime.now() > retval["notAfter"]
else:
retval["valid_cert"] = False
except sh.ErrorReturnCode_1:
retval["valid_cert"] = False
return retval
def make_dummy_cert(self):
"""
Create a dummy key and certificate.
This allows nginx to run in SSL mode.
The key will be written to self.cert_dir/privkey.pem.
The certificate will be written to self.cert_dir/fullchain.pem.
"""
print("*** Creating a dummy certificate.")
cmd = [
"run",
"--no-deps",
"--entrypoint",
"mkdir -p %s" % self.cert_dir,
"certbot"]
result = docker_compose(cmd)
print(result)
cmd = [
"run",
"--no-deps",
"--entrypoint",
"openssl req -x509 -nodes -newkey rsa:2048 -days 1 -keyout '%s/privkey.pem' -out '%s/fullchain.pem' -subj '/CN=localhost'" % (self.cert_dir, self.cert_dir),
"certbot"]
result = docker_compose(cmd)
print(result)
print("*** Dummy certificate created.")
def make_dh_params(self):
"""
Create the Diffie-Hellman parameters.
They will be written to /etc/letsencrypt/chords-dhparam/dhparam-2048.pem.
"""
print ("*** Creating the Diffie-Hellman parameters. This can take several minutes.")
cmd = [
"run",
"--no-deps",
"--entrypoint",
"mkdir -p /etc/letsencrypt/chords-dhparam",
"certbot"]
result = docker_compose(cmd)
print(result)
cmd = [
"run",
"--no-deps",
"--entrypoint",
"openssl dhparam -out /etc/letsencrypt/chords-dhparam/dhparam-2048.pem 2048",
"certbot"]
result = docker_compose(cmd)
print(result)
print ("*** Diffie-Hellman parameters created.")
def remove_certs(self):