-
Notifications
You must be signed in to change notification settings - Fork 64
/
Copy pathservice.ts
1201 lines (1094 loc) · 36 KB
/
service.ts
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
import { detailedDiff as diff } from 'deep-object-diff';
import * as Dockerode from 'dockerode';
import Duration = require('duration-js');
import * as _ from 'lodash';
import * as path from 'path';
import { DockerPortOptions, PortMap } from './ports';
import * as ComposeUtils from './utils';
import * as updateLock from '../lib/update-lock';
import { sanitiseComposeConfig } from './sanitise';
import { getPathOnHost } from '../lib/fs-utils';
import log from '../lib/supervisor-console';
import * as conversions from '../lib/conversions';
import { checkInt } from '../lib/validation';
import { InternalInconsistencyError } from '../lib/errors';
import { EnvVarObject } from '../types';
import {
ServiceConfig,
ServiceConfigArrayField,
ServiceComposeConfig,
ConfigMap,
DeviceMetadata,
DockerDevice,
ShortMount,
ShortBind,
ShortAnonymousVolume,
ShortNamedVolume,
LongDefinition,
LongTmpfs,
LongBind,
LongAnonymousVolume,
LongNamedVolume,
} from './types/service';
const SERVICE_NETWORK_MODE_REGEX = /service:\s*(.+)/;
const CONTAINER_NETWORK_MODE_REGEX = /container:\s*(.+)/;
const unsupportedSecurityOpt = (opt: string) => /label=.*/.test(opt);
export type ServiceStatus =
| 'Stopping'
| 'Stopped'
| 'Running'
| 'Installing'
| 'Installed'
| 'Dead'
| 'paused'
| 'restarting'
| 'removing'
| 'exited';
export class Service {
public appId: number;
public appUuid?: string;
public imageId: number;
public config: ServiceConfig;
public serviceName: string;
public commit: string;
public releaseId: number;
public serviceId: number;
public imageName: string | null;
public containerId: string | null;
public dependsOn: string[] | null;
public dockerImageId: string | null;
// This looks weird, and it is. The lowercase statuses come from Docker,
// except the dashboard takes these values and displays them on the dashboard.
// What we should be doin is defining these container statuses, and have the
// dashboard make these human readable instead. Until that happens we have
// this halfways state of some captalised statuses, and others coming directly
// from docker
public status: ServiceStatus;
public createdAt: Date | null;
private static configArrayFields: ServiceConfigArrayField[] = [
'volumes',
'devices',
'capAdd',
'capDrop',
'dnsOpt',
'tmpfs',
'extraHosts',
'expose',
'ulimitsArray',
'groupAdd',
'securityOpt',
];
public static orderedConfigArrayFields: ServiceConfigArrayField[] = [
'dns',
'dnsSearch',
];
public static allConfigArrayFields: ServiceConfigArrayField[] = Service.configArrayFields.concat(
Service.orderedConfigArrayFields,
);
// A list of fields to ignore when comparing container configuration
private static omitFields = [
'networks',
'running',
'containerId',
// This field is passed at container creation, but is not
// reported on a container inspect, so we cannot use it
// to compare containers
'cpus',
// These fields are special case, due to network_mode:service:<service>
'networkMode',
'hostname',
].concat(Service.allConfigArrayFields);
private constructor() {}
// The type here is actually ServiceComposeConfig, except that the
// keys must be camelCase'd first
public static async fromComposeObject(
appConfig: ConfigMap,
options: DeviceMetadata,
): Promise<Service> {
const service = new Service();
appConfig = {
...appConfig,
composition: ComposeUtils.camelCaseConfig(appConfig.composition || {}),
};
if (!appConfig.appId) {
throw new InternalInconsistencyError('No app id for service');
}
const appId = checkInt(appConfig.appId);
if (appId == null) {
throw new InternalInconsistencyError('Malformed app id for service');
}
// Separate the application information from the docker
// container configuration
service.imageId = parseInt(appConfig.imageId, 10);
service.serviceName = appConfig.serviceName;
service.appId = appId;
service.releaseId = parseInt(appConfig.releaseId, 10);
service.serviceId = parseInt(appConfig.serviceId, 10);
service.imageName = appConfig.image;
service.createdAt = appConfig.createdAt;
service.commit = appConfig.commit;
service.appUuid = appConfig.appUuid;
// dependsOn is used by other parts of the step
// calculation so we delete it from the composition
service.dependsOn = appConfig.composition?.dependsOn || null;
delete appConfig.composition?.dependsOn;
// Get remaining fields from appConfig
const { image, running, labels, environment, composition } = appConfig;
// Get rid of any extra values and report them to the user
const config = sanitiseComposeConfig({
image,
running,
...composition,
// Ensure the top level label and environment definition is used
labels: { ...(composition?.labels ?? {}), ...labels },
environment: { ...(composition?.environment ?? {}), ...environment },
});
// Process some values into the correct format, delete them from
// the original object, and add them to the defaults object below
// We do it using defaults, as the types may be slightly different.
// For any types which do not change, we change config[value] directly
// First process the networks correctly
let networks: ServiceConfig['networks'] = {};
if (_.isArray(config.networks)) {
_.each(config.networks, (name) => {
networks[name] = {};
});
} else if (_.isObject(config.networks)) {
networks = config.networks || {};
}
// Prefix the network entries with the app id
networks = _.mapKeys(networks, (_v, k) => `${service.appUuid}_${k}`);
// Ensure that we add an alias of the service name
networks = _.mapValues(networks, (v) => {
if (v.aliases == null) {
v.aliases = [];
}
const serviceName: string = service.serviceName || '';
if (!_.includes(v.aliases, serviceName)) {
v.aliases.push(serviceName);
}
return v;
});
delete config.networks;
// memory strings
const memLimit = ComposeUtils.parseMemoryNumber(config.memLimit, '0');
const memReservation = ComposeUtils.parseMemoryNumber(
config.memReservation,
'0',
);
const shmSize = ComposeUtils.parseMemoryNumber(config.shmSize, '64m');
delete config.memLimit;
delete config.memReservation;
delete config.shmSize;
// time strings
let stopGracePeriod = 10;
if (config.stopGracePeriod != null) {
stopGracePeriod = new Duration(config.stopGracePeriod).seconds();
}
delete config.stopGracePeriod;
// ulimits
const ulimits: ServiceConfig['ulimits'] = {};
_.each(config.ulimits, (limit, name) => {
if (_.isNumber(limit)) {
ulimits[name] = { soft: limit, hard: limit };
return;
}
ulimits[name] = { soft: limit.soft, hard: limit.hard };
});
delete config.ulimits;
// string or array of strings - normalise to an array
if (_.isString(config.dns)) {
config.dns = [config.dns];
}
if (_.isString(config.dnsSearch)) {
config.dnsSearch = [config.dnsSearch];
}
// Special case network modes
let serviceNetworkMode = false;
if (config.networkMode != null) {
const match = config.networkMode.match(SERVICE_NETWORK_MODE_REGEX);
if (match != null) {
// We need to add a depends on here to ensure that
// the needed container has started up by the time
// we try to start this service
if (service.dependsOn == null) {
service.dependsOn = [];
}
service.dependsOn.push(match[1]);
serviceNetworkMode = true;
} else if (CONTAINER_NETWORK_MODE_REGEX.test(config.networkMode)) {
log.warn(
'A network_mode referencing a container is not supported. Ignoring.',
);
delete config.networkMode;
}
} else {
// Assign network_mode to a default value if necessary
if (!_.isEmpty(networks)) {
config.networkMode = _.keys(networks)[0];
} else {
config.networkMode = 'default';
}
}
if (
config.networkMode !== 'host' &&
config.networkMode !== 'bridge' &&
config.networkMode !== 'none'
) {
if (networks[config.networkMode!] == null && !serviceNetworkMode) {
// The network mode has not been set explicitly
config.networkMode = `${service.appUuid}_${config.networkMode}`;
// If we don't have any networks, we need to
// create the default with some default options
networks[config.networkMode] = {
aliases: [service.serviceName || ''],
};
}
}
// Add default environment variables and labels
// We also omit any device name variables which may have
// been input from the image (for example, if you docker
// commit a container which has been run on a balena device)
config.environment = Service.omitDeviceNameVars(
Service.extendEnvVars(
config.environment || {},
options,
service.appId || 0,
service.appUuid!,
service.serviceName || '',
),
);
config.labels = ComposeUtils.normalizeLabels(
Service.extendLabels(
config.labels || {},
options,
service.appId || 0,
service.serviceId || 0,
service.serviceName || '',
service.appUuid!, // appUuid will always exist on the target state
),
);
// Any other special case handling
if (config.networkMode === 'host' && !config.hostname) {
config.hostname = options.hostname;
}
config.restart = ComposeUtils.createRestartPolicy(config.restart);
config.command = ComposeUtils.getCommand(config.command, options.imageInfo);
config.entrypoint = ComposeUtils.getEntryPoint(
config.entrypoint,
options.imageInfo,
);
config.stopSignal = ComposeUtils.getStopSignal(
config.stopSignal,
options.imageInfo,
);
config.workingDir = ComposeUtils.getWorkingDir(
config.workingDir,
options.imageInfo,
);
config.user = ComposeUtils.getUser(config.user, options.imageInfo);
const healthcheck = ComposeUtils.getHealthcheck(
config.healthcheck,
options.imageInfo,
);
delete config.healthcheck;
config.volumes = Service.extendAndSanitiseVolumes(
config.volumes,
options.imageInfo,
service.appId,
service.serviceName || '',
);
let portMaps: PortMap[] = [];
if (config.ports != null) {
portMaps = PortMap.fromComposePorts(config.ports);
}
delete config.ports;
// get the exposed ports, both from the image and the compose file
let expose: string[] = [];
if (config.expose != null) {
expose = _.map(config.expose, ComposeUtils.sanitiseExposeFromCompose);
}
const imageExposedPorts = _.get(
options.imageInfo,
'Config.ExposedPorts',
{},
);
expose = expose.concat(_.keys(imageExposedPorts));
// Also add any exposed ports which are implied from the portMaps
const exposedFromPortMappings = _.flatMap(portMaps, (port) =>
port.toExposedPortArray(),
);
expose = expose.concat(exposedFromPortMappings);
expose = _.uniq(expose);
delete config.expose;
let devices: DockerDevice[] = [];
if (config.devices != null) {
devices = _.map(config.devices, ComposeUtils.formatDevice);
}
delete config.devices;
// Sanity check the incoming boolean values
config.oomKillDisable = Boolean(config.oomKillDisable);
config.readOnly = Boolean(config.readOnly);
if (config.tty != null) {
config.tty = Boolean(config.tty);
}
if (_.isArray(config.sysctls)) {
config.sysctls = _.fromPairs(
_.map(config.sysctls, (v) => _.split(v, '=')),
);
}
config.sysctls = _.mapValues(config.sysctls, String);
_.each(['cpuShares', 'cpuQuota', 'oomScoreAdj'], (key) => {
const numVal = checkInt(config[key]);
if (numVal) {
config[key] = numVal;
} else {
delete config[key];
}
});
if (config.cpus != null) {
config.cpus = Math.round(Number(config.cpus) * 10 ** 9);
if (_.isNaN(config.cpus)) {
log.warn(
`config.cpus value cannot be parsed. Ignoring.\n Value:${config.cpus}`,
);
config.cpus = undefined;
}
}
let tmpfs: string[] = [];
if (config.tmpfs != null) {
if (_.isString(config.tmpfs)) {
tmpfs = [config.tmpfs];
} else {
tmpfs = config.tmpfs;
}
}
delete config.tmpfs;
if (config.securityOpt != null) {
const unsupported = (config.securityOpt || []).filter(
unsupportedSecurityOpt,
);
if (unsupported.length > 0) {
log.warn(`Ignoring unsupported security options: ${unsupported}`);
config.securityOpt = (config.securityOpt || []).filter(
(opt) => !unsupportedSecurityOpt(opt),
);
}
}
// Normalise the config before passing it to defaults
ComposeUtils.normalizeNullValues(config);
service.config = _.defaults(config, {
portMaps,
capAdd: [],
capDrop: [],
command: [],
cgroupParent: '',
devices,
deviceRequests: [],
dnsOpt: [],
entrypoint: '',
extraHosts: [],
expose,
networks,
dns: [],
dnsSearch: [],
environment: {},
labels: {},
networkMode: '',
ulimits,
groupAdd: [],
healthcheck,
pid: '',
pidsLimit: 0,
securityOpt: [],
stopGracePeriod,
stopSignal: 'SIGTERM',
sysctls: {},
tmpfs,
usernsMode: '',
volumes: [],
restart: 'always',
cpuShares: 0,
cpuQuota: 0,
cpus: 0,
cpuset: '',
domainname: '',
ipc: 'shareable',
macAddress: '',
memLimit,
memReservation,
oomKillDisable: false,
oomScoreAdj: 0,
privileged: false,
readOnly: false,
shmSize,
hostname: '',
user: '',
workingDir: '',
tty: true,
running: true,
});
// If we have the docker image ID, we replace the image
// with that
if (options.imageInfo?.Id != null) {
config.image = options.imageInfo.Id;
service.dockerImageId = options.imageInfo.Id;
}
// Mutate service with extra features
await ComposeUtils.addFeaturesFromLabels(service, options);
return service;
}
public static fromDockerContainer(
container: Dockerode.ContainerInspectInfo,
): Service {
const svc = new Service();
if (container.State.Running) {
svc.status = 'Running';
} else if (container.State.Status === 'created') {
svc.status = 'Installed';
} else if (container.State.Status === 'dead') {
svc.status = 'Dead';
} else {
// We know this cast as fine as we represent all of the status available
// by docker in the ServiceStatus type
svc.status = container.State.Status as ServiceStatus;
}
svc.createdAt = new Date(container.Created);
svc.containerId = container.Id;
let hostname = container.Config.Hostname;
if (hostname.length === 12 && _.startsWith(container.Id, hostname)) {
// A hostname equal to the first part of the container ID actually
// means no hostname was specified
hostname = '';
}
let networks: ServiceConfig['networks'] = {};
if (_.get(container, 'NetworkSettings.Networks', null) != null) {
networks = ComposeUtils.dockerNetworkToServiceNetwork(
container.NetworkSettings.Networks,
);
}
const ulimits: ServiceConfig['ulimits'] = {};
_.each(container.HostConfig.Ulimits, ({ Name, Soft, Hard }) => {
ulimits[Name] = { soft: Soft, hard: Hard };
});
const portMaps = PortMap.fromDockerOpts(container.HostConfig.PortBindings);
let expose = _.flatMap(
_.flatMap(portMaps, (p) => p.toDockerOpts().exposedPorts),
_.keys,
);
if (container.Config.ExposedPorts != null) {
expose = expose.concat(
_.map(container.Config.ExposedPorts, (_v, k) => k.toString()),
);
}
expose = _.uniq(expose);
const tmpfs: string[] = Object.keys(container.HostConfig?.Tmpfs || {});
const binds: string[] = _.uniq(
([] as string[]).concat(
container.HostConfig.Binds || [],
Object.keys(container.Config?.Volumes || {}),
),
);
const mounts: LongDefinition[] = (container.HostConfig?.Mounts || []).map(
ComposeUtils.dockerMountToServiceMount,
);
const volumes: ServiceConfig['volumes'] = [...binds, ...mounts];
// We cannot use || for this value, as the empty string is a
// valid restart policy but will equate to null in an OR
let restart = _.get(container.HostConfig.RestartPolicy, 'Name');
if (restart == null) {
restart = 'always';
}
// Define the service config with the same defaults that are used
// when creating from a compose object, so comparisons will work
// correctly
// TODO: We have extended HostConfig interface to keep up with the
// missing typings, but we cannot do the same the Config sub-object
// as it is not defined as it's own type. We need to either recreate
// the entire ContainerInspectInfo object, or upstream the extra
// fields to DefinitelyTyped
svc.config = {
// The typings say that this is optional, but it's
// always set by docker
networkMode: container.HostConfig.NetworkMode!,
portMaps,
expose,
hostname,
command: container.Config.Cmd || '',
entrypoint: container.Config.Entrypoint || '',
volumes,
image: container.Config.Image,
environment: Service.omitDeviceNameVars(
conversions.envArrayToObject(container.Config.Env || []),
),
privileged: container.HostConfig.Privileged || false,
labels: ComposeUtils.normalizeLabels(container.Config.Labels || {}),
running: container.State.Running,
restart,
capAdd: container.HostConfig.CapAdd || [],
capDrop: container.HostConfig.CapDrop || [],
devices: container.HostConfig.Devices || [],
deviceRequests: container.HostConfig.DeviceRequests || [],
networks,
memLimit: container.HostConfig.Memory || 0,
memReservation: container.HostConfig.MemoryReservation || 0,
shmSize: container.HostConfig.ShmSize || 0,
cpuShares: container.HostConfig.CpuShares || 0,
cpuQuota: container.HostConfig.CpuQuota || 0,
// Not present on a container inspect
cpus: 0,
cpuset: container.HostConfig.CpusetCpus || '',
domainname: container.Config.Domainname || '',
oomKillDisable: container.HostConfig.OomKillDisable || false,
oomScoreAdj: container.HostConfig.OomScoreAdj || 0,
dns: container.HostConfig.Dns || [],
dnsSearch: container.HostConfig.DnsSearch || [],
dnsOpt: container.HostConfig.DnsOptions || [],
tmpfs,
extraHosts: container.HostConfig.ExtraHosts || [],
ulimits,
stopSignal: (container.Config as any).StopSignal || 'SIGTERM',
stopGracePeriod: (container.Config as any).StopTimeout || 10,
healthcheck: ComposeUtils.dockerHealthcheckToServiceHealthcheck(
(container.Config as any).Healthcheck || {},
),
readOnly: container.HostConfig.ReadonlyRootfs || false,
sysctls: container.HostConfig.Sysctls || {},
cgroupParent: container.HostConfig.CgroupParent || '',
groupAdd: container.HostConfig.GroupAdd || [],
pid: container.HostConfig.PidMode || '',
pidsLimit: container.HostConfig.PidsLimit || 0,
securityOpt: (container.HostConfig.SecurityOpt || []).filter(
// The docker engine v20+ adds selinux security options depending
// on the container configuration. Ignore those in the target state
// comparison as selinux is not supported by balenaOS so those options
// will not have any effect.
// https://github.com/moby/moby/blob/master/daemon/create.go#L214
(opt: string) => !unsupportedSecurityOpt(opt),
),
usernsMode: container.HostConfig.UsernsMode || '',
ipc: container.HostConfig.IpcMode || '',
macAddress: (container.Config as any).MacAddress || '',
user: container.Config.User || '',
workingDir: container.Config.WorkingDir || '',
tty: container.Config.Tty || false,
};
const appId = checkInt(svc.config.labels['io.balena.app-id']);
if (appId == null) {
throw new InternalInconsistencyError(
`Found a service with no appId! ${svc}`,
);
}
svc.appId = appId;
svc.appUuid = svc.config.labels['io.balena.app-uuid'];
svc.serviceName = svc.config.labels['io.balena.service-name'];
svc.serviceId = parseInt(svc.config.labels['io.balena.service-id'], 10);
if (Number.isNaN(svc.serviceId)) {
throw new InternalInconsistencyError(
'Attempt to build Service class from container with malformed labels',
);
}
const nameMatch = container.Name.match(/.*_(\d+)_(\d+)(?:_(.*?))?$/);
if (nameMatch == null) {
throw new InternalInconsistencyError(
`Expected supervised container to have name '<serviceName>_<imageId>_<releaseId>_<commit>', got: ${container.Name}`,
);
}
svc.imageId = parseInt(nameMatch[1], 10);
svc.releaseId = parseInt(nameMatch[2], 10);
svc.commit = nameMatch[3];
svc.containerId = container.Id;
svc.dockerImageId = container.Config.Image;
return svc;
}
/**
* Here we try to reverse the fromComposeObject to the best of our ability, as
* this is used for the supervisor reporting it's own target state. Some of
* these values won't match in a 1-1 comparison, such as `devices`, as we lose
* some data about.
*
* @returns ServiceConfig
* @memberof Service
*/
public toComposeObject() {
return this.config;
}
public toDockerContainer(opts: {
deviceName: string;
containerIds: Dictionary<string>;
}): Dockerode.ContainerCreateOptions {
const { binds, mounts, volumes } = this.getBindsMountsAndVolumes();
const { exposedPorts, portBindings } = this.generateExposeAndPorts();
const tmpFs: Dictionary<''> = this.config.tmpfs.reduce(
(dict, tmp) => ({ ...dict, [tmp]: '' }),
{},
);
const mainNetwork = _.pickBy(
this.config.networks,
(_v, k) => k === this.config.networkMode,
) as ServiceConfig['networks'];
const match = this.config.networkMode.match(SERVICE_NETWORK_MODE_REGEX);
if (match != null) {
const containerId = opts.containerIds[match[1]];
if (!containerId) {
throw new Error(
`No container for network_mode: 'service: ${match[1]}'`,
);
}
this.config.networkMode = `container:${containerId}`;
}
return {
name: `${this.serviceName}_${this.imageId}_${this.releaseId}_${this.commit}`,
Tty: this.config.tty,
Cmd: this.config.command,
Volumes: volumes,
// Typings are wrong here, the docker daemon accepts a string or string[],
Entrypoint: this.config.entrypoint as string,
Env: conversions.envObjectToArray(
_.assign(
{
RESIN_DEVICE_NAME_AT_INIT: opts.deviceName,
BALENA_DEVICE_NAME_AT_INIT: opts.deviceName,
},
this.config.environment,
),
),
ExposedPorts: exposedPorts,
Image: this.config.image,
Labels: this.config.labels,
NetworkingConfig: ComposeUtils.serviceNetworksToDockerNetworks(
mainNetwork,
),
StopSignal: this.config.stopSignal,
Domainname: this.config.domainname,
Hostname: this.config.hostname,
// Typings are wrong here, it says MacAddress is a bool (wtf?) but it is
// in fact a string
MacAddress: this.config.macAddress as any,
User: this.config.user,
WorkingDir: this.config.workingDir,
HostConfig: {
CapAdd: this.config.capAdd,
CapDrop: this.config.capDrop,
Binds: binds,
Mounts: mounts,
CgroupParent: this.config.cgroupParent,
Devices: this.config.devices,
DeviceRequests: this.config.deviceRequests,
Dns: this.config.dns,
DnsOptions: this.config.dnsOpt,
DnsSearch: this.config.dnsSearch,
PortBindings: portBindings,
ExtraHosts: this.config.extraHosts,
GroupAdd: this.config.groupAdd,
NetworkMode: this.config.networkMode,
PidMode: this.config.pid,
PidsLimit: this.config.pidsLimit,
SecurityOpt: this.config.securityOpt,
Sysctls: this.config.sysctls,
Ulimits: ComposeUtils.serviceUlimitsToDockerUlimits(
this.config.ulimits,
),
RestartPolicy: ComposeUtils.serviceRestartToDockerRestartPolicy(
this.config.restart,
),
CpuShares: this.config.cpuShares,
CpuQuota: this.config.cpuQuota,
// Type missing, and HostConfig isn't defined as a seperate object
// so we cannot extend it easily
CpusetCpus: this.config.cpuset,
Memory: this.config.memLimit,
MemoryReservation: this.config.memReservation,
OomKillDisable: this.config.oomKillDisable,
OomScoreAdj: this.config.oomScoreAdj,
Privileged: this.config.privileged,
ReadonlyRootfs: this.config.readOnly,
ShmSize: this.config.shmSize,
Tmpfs: tmpFs,
UsernsMode: this.config.usernsMode,
NanoCpus: this.config.cpus,
IpcMode: this.config.ipc,
} as Dockerode.ContainerCreateOptions['HostConfig'],
Healthcheck: ComposeUtils.serviceHealthcheckToDockerHealthcheck(
this.config.healthcheck,
),
StopTimeout: this.config.stopGracePeriod,
};
}
public isEqualConfig(
service: Service,
currentContainerIds: Dictionary<string>,
): boolean {
// Check all of the networks for any changes
let sameNetworks = true;
_.each(service.config.networks, (network, name) => {
if (this.config.networks[name] == null) {
sameNetworks = false;
return;
}
sameNetworks =
sameNetworks && this.isSameNetwork(this.config.networks[name], network);
if (!sameNetworks) {
const currentNetwork = this.config.networks[name];
const newNetwork = network;
log.debug(
`Networks do not match!\nCurrent network: \n${JSON.stringify(
currentNetwork,
)}\nNew network: \n${JSON.stringify(newNetwork)}`,
);
}
});
// Check the configuration for any changes
const thisOmitted = _.omit(this.config, Service.omitFields);
const otherOmitted = _.omit(service.config, Service.omitFields);
let sameConfig = _.isEqual(thisOmitted, otherOmitted);
const nonArrayEquals = sameConfig;
// Because the service config does not have an index
// field, we must first convert to unknown. We know that
// this conversion is fine as the
// Service.configArrayFields and
// Service.orderedConfigArrayFields are defined as
// fields inside of Service.config
const arrayEq = ComposeUtils.compareArrayFields(
(this.config as unknown) as Dictionary<unknown>,
(service.config as unknown) as Dictionary<unknown>,
Service.configArrayFields,
Service.orderedConfigArrayFields,
);
sameConfig = sameConfig && arrayEq.equal;
let differentArrayFields: string[] = [];
if (!arrayEq.equal) {
differentArrayFields = arrayEq.difference;
}
if (!(sameConfig && sameNetworks)) {
// Add some console output for why a service is not matching
// so that if we end up in a restart loop, we know exactly why
log.debug(
`Replacing container for service ${this.serviceName} because of config changes:`,
);
if (!nonArrayEquals) {
// Try not to leak any sensitive information
const diffObj = diff(thisOmitted, otherOmitted) as ServiceConfig;
if (diffObj.environment != null) {
diffObj.environment = _.mapValues(
diffObj.environment,
() => 'hidden',
);
}
log.debug(' Non-array fields: ', JSON.stringify(diffObj));
}
if (differentArrayFields.length > 0) {
log.debug(' Array Fields: ', differentArrayFields.join(','));
}
if (!sameNetworks) {
log.debug(' Network changes detected');
}
}
// Check the network mode separetely, as if it is a
// service: network mode, the container id needs to be
// checked against the running containers
// When this function is called, it's with the current
// state as a parameter and the target as the instance.
// We shouldn't rely on that because it's not enforced
// anywhere. For that reason we need to consider both
// network_modes in the correct way
let sameNetworkMode = false;
for (const [a, b] of [
[this.config.networkMode, service.config.networkMode],
[service.config.networkMode, this.config.networkMode],
]) {
const aMatch = a.match(SERVICE_NETWORK_MODE_REGEX);
const bMatch = b.match(SERVICE_NETWORK_MODE_REGEX);
if (aMatch !== null) {
if (bMatch === null) {
const containerMatch = b.match(CONTAINER_NETWORK_MODE_REGEX);
if (
containerMatch !== null &&
currentContainerIds[aMatch[1]] === containerMatch[1]
) {
sameNetworkMode = true;
break;
}
} else {
// They're both service entries, we shouldn't get here
// but it's technically an equal configuration
if (a === b) {
sameNetworkMode = true;
break;
}
}
} else if (a === b && this.config.hostname === service.config.hostname) {
// We consider the hostname when it's not a service: entry
sameNetworkMode = true;
break;
}
}
return sameNetworks && sameConfig && sameNetworkMode;
}
public extraNetworksToJoin(): ServiceConfig['networks'] {
return _.omit(this.config.networks, this.config.networkMode);
}
public isEqualExceptForRunningState(
service: Service,
currentContainerIds: Dictionary<string>,
): boolean {
return (
this.isEqualConfig(service, currentContainerIds) &&
this.commit === service.commit
);
}
public isEqual(
service: Service,
currentContainerIds: Dictionary<string>,
): boolean {
return (
this.isEqualExceptForRunningState(service, currentContainerIds) &&
this.config.running === service.config.running
);
}
public handoverCompleteFullPathsOnHost(): string[] {
const lockPath = updateLock.lockPath(
this.appId || 0,
this.serviceName || '',
);
return getPathOnHost(
...['handover-complete', 'resin-kill-me'].map((tail) =>
path.join(lockPath, tail),
),
);
}
private getBindsMountsAndVolumes(): {
binds: string[];
mounts: Dockerode.MountSettings[];
volumes: { [volName: string]: {} };
} {
const binds: string[] = [];
const mounts: Dockerode.MountSettings[] = [];
const volumes: { [volName: string]: {} } = {};
for (const volume of this.config.volumes) {
if (LongDefinition.is(volume)) {
// Volumes with the long syntax are translated into Docker-accepted configs
mounts.push(ComposeUtils.serviceMountToDockerMount(volume));
} else {
// Volumes with the string short syntax are acceptable as Docker configs as-is
ShortMount.is(volume) ? binds.push(volume) : (volumes[volume] = {});
}
}
return { binds, mounts, volumes };
}
private generateExposeAndPorts(): DockerPortOptions {
const exposed: DockerPortOptions['exposedPorts'] = {};
const ports: DockerPortOptions['portBindings'] = {};
_.each(this.config.portMaps, (pmap) => {
const { exposedPorts, portBindings } = pmap.toDockerOpts();
_.merge(exposed, exposedPorts);
_.mergeWith(ports, portBindings, (destVal, srcVal) => {
if (destVal == null) {
return srcVal;
}
return destVal.concat(srcVal);
});
});
// We also want to merge the compose and image exposedPorts
// into the list of exposedPorts
const composeExposed: DockerPortOptions['exposedPorts'] = {};
_.each(this.config.expose, (port) => {
composeExposed[port] = {};
});
_.merge(exposed, composeExposed);
return { exposedPorts: exposed, portBindings: ports };
}
private static extendEnvVars(
environment: { [envVarName: string]: string } | null | undefined,
options: DeviceMetadata,
appId: number,
appUuid: string,
serviceName: string,
): { [envVarName: string]: string } {
const defaultEnv: { [envVarName: string]: string } = {};
for (const namespace of ['BALENA', 'RESIN']) {
_.assign(
defaultEnv,