Infra for running ovs dockers
[ovsdb.git] / utils / ovsdb-it-utils / src / main / java / org / opendaylight / ovsdb / utils / ovsdb / it / utils / DockerOvs.java
1 /*
2  * Copyright (c) 2016 Red Hat, Inc. and others.  All rights reserved.
3  *
4  * This program and the accompanying materials are made available under the
5  * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6  * and is available at http://www.eclipse.org/legal/epl-v10.html
7  */
8
9 package org.opendaylight.ovsdb.utils.ovsdb.it.utils;
10
11 import java.io.BufferedReader;
12 import java.io.File;
13 import java.io.FileNotFoundException;
14 import java.io.FileReader;
15 import java.io.FileWriter;
16 import java.io.InputStreamReader;
17 import java.io.IOException;
18 import java.io.Reader;
19 import java.net.InetSocketAddress;
20 import java.net.URL;
21 import java.nio.ByteBuffer;
22 import java.nio.channels.ClosedByInterruptException;
23 import java.nio.channels.SocketChannel;
24 import java.nio.charset.Charset;
25 import java.nio.charset.CharsetDecoder;
26 import java.util.ArrayList;
27 import java.util.Arrays;
28 import java.util.List;
29 import java.util.Map;
30 import java.util.concurrent.atomic.AtomicInteger;
31
32 import com.esotericsoftware.yamlbeans.YamlException;
33 import com.esotericsoftware.yamlbeans.YamlReader;
34 import org.junit.Assert;
35 import org.osgi.framework.Bundle;
36 import org.osgi.framework.FrameworkUtil;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
39
40 /**
41  * Run OVS(s) using docker-compose for use in integration tests.
42  * For example,
43  * <pre>
44  * try(DockerOvs ovs = new DockerOvs()) {
45  *      ConnectionInfo connectionInfo = SouthboundUtils.getConnectionInfo(
46  *                               ovs.getOvsdbAddress(0), ovs.getOvsdbPort(0));
47  *      ...
48         nodeInfo.disconnect();
49
50  * } catch (Exception e) {
51  * ...
52  * </pre>
53  * <b>
54  * Nota bene, DockerOvs will check whether or not docker-compose command requires "sudo"
55  * to run. However, if it does require sudo, it must be configured to not prompt for a
56  * password ("NOPASSWD: ALL" is the sudoers file).
57  * </b>
58  * DockerOvs loads its docker-compose yaml files from inside the ovsdb-it-utils bundle
59  * at the path META-INF/docker-compose-files/. Currently, a single yaml file is used,
60  * "docker-ovs-2.5.1.yml." DockerOvs does support docker-compose files that
61  * launch more than one docker image, more on this later. DockerOvs will wait for OVS
62  * to accept OVSDB connections. In order for this to work, the docker-compose file *must*
63  * have a port mapping.
64  * Currently, DockerOvs does not support docker images with OVS instances that connect actively.
65  */
66 public class DockerOvs implements AutoCloseable {
67     private static final Logger LOG = LoggerFactory.getLogger(DockerOvs.class);
68     public static final String DOCKER_SUDO = "docker.sudo";
69     private static final String DEFAULT_DOCKER_FILE = "docker-ovs-2.5.1.yml";
70     private static final String DOCKER_FILE_PATH = "META-INF/docker-compose-files/";
71     //private static final String[] HELP_CMD = {"docker-compose", "--help"};
72     //private static final String[] EXEC_CMD_PFX = {"sudo", "docker-compose", "-f"};
73     private static final int COMPOSE_FILE_IDX = 3;
74     private static final String DEFAULT_OVSDB_HOST = "127.0.0.1";
75     private static final String[] PS_CMD = {"sudo", "docker-compose", "ps"};
76     private static final String[] PS_CMD_NO_SUDO = {"docker-compose", "ps"};
77
78     private String[] upCmd = {"sudo", "docker-compose", "-f", null, "up", "-d"};
79     private String[] downCmd = {"sudo", "docker-compose", "-f", null, "stop"};
80     private File tmpDockerComposeFile;
81     private List<String> ovsdbPorts;
82     boolean isRunning;
83
84     /**
85      * Bring up all docker images in the default docker-compose file.
86      * @throws IOException if something goes wrong on the IO end
87      * @throws InterruptedException If this thread is interrupted
88      */
89     public DockerOvs() throws IOException, InterruptedException {
90         this(DEFAULT_DOCKER_FILE);
91     }
92
93     /**
94      * Bring up all docker images in the provided docker-compose file under "META-INF/docker-compose-files/".
95      * @param yamlFileName Just the file name
96      * @throws IOException if something goes wrong on the IO end
97      * @throws InterruptedException If this thread is interrupted
98      */
99     public DockerOvs(String yamlFileName) throws IOException, InterruptedException {
100         tmpDockerComposeFile = createTempDockerComposeFile(yamlFileName);
101         buildDockerComposeCommands();
102         ovsdbPorts = extractPortsFromYaml();
103
104         isRunning = false;
105         //We run this for A LONG TIME since on the first run docker must download the
106         //image from docker hub. In experience it takes significantly less than this
107         //even when downloading the image. Once the image is downloaded this command
108         //runs like that <snaps fingers>
109         runProcess(60000, upCmd);
110         isRunning = true;
111         waitForOvsdbServers(10 * 1000);
112     }
113
114     /**
115      * Verify and build the docker-compose commands we will be running. This function adds the docker-compose file
116      * to the command lines and also checks (and adjusts the command line) as to whether sudo is required. This is
117      * done by attempting to run "docker-compose ps" without and then with sudo
118      * @throws IOException if something goes wrong on the IO end
119      * @throws InterruptedException If this thread is interrupted
120      */
121     private void buildDockerComposeCommands() throws IOException, InterruptedException {
122         upCmd[COMPOSE_FILE_IDX] = tmpDockerComposeFile.toString();
123         downCmd[COMPOSE_FILE_IDX] = tmpDockerComposeFile.toString();
124
125         if (0 == tryProcess(5000, PS_CMD_NO_SUDO)) {
126             LOG.info("DockerOvs.buildDockerComposeCommands docker-compose does not require sudo");
127             String[] tmp;
128             tmp = Arrays.copyOfRange(upCmd, 1, upCmd.length);
129             upCmd = tmp;
130             tmp = Arrays.copyOfRange(downCmd, 1, downCmd.length);
131             downCmd = tmp;
132         } else if (0 == tryProcess(5000, PS_CMD)) {
133             LOG.info("DockerOvs.buildDockerComposeCommands docker-compose requires sudo");
134         } else {
135             Assert.fail("docker-compose does not seem to work with or without sudo");
136         }
137     }
138     /**
139      * Get the IP address of the n'th OVS.
140      * @param ovsNumber which OVS?
141      * @return IP string
142      */
143     public String getOvsdbAddress(int ovsNumber) {
144         return DEFAULT_OVSDB_HOST;
145     }
146
147     /**
148      * Get the port of the n'th OVS.
149      * @param ovsNumber which OVS?
150      * @return Port as a string
151      */
152     public String getOvsdbPort(int ovsNumber) {
153         return ovsdbPorts.get(ovsNumber);
154     }
155
156     /**
157      * How many OVS nodes are there.
158      * @return number of running OVS nodes
159      */
160     public int getNumOvsNodes() {
161         return ovsdbPorts.size();
162     }
163
164     /**
165      * Parse the docker-compose yaml file to extract the port mappings.
166      * @return a list of the external ports
167      */
168     private List<String> extractPortsFromYaml() {
169         List<String> ports = new ArrayList<String>();
170
171         YamlReader yamlReader = null;
172         Map root = null;
173         try {
174             yamlReader = new YamlReader(new FileReader(tmpDockerComposeFile));
175             root = (Map) yamlReader.read();
176         } catch (FileNotFoundException e) {
177             LOG.warn("DockerOvs.extractPortsFromYaml error reading yaml file", e);
178             return ports;
179         } catch (YamlException e) {
180             LOG.warn("DockerOvs.extractPortsFromYaml error parsing yaml file", e);
181             return ports;
182         }
183
184         if (null == root) {
185             return ports;
186         }
187         for (Object map : root.values()) {
188             List portMappings = (List) ((Map)map).get("ports");
189             if (null == portMappings) {
190                 continue;
191             }
192             for (Object portMapping : portMappings) {
193                 String portMappingStr = (String) portMapping;
194                 int delim = portMappingStr.indexOf(":");
195                 if (delim == -1) {
196                     continue;
197                 }
198                 String port = portMappingStr.substring(0, delim);
199                 ports.add(port);
200             }
201         }
202
203         return ports;
204     }
205
206     /**
207      * Shut everything down.
208      * @throws Exception but not really
209      */
210     @Override
211     public void close() throws Exception {
212         if (isRunning) {
213             runProcess(5000, downCmd);
214             isRunning = false;
215         }
216
217         try {
218             tmpDockerComposeFile.delete();
219         } catch (Exception ignored) {
220             //No reason to fail the test, we're just being polite here.
221         }
222     }
223
224     /**
225      * A thread that waits until it can "ping" a running OVS -  tests basic reachability
226      * and readiness. The "ping" here is actually a list_dbs method and the response is
227      * checked to make sure the Open_Vswitch DB is present. Note that this thread will
228      * run until it succeeds unless its interrupt() method is called.
229      */
230     class OvsdbPing extends Thread {
231
232         private final String host;
233         private final int port;
234         private final AtomicInteger result;
235         public CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
236         ByteBuffer listDbsRequest;
237
238         /**
239          * Construct a new OvsdbPing object.
240          * @param ovsNumber which OVS is this?
241          * @param result an AtomicInteger that is incremented upon a successful "ping"
242          */
243         public OvsdbPing(int ovsNumber, AtomicInteger result) {
244             this.host = getOvsdbAddress(ovsNumber);
245             this.port = Integer.parseInt(getOvsdbPort(ovsNumber));
246             this.result = result;
247             listDbsRequest = ByteBuffer.wrap(
248                     ("{\"method\": \"list_dbs\", \"params\": [], \"id\": " + port + "}").getBytes());
249             listDbsRequest.mark();
250         }
251
252         @Override
253         public void run() {
254             while (!doPing()) {
255                 try {
256                     Thread.sleep(1000);
257                 } catch (InterruptedException e) {
258                     LOG.warn("OvsdbPing interrupted", e);
259                     return;
260                 }
261             }
262         }
263
264         /**
265          * Attempt a "ping" of the OVSDB connection.
266          * @return true if the ping was successful OR IF THIS THREAD WAS INTERRUPTED
267          */
268         private boolean doPing() {
269             try (SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress(host, port))) {
270                 socketChannel.write(listDbsRequest);
271                 listDbsRequest.reset();
272
273                 ByteBuffer buf = ByteBuffer.allocateDirect(512);
274                 socketChannel.read(buf);
275                 buf.flip();
276                 String response = decoder.decode(buf).toString();
277
278                 if (response.contains("Open_vSwitch")) {
279                     LOG.info("OvsdbPing connection validated");
280                     result.incrementAndGet();
281                     return true;
282                 }
283             } catch (ClosedByInterruptException e) {
284                 LOG.warn("OvsdbPing interrupted", e);
285                 //return true here because we're done, ne'er to return again.
286                 return true;
287             } catch (Exception e) {
288                 LOG.info("OvsdbPing exception while attempting connect {}", e.toString());
289             }
290             return false;
291         }
292     }
293
294     /**
295      * Wait for all Ovs's to accept and respond to OVSDB requests.
296      * @param waitFor How long to wait
297      * @throws IOException if something goes wrong on the IO end
298      * @throws InterruptedException If this thread is interrupted
299      */
300     private void waitForOvsdbServers(long waitFor) throws IOException, InterruptedException {
301         AtomicInteger numRunningOvs = new AtomicInteger(0);
302
303         int numOvs = ovsdbPorts.size();
304         if (0 == numOvs) {
305             return;
306         }
307
308         OvsdbPing[] pingers = new OvsdbPing[numOvs];
309         for (int i = 0; i < numOvs; i++) {
310             pingers[i] = new OvsdbPing(i, numRunningOvs);
311             pingers[i].start();
312         }
313
314         long startTime = System.currentTimeMillis();
315         while ( (System.currentTimeMillis() - startTime) < waitFor) {
316             if (numRunningOvs.get() >= numOvs) {
317                 LOG.info("DockerOvs.waitForOvsdbServers all OVS instances running");
318                 break;
319             }
320             Thread.sleep(1000);
321         }
322         LOG.info("DockerOvs.waitForOvsdbServers - finished waiting in {}", System.currentTimeMillis() - startTime);
323
324         for (OvsdbPing pinger : pingers) {
325             pinger.interrupt();
326         }
327     }
328
329     /*
330     WIP - todo: need to extract teh service name from the yaml or receive it as a param
331     private void validateDockerComposeVersion() throws IOException, InterruptedException {
332         StringBuilder stringBuilder = new StringBuilder();
333         runProcess(2000, stringBuilder, HELP_CMD);
334         assertTrue("DockerOvs.validateDockerComposeVersion: docker-compose version does not support exec, try updating",
335                                                                     stringBuilder.toString().contains(" exec "));
336     }
337
338     public String exec(long waitFor, String... execCmdWords) throws IOException, InterruptedException {
339         List<String> execCmd = new ArrayList<String>(20);
340         execCmd.addAll(Arrays.asList(EXEC_CMD_PFX));
341         execCmd.add(tmpDockerComposeFile.toString());
342         execCmd.add("exec");
343         execCmd.add("ovs");
344         execCmd.addAll(Arrays.asList(execCmdWords));
345
346         StringBuilder stringBuilder = new StringBuilder();
347         runProcess(waitFor, stringBuilder, execCmd.toArray(new String[0]));
348         return stringBuilder.toString();
349     }
350     */
351
352     /**
353      * Run a process and assert the exit code is 0.
354      * @param waitFor How long to wait for the command to execute
355      * @param words The words of the command to run
356      * @throws IOException if something goes wrong on the IO end
357      * @throws InterruptedException If this thread is interrupted
358      */
359     private void runProcess(long waitFor, String... words) throws IOException, InterruptedException {
360         runProcess(waitFor, null, words);
361     }
362
363     /**
364      * Run a process, collect the stdout, and assert the exit code is 0.
365      * @param waitFor How long to wait for the command to execute
366      * @param capturedStdout Whatever the process wrote to standard out
367      * @param words The words of the command to run
368      * @throws IOException if something goes wrong on the IO end
369      * @throws InterruptedException If this thread is interrupted
370      */
371     private void runProcess(long waitFor,StringBuilder capturedStdout, String... words)
372                                                                         throws IOException, InterruptedException {
373         int exitValue = tryProcess(waitFor, capturedStdout, words);
374         Assert.assertEquals("DockerOvs.runProcess exit code is not 0", 0, exitValue);
375     }
376
377     /**
378      * Run a process.
379      * @param waitFor How long to wait for the command to execute
380      * @param words The words of the command to run
381      * @return The process's exit code
382      * @throws IOException if something goes wrong on the IO end
383      * @throws InterruptedException If this thread is interrupted
384      */
385     private int tryProcess(long waitFor, String... words) throws IOException, InterruptedException {
386         return tryProcess(waitFor, null, words);
387     }
388
389     /**
390      * Run a process, collect the stdout.
391      * @param waitFor How long to wait (milliseconds) for the command to execute
392      * @param capturedStdout Whatever the process wrote to standard out
393      * @param words The words of the command to run
394      * @return The process's exit code or -1 if the the command does not complete within waitFor milliseconds
395      * @throws IOException if something goes wrong on the IO end
396      * @throws InterruptedException If this thread is interrupted
397      */
398     private int tryProcess(long waitFor, StringBuilder capturedStdout, String... words)
399                                                                         throws IOException, InterruptedException {
400
401         LOG.info("DockerOvs.runProcess running \"{}\", waitFor {}", words, waitFor);
402
403         Process proc = new ProcessBuilder(words).start();
404         int exitValue = -1;
405
406         // Use a try block to guarantee stdout and stderr are closed
407         try (BufferedReader stdout = new BufferedReader(new InputStreamReader(proc.getInputStream()));
408         BufferedReader stderr = new BufferedReader(new InputStreamReader(proc.getErrorStream()))) {
409
410             exitValue = waitForExitValue(waitFor, proc);
411
412             while (stderr.ready()) {
413                 LOG.warn("DockerOvs.runProcess [stderr]: {}", stderr.readLine());
414             }
415
416             StringBuilder stdoutStringBuilder = (capturedStdout != null) ? capturedStdout : new StringBuilder();
417             int read;
418             char[] buf = new char[1024];
419             while (-1 != (read = stdout.read(buf))) {
420                 stdoutStringBuilder.append(buf, 0, read);
421             }
422
423             for (String line : stdoutStringBuilder.toString().split("\\n")) {
424                 LOG.info("DockerOvs.runProcess [stdout]: {}", line);
425             }
426         }
427
428         return exitValue;
429     }
430
431     /**
432      * Wait for a process to end.
433      * @param waitFor how long to wait in milliseconds
434      * @param proc Process object
435      * @return the process's exit value or -1 if the process did not complete within waitFor milliseconds
436      * @throws InterruptedException if this thread is interrupted
437      */
438     private int waitForExitValue(long waitFor, Process proc) throws InterruptedException {
439         //Java 7 has no way to check whether a process is still running without blocking
440         //until the process exits. What this hack does is checks the exitValue() which
441         //throws an IllegalStateException if the process is still running still it does
442         //not have a exit value. We catch that exception and implement our own timeout.
443         //Once we no longer need to support Java 7, this has more elegant solutions.
444         int exitValue = -1;
445         long startTime = System.currentTimeMillis();
446         while (true) {
447             try {
448                 exitValue = proc.exitValue();
449                 break;
450             } catch (IllegalThreadStateException e) {
451                 if ((System.currentTimeMillis() - startTime) < waitFor) {
452                     Thread.sleep(200);
453                 } else {
454                     LOG.warn("DockerOvs.waitForExitValue: timed out while waiting for command to complete", e);
455                     break;
456                 }
457             }
458         }
459         return exitValue;
460     }
461
462     /**
463      * Since the docker-compose file is a resource in the bundle and docker-compose needs it.
464      * in the file system, we copy it over - ugly but necessary.
465      * @param yamlFileName File name
466      * @return A File object for the newly created temporary yaml file.
467      */
468     private File createTempDockerComposeFile(String yamlFileName) {
469         Bundle bundle = FrameworkUtil.getBundle(this.getClass());
470         Assert.assertNotNull("DockerOvs: bundle is null", bundle);
471         URL url = bundle.getResource(DOCKER_FILE_PATH + yamlFileName);
472         Assert.assertNotNull("DockerOvs: URL is null", url);
473
474         File tmpFile = null;
475         try {
476             tmpFile = File.createTempFile("ovsdb-it-tmp-", null);
477
478             try (Reader in = new InputStreamReader(url.openStream());
479                                 FileWriter out = new FileWriter(tmpFile)) {
480                 char[] buf = new char[1024];
481                 int read;
482                 while (-1 != (read = in.read(buf))) {
483                     out.write(buf, 0, read);
484                 }
485             }
486
487         } catch (IOException e) {
488             Assert.fail(e.toString());
489         }
490
491         return tmpFile;
492     }
493
494 }