conlink 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.dockerignore +5 -0
  2. package/Dockerfile +34 -0
  3. package/LICENSE +373 -0
  4. package/README.md +485 -0
  5. package/TODO +34 -0
  6. package/conlink +11 -0
  7. package/conlink-start.sh +172 -0
  8. package/examples/dot.js +36 -0
  9. package/examples/index.html +11 -0
  10. package/examples/net2dot.yaml +21 -0
  11. package/examples/test1-compose.yaml +60 -0
  12. package/examples/test2-compose.yaml +31 -0
  13. package/examples/test2-network.yaml +5 -0
  14. package/examples/test3-network.yaml +5 -0
  15. package/examples/test4-multiple/all-compose.yaml +5 -0
  16. package/examples/test4-multiple/base-compose.yaml +25 -0
  17. package/examples/test4-multiple/node1-compose.yaml +17 -0
  18. package/examples/test4-multiple/nodes2-compose.yaml +20 -0
  19. package/examples/test4-multiple/web-network.yaml +2 -0
  20. package/examples/test5-geneve-compose.yaml +31 -0
  21. package/examples/test6-cfn.yaml +184 -0
  22. package/examples/test7-compose.yaml +31 -0
  23. package/examples/test8-compose.yaml +35 -0
  24. package/host-build.yaml +1 -0
  25. package/inspect.json +210 -0
  26. package/link-add.sh +197 -0
  27. package/link-del.sh +60 -0
  28. package/net2dot +11 -0
  29. package/notes.txt +82 -0
  30. package/old/Dockerfile.bak +26 -0
  31. package/old/add-link.sh +82 -0
  32. package/old/conlink +12 -0
  33. package/old/conlink.cljs +131 -0
  34. package/old/dot_gitignore +1 -0
  35. package/old/examples/test2-compose.yaml +32 -0
  36. package/old/examples/test2-network.yaml +42 -0
  37. package/old/move-link.sh +108 -0
  38. package/old/net2dot.py +122 -0
  39. package/old/notes-old.txt +97 -0
  40. package/old/package.json +16 -0
  41. package/old/schema.yaml +138 -0
  42. package/old/schema.yaml.bak +76 -0
  43. package/old/test2b-compose.yaml +18 -0
  44. package/old/veth-link.sh +96 -0
  45. package/package.json +15 -0
  46. package/schema-ish.yaml +29 -0
  47. package/schema.yaml +71 -0
  48. package/shadow-cljs.edn +33 -0
  49. package/src/conlink/addrs.cljc +63 -0
  50. package/src/conlink/core.cljs +772 -0
  51. package/src/conlink/net2dot.cljs +158 -0
  52. package/src/conlink/util.cljs +140 -0
  53. package/tests/invalid-schema-1.yaml +6 -0
  54. package/tests/invalid-schema-2.yaml +6 -0
  55. package/tests/invalid-schema-3.yaml +17 -0
  56. package/tests/invalid-schema-4.yaml +14 -0
  57. package/tests/invalid-schema-5.yaml +12 -0
  58. package/tests/invalid-schema-6.yaml +12 -0
  59. package/tmp/conlink/.env +1 -0
@@ -0,0 +1,772 @@
1
+ #!/usr/bin/env nbb
2
+
3
+ (ns conlink.core
4
+ (:require [clojure.string :as S]
5
+ [clojure.pprint :refer [pprint]]
6
+ [promesa.core :as P]
7
+ [cljs-bean.core :refer [->clj ->js]]
8
+ [conlink.util :refer [parse-opts Eprintln fatal
9
+ trim indent interpolate-walk deep-merge
10
+ spawn read-file load-config]]
11
+ [conlink.addrs :as addrs]
12
+ #_["ajv$default" :as Ajv]
13
+ #_["dockerode$default" :as Docker]))
14
+
15
+ ;; TODO: use require syntax when shadow-cljs works with "*$default"
16
+ (def Ajv (js/require "ajv"))
17
+ (def Docker (js/require "dockerode"))
18
+
19
+ (def usage "
20
+ conlink: advanced container layer 2/3 linking/networking.
21
+
22
+ Usage:
23
+ conlink [options]
24
+
25
+ General Options:
26
+ -v, --verbose Show verbose output (stderr)
27
+ [env: VERBOSE]
28
+ --bridge-mode BRIDGE-MODE Bridge mode (ovs or linux) to use for
29
+ bridge/switch connections
30
+ [default: ovs]
31
+ --network-file NETWORK-FILE... Network config file
32
+ --compose-file COMPOSE-FILE... Docker compose file with network config
33
+ --compose-project NAME Docker compose project name for resolving
34
+ link :service keys (if conlink is a
35
+ compose service then this defaults to
36
+ the current compose project name)
37
+ --config-schema SCHEMA-FILE JSON schema file for validating network config
38
+ [default: schema.yaml]
39
+ --docker-socket PATH Docker socket to listen to
40
+ [default: /var/run/docker.sock]
41
+ --podman-socket PATH Podman socket to listen to
42
+ [default: /var/run/podman/podman.sock]
43
+ ")
44
+
45
+ ;; TODO: :service should require either command line option or
46
+ ;; detection of running in a compose project (but not both).
47
+
48
+ (def OVS-START-CMD (str "/usr/share/openvswitch/scripts/ovs-ctl start"
49
+ " --system-id=random --no-mlockall --delete-bridges"))
50
+
51
+ (def VLAN-TYPES #{:vlan :macvlan :macvtap :ipvlan :ipvtap})
52
+ (def LINK-ADD-OPTS [:ip :mac :route :mtu :nat :netem :mode :vlanid :remote :vni])
53
+ (def INTF-MAX-LEN 15)
54
+
55
+ (def ctx (atom {:error #(apply Eprintln "ERROR:" %&)
56
+ :warn #(apply Eprintln "WARNING:" %&)
57
+ :log Eprintln
58
+ :info list}))
59
+
60
+ ;; Simple utility functions
61
+ (defn json-str [obj]
62
+ (js/JSON.stringify (->js obj)))
63
+
64
+ (def conjv (fnil conj []))
65
+
66
+ (defn indent-pprint-str [o pre]
67
+ (indent (trim (with-out-str (pprint o))) pre))
68
+
69
+
70
+ (defn load-configs
71
+ "Load network configs from a list of compose file paths and a list
72
+ of network config file paths. Network configs in compose files are
73
+ under the 'x-network' top-level key or as an 'x-network' property of
74
+ services. The network configs are merged together into a single
75
+ network configuration that is returned."
76
+ [comp-cfgs net-cfgs]
77
+ (P/let [comp-cfgs (P/all (map load-config comp-cfgs))
78
+ xnet-cfgs (mapcat #(into [(:x-network %)]
79
+ (for [[s sd] (:services %)
80
+ :let [cfg (:x-network sd)]]
81
+ ;; current service is default
82
+ (assoc cfg :links
83
+ (for [l (:links cfg)]
84
+ (merge {:service (name s)} l)))))
85
+ comp-cfgs)
86
+ net-cfgs (P/all (map load-config net-cfgs))
87
+ net-cfg (reduce deep-merge {} (concat xnet-cfgs net-cfgs))]
88
+ net-cfg))
89
+
90
+ (defn enrich-link
91
+ "Add default values to a link:
92
+ - type: veth
93
+ - dev: eth0
94
+ - mtu: 9000 (for non *vlan type)
95
+ - base: :conlink for veth type, :host for *vlan types, :local otherwise"
96
+ [{:as link :keys [type base bridge ip vlanid]}]
97
+ (let [type (keyword (or type "veth"))
98
+ base-default (cond (= :veth type) :conlink
99
+ (VLAN-TYPES type) :host
100
+ :else :local)
101
+ base (get link :base base-default)
102
+ link (merge
103
+ link
104
+ {:type type
105
+ :dev (get link :dev "eth0")
106
+ :base base}
107
+ (when (not (VLAN-TYPES type))
108
+ {:mtu (get link :mtu 9000)}))]
109
+ link))
110
+
111
+ (defn enrich-network-config
112
+ "Validate and update each link (enrich-link) and add
113
+ :containers and :services maps with restructured link and command
114
+ configuration to provide a more efficient structure for looking up
115
+ configuration later."
116
+ [{:as cfg :keys [links commands]}]
117
+ (let [links (vec (map enrich-link links))
118
+ cfg (merge cfg {:links links :containers {} :services {}})
119
+ rfn (fn [kind cfg {:as x :keys [container service]}]
120
+ (cond-> cfg
121
+ container (update-in [:containers container kind] conjv x)
122
+ service (update-in [:services service kind] conjv x)))
123
+ cfg (reduce (partial rfn :links) cfg links)
124
+ cfg (reduce (partial rfn :commands) cfg commands)]
125
+ cfg))
126
+
127
+ (defn ajv-error-to-str [error]
128
+ (let [path (:instancePath error)
129
+ params (dissoc (:params error) :type :pattern :missingProperty)]
130
+ (str " " (if (not (empty? path)) path "/")
131
+ " " (:message error)
132
+ (if (not (empty? params)) (str " " params) ""))))
133
+
134
+ (defn check-schema [data schema verbose]
135
+ (let [{:keys [info warn]} @ctx
136
+ ajv (Ajv. #js {:allErrors true})
137
+ validator (.compile ajv (->js schema))
138
+ valid (validator (->js data))]
139
+ (if valid
140
+ data
141
+ (let [errors (-> validator .-errors ->clj)
142
+ msg (if verbose
143
+ (indent-pprint-str errors " ")
144
+ (S/join "\n" (map ajv-error-to-str errors)))]
145
+ (fatal 1 (str "\nError during schema validation:\n"
146
+ (when verbose
147
+ "\nUser config:\n" (indent-pprint-str data " "))
148
+ "\nValidation errors:\n" msg))))))
149
+
150
+ (defn gen-network-state
151
+ "Generate network state/context from network configuration. Adds
152
+ empty :devices map and :bridges map containing nil status for
153
+ each bridge mentioned in the network config :links and :tunnels."
154
+ [{:keys [links tunnels]}]
155
+ (reduce (fn [state bridge]
156
+ (assoc-in state [:bridges bridge :status] nil))
157
+ {:devices {} :bridges {}}
158
+ (keep :bridge (concat links tunnels))))
159
+
160
+ (defn link-outer-dev
161
+ "outer-dev format:
162
+ - standalone: container '-' dev
163
+ - compose: service '_' index '-' dev
164
+ - len > 15: 'c' cid[0:8] '-' dev[0:5]"
165
+ [{:as link :keys [container service dev]} cid index]
166
+ (let [oif (str (if service (str service "_" index) container) "-" dev)
167
+ oif (if (<= (count oif) INTF-MAX-LEN)
168
+ oif
169
+ (str "c" (.substring cid 0 8) "-" (.substring dev 0 5)))]
170
+ oif))
171
+
172
+ (defn link-add-offset
173
+ "Add offset value to ip and mac keys in a link definition to account
174
+ for multiple instances of that link i.e. a compose service with
175
+ multiple replicas (scale >= 2)."
176
+ [{:as link :keys [ip mac]} offset]
177
+ ;; TODO: add vlanid
178
+ (let [mac (when mac
179
+ (addrs/int->mac
180
+ (+ offset (addrs/mac->int mac))))
181
+ ip (when ip
182
+ (let [[ip prefix] (S/split ip #"/")]
183
+ (str (addrs/int->ip
184
+ (+ offset (addrs/ip->int ip)))
185
+ "/" prefix)))]
186
+ (merge link (when mac {:mac mac}) (when ip {:ip ip}))))
187
+
188
+
189
+ (defn link-instance-enrich
190
+ "Add/update properties of a specific runtime link instance using the
191
+ container properties from an event and the current pid of the
192
+ network container. Updates iterable properties of the link
193
+ (via link-add-offset) and adds the following keys:
194
+ - :container - the container properties (passed in)
195
+ - :outer-pid - PID of the network namespace (passed in)
196
+ - :pid - PID of this container
197
+ - :dev-id - container name + container interface name
198
+ - :outer-dev - outer interface name for veth and *vlan link types"
199
+ [link container self-pid]
200
+ (let [{:keys [id pid index name]} container
201
+ dev-id (str name ":" (:dev link))
202
+ outer-pid (condp = (:base link)
203
+ :conlink self-pid
204
+ :host 1
205
+ :local nil)
206
+ link (link-add-offset link (dec index))
207
+ link (if (and outer-pid (not (:outer-dev link)))
208
+ (assoc link :outer-dev (link-outer-dev link id index))
209
+ link)
210
+ link (merge link {:container container
211
+ :dev-id dev-id
212
+ :pid pid
213
+ :outer-pid outer-pid})]
214
+ link))
215
+
216
+ (defn tunnel-instance-enrich
217
+ [tunnel self-pid]
218
+ (let [dev (str (:type tunnel) "-" (:vni tunnel))]
219
+ (merge tunnel {:dev dev
220
+ :outer-dev dev
221
+ :dev-id dev
222
+ :pid self-pid})))
223
+
224
+ ;;; General commands
225
+
226
+ (defn run
227
+ "Run/spawn a shell command with result logging. If :quiet is not set
228
+ then print indented results (success to stdout, failure to stderr).
229
+ If :id is set then it will be included in the results. Returns
230
+ command result (whether failure or success)."
231
+ [cmd & [{:as opts :keys [quiet id]}]]
232
+ (P/let [{:keys [info warn]} @ctx
233
+ id (if id (str " (" id ")") "")
234
+ _ (when (not quiet) (info (str "Running" id ": " cmd)))
235
+ res (P/catch (spawn cmd) #(identity %))]
236
+ (P/do
237
+ (when (not quiet)
238
+ (if (= 0 (:code res))
239
+ (when (not (empty? (:stdout res)))
240
+ (info (str "Result" id ":\n"
241
+ (indent (:stdout res) " "))))
242
+ (warn (str "[code: " (:code res) "]" id ":\n"
243
+ (indent (:stdout res) " ") "\n"
244
+ (indent (:stderr res) " ")))))
245
+ res)))
246
+
247
+ (defn run*
248
+ "Like run but runs each cmd in cmds. Returns final cmd result or if
249
+ a cmd fails then returns that cmd's result."
250
+ [cmds opts]
251
+ (P/loop [cmds cmds]
252
+ (P/let [[cmd & cmds] cmds
253
+ res (run cmd opts)]
254
+ (if (and (= 0 (:code res)) (seq cmds))
255
+ (P/recur cmds)
256
+ res))))
257
+
258
+ (defn rename-docker-eth0
259
+ "If eth0 exists, then rename it to DOCKER-ETH0 to prevent 'RTNETLINK
260
+ answers: File exists' errors during creation of links that use
261
+ 'eth0' device name. This is necessary because even if the netns is
262
+ specified with the same link create command, the creation and move
263
+ does not appear to be idempotent and results in the conflict."
264
+ []
265
+ (P/let [{:keys [log]} @ctx
266
+ res (run "[ -d /sys/class/net/eth0 ]" {:quiet true})]
267
+ (if (not= 0 (:code res))
268
+ (log "No eth0 docker network interface detected")
269
+ (P/let [_ (log "Renaming eth0 to DOCKER-ETH0")
270
+ res (run* [(str "ip route save dev eth0 > /tmp/routesave")
271
+ (str "ip link set eth0 down")
272
+ (str "ip link set eth0 name DOCKER-ETH0")
273
+ (str "ip link set DOCKER-ETH0 up")
274
+ (str "ip route restore < /tmp/routesave")]
275
+ {:id "rename"})]
276
+ (when (not= 0 (:code res))
277
+ (fatal 1 "Could not rename docker eth0 interface"))))))
278
+
279
+ (defn start-ovs
280
+ "Start and initialize the openvswitch daemons. Exit with error if it
281
+ can't be started."
282
+ []
283
+ (P/let [res (run OVS-START-CMD)]
284
+ (if (not= 0 (:code res))
285
+ (fatal 1 (str "Failed starting OVS: " (:stderr res)))
286
+ res)))
287
+
288
+ (defn kmod-loaded?
289
+ "Return whether kernel module 'kmod' is loaded."
290
+ [kmod]
291
+ (P/let [cmd (str "grep -o '^" kmod "\\>' /proc/modules")
292
+ res (run cmd {:quiet true})]
293
+ (and (= 0 (:code res)) (= kmod (trim (:stdout res))))))
294
+
295
+ ;;; Link and bridge commands
296
+
297
+ (defn check-no-bridge
298
+ "Check that no bridge named 'bridge' is currently configured.
299
+ Bridge type is dependent on bridge-mode (:ovs or :linux). Exit with
300
+ error if the bridge already exists."
301
+ [bridge]
302
+ (P/let [{:keys [info bridge-mode]} @ctx
303
+ cmd (get {:ovs (str "ovs-vsctl list-ifaces " bridge)
304
+ :linux (str "ip link show type bridge " bridge)}
305
+ bridge-mode)
306
+ res (run cmd {:quiet true})]
307
+ (if (= 0 (:code res))
308
+ ;; TODO: maybe mark as :exists and use without cleanup
309
+ (fatal 1 (str "Bridge " bridge " already exists"))
310
+ (if (re-seq #"(does not exist|no bridge named)" (:stderr res))
311
+ true
312
+ (fatal 1 (str "Unable to run '" cmd "': " (:stderr res)))))))
313
+
314
+
315
+ (defn bridge-create
316
+ "Create a bridge named 'bridge'.
317
+ Bridge type is dependent on bridge-mode (:ovs or :linux)."
318
+ [bridge]
319
+ (P/let [{:keys [info error bridge-mode]} @ctx
320
+ _ (info "Creating bridge/switch" bridge)
321
+ cmd (get {:ovs (str "ovs-vsctl add-br " bridge)
322
+ :linux (str "ip link add " bridge " up type bridge")}
323
+ bridge-mode)
324
+ res (run cmd)]
325
+ (if (not= 0 (:code res))
326
+ (error (str "Unable to create bridge/switch " bridge))
327
+ (swap! ctx assoc-in [:network-state :bridges bridge :status] :created))
328
+ res))
329
+
330
+ (defn bridge-del
331
+ "Delete the bridge named 'bridge'.
332
+ Bridge type is dependent on bridge-mode (:ovs or :linux)."
333
+ [bridge]
334
+ (P/let [{:keys [info error bridge-mode]} @ctx
335
+ _ (info "Deleting bridge/switch" bridge)
336
+ cmd (get {:ovs (str "ovs-vsctl del-br " bridge)
337
+ :linux (str "ip link del " bridge)} bridge-mode)
338
+ res (run cmd)]
339
+ (if (not= 0 (:code res))
340
+ (error (str "Unable to delete bridge " bridge))
341
+ (swap! ctx assoc-in [:network-state :bridges bridge :status] nil))
342
+ res))
343
+
344
+ (defn bridge-add-link
345
+ "Add the link/interface 'dev' to the bridge 'bridge'.
346
+ Bridge type is dependent on bridge-mode (:ovs or :linux)."
347
+ [bridge dev]
348
+ (P/let [{:keys [error bridge-mode]} @ctx
349
+ cmd (get {:ovs (str "ovs-vsctl add-port " bridge " " dev)
350
+ :linux (str "ip link set dev " dev " master " bridge)}
351
+ bridge-mode)
352
+ res (run cmd)]
353
+ (when (not= 0 (:code res))
354
+ (error (str "Unable to add link " dev " into " bridge)))
355
+ res))
356
+
357
+ (defn bridge-drop-link
358
+ "Remove the link/interface 'dev' from the bridge 'bridge'.
359
+ Bridge type is dependent on bridge-mode (:ovs or :linux)."
360
+ [bridge dev]
361
+ (P/let [{:keys [error bridge-mode]} @ctx
362
+ cmd (get {:ovs (str "ovs-vsctl del-port " bridge " " dev)
363
+ :linux (str "ip link set dev " dev " nomaster")}
364
+ bridge-mode)
365
+ res (run cmd)]
366
+ (when (not= 0 (:code res))
367
+ (error (str "Unable to drop link " dev " from " bridge)))
368
+ res))
369
+
370
+
371
+ (defn link-add
372
+ "Create a link/interface defined by 'link' in a container by calling
373
+ the 'link-add.sh' script. This function just marshalls the command
374
+ line arguments from the 'link' definition and reports the results."
375
+ [link]
376
+ (P/let [{:keys [error]} @ctx
377
+ {:keys [type dev outer-dev pid outer-pid container dev-id]} link
378
+ cmd (str "link-add.sh"
379
+ " '" (name type) "' '" pid "' '" dev "'"
380
+ (when outer-pid (str " --pid1 " outer-pid))
381
+ (when outer-dev (str " --intf1 " outer-dev))
382
+ (S/join ""
383
+ (for [o LINK-ADD-OPTS]
384
+ (when-let [v (get link o)]
385
+ (str " --" (name o) " '" v "'")))))
386
+ res (run cmd {:id dev-id})]
387
+ (when (not= 0 (:code res))
388
+ (error (str "Unable to add " (name type) " " dev-id)))
389
+ res))
390
+
391
+ (defn link-del
392
+ "Delete a link/interface defined by 'link' in a container by calling
393
+ the 'link-del.sh' script. This function just marshalls the command
394
+ line arguments from the 'link' definition and reports the results."
395
+ [link]
396
+ (P/let [{:keys [warn error]} @ctx
397
+ {:keys [dev pid dev-id]} link
398
+ cmd (str "link-del.sh " pid " " dev)
399
+ res (run cmd {:id dev-id :quiet true})]
400
+ (when (not= 0 (:code res))
401
+ (if (re-seq #"is no longer running" (:stderr res))
402
+ (warn (str "Skipping delete of " dev-id " (container gone)"))
403
+ (error (str "Unable to delete " dev-id ": " (:stderr res)))))
404
+ res))
405
+
406
+
407
+ ;;; docker/docker-compose utilities
408
+
409
+ (defn get-container-id
410
+ "Determine and return our docker or podman container ID. Returns nil
411
+ if no container ID can be determined (e.g. we are probably not
412
+ running in a container)"
413
+ []
414
+ (P/let [[cgroup mountinfo]
415
+ , (P/all [(read-file "/proc/self/cgroup" "utf8")
416
+ (read-file "/proc/self/mountinfo" "utf8")])
417
+ ;; docker
418
+ d-cgroups (map second (re-seq #"/docker/([^/\n]*)" cgroup))
419
+ ;; podman (root)
420
+ p-cgroups (map second (re-seq #"libpod-([^/.\n]*)" cgroup))
421
+ ;; general fallback
422
+ o-mounts (map second (re-seq #"containers/([^/]{64})/.*/etc/hosts" mountinfo))]
423
+ (first (concat d-cgroups p-cgroups o-mounts))))
424
+
425
+ (defn list-containers
426
+ "Return a sequence of container objects optionally limited to those
427
+ matching filters in 'filters'."
428
+ [client & [filters]]
429
+ (P/let [opts (if filters {:filters (json-str filters)} {})]
430
+ ^obj (.listContainers client (->js opts))))
431
+
432
+ (defn get-container
433
+ "Return a dockerode container object with container ID 'cid'."
434
+ [client cid]
435
+ ^obj (.getContainer client cid))
436
+
437
+ (defn inspect-container
438
+ "Return a map of inspected container properties for the 'container'
439
+ container object."
440
+ [container]
441
+ (P/-> ^obj (.inspect container) ->clj))
442
+
443
+ (defn get-compose-labels
444
+ "Return a map of compose related container labels with the
445
+ 'com.docker.compose.' prefix stripped off and converted to
446
+ keywords."
447
+ [container]
448
+ (into {}
449
+ (for [[k v] (get-in container [:Config :Labels])
450
+ :let [n (name k)]
451
+ :when (S/starts-with? n "com.docker.compose.")]
452
+ [(keyword (-> n
453
+ (S/replace #"^com\.docker\.compose\." "")
454
+ (S/replace #"\." "-")))
455
+ v])))
456
+
457
+ ;;;
458
+
459
+ (defn docker-client
460
+ "Return a docker/dockerode client object for the docker/podman
461
+ server listening at 'path'."
462
+ [path]
463
+ (P/let [{:keys [error log]} @ctx]
464
+ (P/catch
465
+ (P/let
466
+ [client (Docker. #js {:socketPath path})
467
+ ;; client is lazy so trigger it now
468
+ containers (list-containers client)]
469
+ (log (str "Listening on " path))
470
+ client)
471
+ #(error "Could not start docker client on '" path "': " %))))
472
+
473
+ (defn docker-listen
474
+ "Listen for docker events from 'client' that match filter 'filters'.
475
+ Calls 'event-callback' function with each decoded event map."
476
+ [client filters event-callback]
477
+ (P/let [{:keys [error log]} @ctx]
478
+ (P/catch
479
+ (P/let
480
+ [ev-stream ^obj (.getEvents client #js {:filters (json-str filters)})
481
+ _ ^obj (.on ev-stream "data"
482
+ #(event-callback client (->clj (js/JSON.parse %))))]
483
+ ev-stream)
484
+ #(error "Could not start docker listener"))))
485
+
486
+ (defn link-repr [{:keys [type dev remote outer-dev bridge ip dev-id]}]
487
+ (str dev-id
488
+ (if remote
489
+ (str " (bridge " bridge ", remote " remote ")")
490
+ (str (when ip (str " (IP " ip ")"))
491
+ (when outer-dev (str " <-> " outer-dev))
492
+ (when bridge (str " (bridge " bridge ")"))))))
493
+
494
+ (defn modify-link
495
+ "Depending on 'action' create ('start') or delete ('die') a link
496
+ defined by 'link'. veth type links and tunnel interfaces will also
497
+ be added to the local bridge defined in the link. The network-state
498
+ for this link will be updated to either :creating or :deleting
499
+ before any action is taken. Once the async commands complete, the
500
+ state will be updated to either :created or nil."
501
+ [link action]
502
+ (P/let
503
+ [{:keys [error log]} @ctx
504
+ {:keys [type outer-dev bridge dev-id]} link
505
+ status-path [:network-state :devices dev-id :status]
506
+ link-status (get-in @ctx status-path)]
507
+ (log (str (get {"start" "Creating" "die" "Deleting"} action)
508
+ " " (name type) " link " (link-repr link)))
509
+ (condp = action
510
+ "start"
511
+ (if link-status
512
+ (error (str "Link " dev-id " already exists"))
513
+ (P/do
514
+ (swap! ctx assoc-in status-path :creating)
515
+ (link-add link)
516
+ (when bridge (bridge-add-link bridge outer-dev))
517
+ (swap! ctx assoc-in status-path :created)))
518
+
519
+ "die"
520
+ (if (not link-status)
521
+ (error (str "Link " dev-id " does not exist"))
522
+ (P/do
523
+ (swap! ctx assoc-in status-path :deleting)
524
+ (when bridge (bridge-drop-link bridge outer-dev))
525
+ (link-del link)
526
+ (swap! ctx assoc-in status-path nil))))))
527
+
528
+ (defn exec-command
529
+ "Exec a command 'command' in the container named 'cname' using the
530
+ 'container' object. If 'command' is a string then call the command
531
+ using 'sh -c'. Every line of output from the command is prefixed
532
+ with 'cname' and printed to stdout."
533
+ [cname container command]
534
+ (P/let [{:keys [error log]} @ctx
535
+ cmd (if (string? command)
536
+ ["sh", "-c", command]
537
+ command)
538
+ _ (log (str "Exec command in " cname ": " cmd))
539
+ ex (.exec container (->js {:Cmd cmd
540
+ :AttachStdout true
541
+ :AttachStderr true}))
542
+ stream (.start ex)]
543
+ ^obj (.on stream "data"
544
+ (fn [b] (log (str " " cname ": " (trim (.toString b "utf8"))))))
545
+ ex))
546
+
547
+ (defn all-connected-check
548
+ "Check if all containers/services have been connected (at least one
549
+ link for each service) and if so, output the ending network state (if
550
+ verbose) and the message 'All links connected'. This will only fire
551
+ once. Caveat: can fire early depending on the service replica/scale
552
+ counts."
553
+ []
554
+ (let [{:keys [network-config network-state log info]} @ctx
555
+ {:keys [links tunnels]} network-config
556
+ {:keys [devices all-connected]} network-state]
557
+ (when (and (not all-connected)
558
+ (every? #(= :created (:status %)) (vals devices))
559
+ (>= (count devices) (+ (count links) (count tunnels))))
560
+ ;; Save all-connected to prevent scale/stop-start from
561
+ ;; showing this message multiple times.
562
+ (swap! ctx assoc-in [:network-state :all-connected] true)
563
+ (info (str "Ending network state:\n"
564
+ (indent-pprint-str network-state " ")))
565
+ (log "All links connected"))))
566
+
567
+ (defn handle-event
568
+ "Handle a docker/podman container event. Match the event to
569
+ a container or service network definition (if any), then create all
570
+ the links for that container and then run any commmands defined for
571
+ the container. Finally call all-connected-check to check and notify
572
+ if all containers/services are connected."
573
+ [client {:keys [status id]}]
574
+ (P/let
575
+ [{:keys [log info network-config compose-opts self-pid]} @ctx
576
+ container-obj (get-container client id)
577
+ container (inspect-container container-obj)
578
+ cname (->> container :Name (re-seq #"(.*/)?(.*)") first last)
579
+ pid (-> container :State :Pid)
580
+
581
+ clabels (get-compose-labels container)
582
+ svc-name (:service clabels)
583
+ svc-num (:container-number clabels)
584
+ cindex (if svc-num (js/parseInt svc-num) 1)
585
+ container-info {:id id
586
+ :name cname
587
+ :index cindex
588
+ :service svc-name
589
+ :pid pid
590
+ :labels clabels}
591
+
592
+ svc-match? (and (let [p (:project compose-opts)]
593
+ (or (not p) (= p (:project clabels))))
594
+ (let [d (:project-working_dir compose-opts)]
595
+ (or (not d) (= d (:project-working_dir clabels)))))
596
+ containers (get-in network-config [:containers cname])
597
+ services (when svc-match? (get-in network-config [:services svc-name]))
598
+ links (concat (:links containers) (:links services))
599
+ commands (concat (:commands containers) (:commands services))]
600
+ (if (and (not (seq links)) (not (seq commands)))
601
+ (info (str "Event: no matching config for " cname ", ignoring"))
602
+ (P/do
603
+ (info "Event:" status cname id)
604
+ (P/all (for [link links
605
+ :let [link (link-instance-enrich
606
+ link container-info self-pid)]]
607
+ (modify-link link status)))
608
+ (when (= "start" status)
609
+ (P/all (for [{:keys [command]} commands]
610
+ (exec-command cname container-obj command))))
611
+
612
+ (all-connected-check)))))
613
+
614
+ (defn exit-handler
615
+ "When the process is exiting, delete all links and bridges that are
616
+ currently configured (have :created status)."
617
+ [err origin]
618
+ (let [{:keys [log info network-state]} @ctx
619
+ {:keys [devices bridges]} network-state
620
+ ;; filter for :created status (ignore :exists)
621
+ devices (filter #(= :created (-> % val :status)) devices)
622
+ bridges (filter #(= :created (-> % val :status)) bridges)]
623
+ (info (str "Got " origin ":") err)
624
+ (P/do
625
+ (when (seq devices)
626
+ (P/do
627
+ (log (str "Removing devices: " (S/join ", " (keys devices))))
628
+ (P/all (map link-del (vals devices)))))
629
+ (when (seq bridges)
630
+ (P/do
631
+ (log (str "Removing bridges: " (S/join ", " (keys bridges))))
632
+ (P/all (map bridge-del (keys bridges)))))
633
+ (js/process.exit 127))))
634
+
635
+
636
+ ;;;
637
+
638
+ (defn arg-checks
639
+ "Check command line arguments. Exit with error if arguments are
640
+ invalid."
641
+ [{:keys [network-file compose-file]}]
642
+ (when (and (empty? network-file) (empty? compose-file))
643
+ (fatal 2 "either --network-file or --compose-file is required")))
644
+
645
+ (defn startup-checks
646
+ "Check startup state and exit if openvswitch kernel module is not
647
+ loaded or if no docker or podman connection could be established."
648
+ [bridge-mode docker podman]
649
+ (P/let
650
+ [kmod-okay? (if (= :ovs bridge-mode)
651
+ (kmod-loaded? "openvswitch")
652
+ true)]
653
+ (when (not kmod-okay?)
654
+ (fatal 1 "bridge-mode is 'ovs', but no 'openvswitch' module loaded"))
655
+ (when (and (not docker) (not podman))
656
+ (fatal 1 "Failed to start either docker or podman client/listener"))))
657
+
658
+ (defn server
659
+ "Process:
660
+ - parse/validate command line options
661
+ - load/combine/mangle/validate network configuration
662
+ - connect to docker and/or podman server
663
+ - determine our own container ID and compose properties (if any)
664
+ - generate runtime network state and other process context/state
665
+ - install exit/cleanup handlers
666
+ - start/init openvswitch daemons/config (if :ovs bridge-mode)
667
+ - check that any defined bridges do not already exist
668
+ - create any bridges defined in network config links
669
+ - start listening/handling docker/podman container events
670
+ - list/handle any already running containers that match the config
671
+ "
672
+ [& args]
673
+ (P/let
674
+ [{:as opts :keys [verbose]} (parse-opts usage args)
675
+ {:keys [log info]} (swap! ctx merge (when verbose {:info Eprintln}))
676
+ opts (merge
677
+ opts
678
+ {:bridge-mode (keyword (:bridge-mode opts))
679
+ :network-file (mapcat #(S/split % #":") (:network-file opts))
680
+ :compose-file (mapcat #(S/split % #":") (:compose-file opts))})
681
+ _ (arg-checks opts)
682
+ _ (info (str "User options:\n" (indent-pprint-str opts " ")))
683
+
684
+ {:keys [network-file compose-file compose-project bridge-mode]} opts
685
+ env (js->clj (js/Object.assign #js {} js/process.env))
686
+ self-pid js/process.pid
687
+ schema (load-config (:config-schema opts))
688
+ network-config (P/-> (load-configs compose-file network-file)
689
+ (interpolate-walk env)
690
+ (check-schema schema verbose)
691
+ (enrich-network-config))
692
+
693
+ docker (docker-client (:docker-socket opts))
694
+ podman (docker-client (:podman-socket opts))
695
+ _ (startup-checks bridge-mode docker podman)
696
+ self-cid (get-container-id)
697
+ self-container-obj (when self-cid
698
+ (get-container (or docker podman) self-cid))
699
+ self-container (inspect-container self-container-obj)
700
+ compose-opts (if compose-project
701
+ {:project compose-project}
702
+ (get-compose-labels self-container))
703
+ network-state (gen-network-state network-config)
704
+ ctx-data {:bridge-mode bridge-mode
705
+ :network-config network-config
706
+ :network-state network-state
707
+ :compose-opts compose-opts
708
+ :docker docker
709
+ :podman podman
710
+ :self-pid self-pid
711
+ :self-cid self-cid}]
712
+
713
+
714
+ (swap! ctx merge ctx-data)
715
+
716
+ (js/process.on "SIGINT" #(exit-handler % "signal"))
717
+ (js/process.on "SIGTERM" #(exit-handler % "signal"))
718
+ (js/process.on "uncaughtException" #(exit-handler %1 %2))
719
+
720
+ (log "Bridge mode:" (name bridge-mode))
721
+ (info (str "Starting network config\n"
722
+ (indent-pprint-str network-config " ")))
723
+ (info (str "Starting network state:\n"
724
+ (indent-pprint-str network-state " ")))
725
+ (when self-cid
726
+ (info "Detected enclosing container:" self-cid))
727
+ (when compose-project
728
+ (info "Detected compose context:" compose-project))
729
+
730
+ (P/do
731
+ (when self-cid
732
+ (rename-docker-eth0))
733
+
734
+ (when (= :ovs bridge-mode)
735
+ (start-ovs))
736
+
737
+ ;; Check that bridges/switches do not already exist
738
+ (P/all (for [bridge (-> network-state :bridges keys)]
739
+ (check-no-bridge bridge)))
740
+ ;; Create bridges/switch configs
741
+ ;; TODO: should be done on-demand
742
+ (P/all (for [bridge (-> network-state :bridges keys)]
743
+ (bridge-create bridge)))
744
+
745
+ ;; Create tunnels configs
746
+ (P/all (for [tunnel (:tunnels network-config)
747
+ :let [tunnel (tunnel-instance-enrich tunnel self-pid)]]
748
+ (modify-link tunnel "start")))
749
+
750
+ (P/all (for [client [docker podman] :when client]
751
+ (P/let
752
+ [event-filter {"event" ["start" "die"]}
753
+ ;; Listen for docker and/or podman events
754
+ _ (docker-listen client event-filter handle-event)
755
+ containers ^obj (list-containers client)]
756
+ ;; Generate fake events for existing containers
757
+ (P/all (for [container containers
758
+ :let [ev {:status "start"
759
+ :from "pre-existing"
760
+ :id (.-Id ^obj container)}]]
761
+ (handle-event client ev))))))
762
+ nil)))
763
+
764
+
765
+ (defn main
766
+ [& args]
767
+ ;; nbb implicitly does this wrapping but shadow-cljs does not
768
+ ;; (exceptions result in successful exit code).
769
+ (P/catch
770
+ (apply server args)
771
+ (fn [err]
772
+ (fatal 1 "Error during conlink server startup:" err))))