diff --git a/component/fontconfig/buildout.cfg b/component/fontconfig/buildout.cfg
index 06ba0b8d62bd5dee820cbff7935b3c35f1ccceb2..a621f3a21a3c40e4433807fd9a973a969cae3981 100644
--- a/component/fontconfig/buildout.cfg
+++ b/component/fontconfig/buildout.cfg
@@ -18,12 +18,12 @@ shared = true
 url = http://fontconfig.org/release/fontconfig-2.12.6.tar.bz2
 md5sum = 733f5e2371ca77b69707bd7b30cc2163
 pkg_config_depends = ${freetype:pkg_config_depends}:${freetype:location}/lib/pkgconfig:${libxml2:location}/lib/pkgconfig
-# XXX-Cedric : should we use --with-add-fonts={somefont:location}/share,{someotherfont:location}/share?
 configure-options =
   --disable-static
   --disable-docs
   --enable-libxml2
   --with-default-fonts=${fonts:location}
+  --with-add-fonts=no
 environment =
   PATH=${pkgconfig:location}/bin:${gperf:location}/bin:%(PATH)s
   PKG_CONFIG_PATH=${:pkg_config_depends}
diff --git a/component/fonts/buildout.cfg b/component/fonts/buildout.cfg
index a6ed31152817e722b8bdefd194eb3b6365816196..0475f88281ea13303c646943b988c388bf6f23aa 100644
--- a/component/fonts/buildout.cfg
+++ b/component/fonts/buildout.cfg
@@ -9,6 +9,7 @@ parts =
   ipa-fonts
   ocrb-fonts
   android-fonts
+  dejavu-fonts
 
 [fonts]
 location = ${buildout:parts-directory}/${:_buildout_section_name_}
@@ -50,6 +51,13 @@ md5sum = 2d41d5342eb5f61591ddeec5b80da74d
 environment =
   PATH=${xz-utils:location}/bin:%(PATH)s
 
+# The DejaVu fonts are a font family based upon Bitstream Vera v1.10. Its purpose is to
+# provide a wider range of characters while maintaining the original look-and-feel
+[dejavu-fonts]
+<= fonts-base
+url = https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.tar.bz2
+md5sum = d0efec10b9f110a32e9b8f796e21782c
+
 # Microsoft's TrueType core fonts
 # non-free so not enabled by default
 [msttcore-fonts]
diff --git a/component/graphviz/buildout.cfg b/component/graphviz/buildout.cfg
index 83d597df4cc281041de7943d0a44f2f92b000e1a..bcfa2d026dfe98d09e32b7d03536019a228a4cfb 100644
--- a/component/graphviz/buildout.cfg
+++ b/component/graphviz/buildout.cfg
@@ -9,12 +9,13 @@ extends =
   ../gtk-2/buildout.cfg
   ../pkgconfig/buildout.cfg
   ../zlib/buildout.cfg
+  ../libexpat/buildout.cfg
 
 [graphviz]
 recipe = slapos.recipe.cmmi
 shared = true
-url = http://www.graphviz.org/pub/graphviz/stable/SOURCES/graphviz-2.38.0.tar.gz
-md5sum = 5b6a829b2ac94efcd5fa3c223ed6d3ae
+url = https://ftp.osuosl.org/pub/blfs/conglomeration/graphviz/graphviz-2.40.1.tar.gz
+md5sum = 4ea6fd64603536406166600bcc296fc8
 pkg_config_depends = ${pango:location}/lib/pkgconfig:${pango:pkg_config_depends}
 configure-options =
   --with-included-ltdl
@@ -22,6 +23,7 @@ configure-options =
   --with-zlibdir=${zlib:location}/lib
   --with-freetype2
   --with-fontconfig
+  --with-expat
   --disable-swig
   --disable-sharp
   --disable-go
@@ -37,7 +39,6 @@ configure-options =
   --disable-ruby
   --disable-tcl
   --without-x
-  --without-expat
   --without-devil
   --without-webp
   --without-poppler
@@ -59,5 +60,5 @@ configure-options =
 environment =
   PATH=${pkgconfig:location}/bin:%(PATH)s
   PKG_CONFIG_PATH=${:pkg_config_depends}
-  CPPFLAGS=-I${zlib:location}/include
-  LDFLAGS=-L${bzip2:location}/lib -Wl,-rpath=${bzip2:location}/lib -L${zlib:location}/lib -Wl,-rpath=${zlib:location}/lib
+  CPPFLAGS=-I${zlib:location}/include -I${libexpat:location}/include
+  LDFLAGS=-L${bzip2:location}/lib -Wl,-rpath=${bzip2:location}/lib -L${zlib:location}/lib -Wl,-rpath=${zlib:location}/lib -L${libexpat:location}/lib -Wl,-rpath=${libexpat:location}/lib
diff --git a/component/tomcat/buildout.cfg b/component/tomcat/buildout.cfg
index 0a0d98b500d8b908f086fe2290993027888df606..99da212c2969df600137d59a521d06ab9c287a8d 100644
--- a/component/tomcat/buildout.cfg
+++ b/component/tomcat/buildout.cfg
@@ -23,6 +23,13 @@ strip-top-level-dir = true
 url = http://www-us.apache.org/dist/tomcat/tomcat-7/v7.0.91/bin/apache-tomcat-7.0.91.tar.gz
 md5sum = 8bfbb358b51f90374067879f8db1e91c
 
+[tomcat9]
+recipe = hexagonit.recipe.download
+ignore-existing = true
+strip-top-level-dir = true
+url = https://www-eu.apache.org/dist/tomcat/tomcat-9/v9.0.12/bin/apache-tomcat-9.0.12.tar.gz
+md5sum = 7283da4a3a6e939adcd8f919be4ba41a
+
 [tomcat7-output]
 # Shared binary location to ease migration
 recipe = plone.recipe.command
diff --git a/software/erp5testnode/testsuite/plantuml/README.md b/software/erp5testnode/testsuite/plantuml/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7228b2f6651aca868e580dcde53c0920805c0127
--- /dev/null
+++ b/software/erp5testnode/testsuite/plantuml/README.md
@@ -0,0 +1,7 @@
+# PlantUML test
+
+This software release is simply to run the test suite from `../../plantuml/test/setup.py`
+
+Nexedi staff can see the results of this test from the test suite
+`SLAPOS-PLANTUML-TEST` in test result module.
+
diff --git a/software/erp5testnode/testsuite/plantuml/buildout.hash.cfg b/software/erp5testnode/testsuite/plantuml/buildout.hash.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..50bcdcc84d552919d3ecc9b3da829b6949bfd8c6
--- /dev/null
+++ b/software/erp5testnode/testsuite/plantuml/buildout.hash.cfg
@@ -0,0 +1,19 @@
+# THIS IS NOT A BUILDOUT FILE, despite purposedly using a compatible syntax.
+# The only allowed lines here are (regexes):
+# - "^#" comments, copied verbatim
+# - "^[" section beginings, copied verbatim
+# - lines containing an "=" sign which must fit in the following categorie.
+#   - "^\s*filename\s*=\s*path\s*$" where "path" is relative to this file
+#     But avoid directories, they are not portable.
+#     Copied verbatim.
+#   - "^\s*hashtype\s*=.*" where "hashtype" is one of the values supported
+#     by the re-generation script.
+#     Re-generated.
+# - other lines are copied verbatim
+# Substitution (${...:...}), extension ([buildout] extends = ...) and
+# section inheritance (< = ...) are NOT supported (but you should really
+# not need these here).
+
+[template]
+filename = instance.cfg.in
+md5sum = 4ab7207a0440a904b4374add9b744312
diff --git a/software/erp5testnode/testsuite/plantuml/instance.cfg.in b/software/erp5testnode/testsuite/plantuml/instance.cfg.in
new file mode 100644
index 0000000000000000000000000000000000000000..fc0a8c12f1ed8299f165a9552c45a61cda8b8c0a
--- /dev/null
+++ b/software/erp5testnode/testsuite/plantuml/instance.cfg.in
@@ -0,0 +1,43 @@
+[buildout]
+parts =
+  slapos-test-runner
+
+eggs-directory = ${buildout:eggs-directory}
+develop-eggs-directory = ${buildout:develop-eggs-directory}
+offline = true
+
+[slap-configuration]
+recipe = slapos.cookbook:slapconfiguration
+computer = $${slap-connection:computer-id}
+partition = $${slap-connection:partition-id}
+url = $${slap-connection:server-url}
+key = $${slap-connection:key-file}
+cert = $${slap-connection:cert-file}
+
+[download-source]
+recipe = slapos.recipe.build:gitclone
+git-executable = ${git:location}/bin/git
+
+[slapos]
+<= download-source
+repository = ${slapos-repository:location}
+
+[create-directory]
+recipe = slapos.cookbook:mkdirectory
+bin = $${buildout:directory}/bin
+working-dir = $${buildout:directory}/tmp/
+
+[slapos-test-runner]
+recipe = slapos.cookbook:wrapper
+wrapper-path = $${create-directory:bin}/runTestSuite
+command-line =
+  ${buildout:bin-directory}/runTestSuite
+  --python_interpreter=${buildout:bin-directory}/${eggs:interpreter}
+  --source_code_path_list=$${slapos:location}/software/plantuml/test
+
+# XXX slapos.cookbook:wrapper does not allow extending env, so we add some default $PATH entries
+environment =
+  PATH=${buildout:bin-directory}:/usr/bin/:/bin/
+  LOCAL_IPV4=$${slap-configuration:ipv4-random}
+  GLOBAL_IPV6=$${slap-configuration:ipv6-random}
+  SLAPOS_TEST_WORKING_DIR=$${create-directory:working-dir}
diff --git a/software/erp5testnode/testsuite/plantuml/software.cfg b/software/erp5testnode/testsuite/plantuml/software.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..90f759412be5a44b97ead0db8997030305541273
--- /dev/null
+++ b/software/erp5testnode/testsuite/plantuml/software.cfg
@@ -0,0 +1,58 @@
+[buildout]
+
+extends =
+  ../../../../component/git/buildout.cfg
+  ../../../../component/pillow/buildout.cfg
+  ../../../../stack/slapos.cfg
+  ./buildout.hash.cfg
+
+parts =
+  slapos-cookbook
+  eggs
+  template
+
+[setup-develop-egg]
+recipe = zc.recipe.egg:develop
+
+[slapos.test.plantuml-setup]
+<= setup-develop-egg
+egg = slapos.test.plantuml
+setup = ${slapos-repository:location}/software/plantuml/test/
+
+[eggs]
+recipe = zc.recipe.egg
+eggs =
+  ${pillow-python:egg}
+  ${slapos.test.plantuml-setup:egg}
+  slapos.core
+entry-points =
+  runTestSuite=erp5.util.testsuite:runTestSuite
+scripts =
+  runTestSuite
+  slapos
+interpreter=
+  python_for_test
+
+[git-clone-repository]
+recipe = slapos.recipe.build:gitclone
+git-executable = ${git:location}/bin/git
+forbid-download-cache = true
+branch = master
+
+[slapos-repository]
+<= git-clone-repository
+repository = https://lab.nexedi.com/nexedi/slapos.git
+
+[template]
+recipe = slapos.recipe.template
+url = ${:_profile_base_location_}/${:filename}
+output = ${buildout:directory}/template.cfg
+mode = 640
+
+[versions]
+erp5.util = 0.4.56
+slapos.recipe.template = 4.3
+plantuml = 0.1.1
+httplib2 = 0.11.3
+image = 1.5.25
+Pillow = 5.3.0
\ No newline at end of file
diff --git a/software/plantuml/README.md b/software/plantuml/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..a86e3eb1527449e02632325b4cae2200099b772f
--- /dev/null
+++ b/software/plantuml/README.md
@@ -0,0 +1,14 @@
+# PlantUML
+
+http://plantuml.com/
+
+PlantUML is a service rendering UML diagrams defined in a simple and intuitive
+language.
+
+Each diagram has a unique URL which is made of an encoded version of the
+diagram code.
+
+Diagrams can be rendered as png, svg or ascii art text.
+
+See http://plantuml.com/PlantUML_Language_Reference_Guide.pdf for a full
+reference on the diagram language.
diff --git a/software/plantuml/buildout.hash.cfg b/software/plantuml/buildout.hash.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..3c9bd1c32243ff58d61d088229283f40ddccda17
--- /dev/null
+++ b/software/plantuml/buildout.hash.cfg
@@ -0,0 +1,26 @@
+# THIS IS NOT A BUILDOUT FILE, despite purposedly using a compatible syntax.
+# The only allowed lines here are (regexes):
+# - "^#" comments, copied verbatim
+# - "^[" section beginings, copied verbatim
+# - lines containing an "=" sign which must fit in the following categorie.
+#   - "^\s*filename\s*=\s*path\s*$" where "path" is relative to this file
+#     Copied verbatim.
+#   - "^\s*hashtype\s*=.*" where "hashtype" is one of the values supported
+#     by the re-generation script.
+#     Re-generated.
+# - other lines are copied verbatim
+# Substitution (${...:...}), extension ([buildout] extends = ...) and
+# section inheritance (< = ...) are NOT supported (but you should really
+# not need these here).
+
+[instance]
+filename = instance.cfg.in
+md5sum = 8915151103355dd59da31979a14e59fd
+
+[tomcat-server-xml]
+filename = server.xml.in
+md5sum = fdfa7eb249082855039ca98f310324e9
+
+[font.conf]
+filename = font.conf.in
+md5sum = caa3463c9c3766ac5f2396a517d6f926
diff --git a/software/plantuml/font.conf.in b/software/plantuml/font.conf.in
new file mode 100644
index 0000000000000000000000000000000000000000..5c0ad3759e1d77e646f4d26a0c7d2e679c11039c
--- /dev/null
+++ b/software/plantuml/font.conf.in
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
+<fontconfig>
+  <cachedir>$${:fontcache}</cachedir>
+  <!-- installed fonts: $${:installed-fonts} -->
+  <dir>${fonts:location}</dir>
+  <dir>$${:fonts}</dir>
+  <include>${fontconfig:location}/etc/fonts/conf.d</include>
+</fontconfig>
\ No newline at end of file
diff --git a/software/plantuml/instance.cfg.in b/software/plantuml/instance.cfg.in
new file mode 100644
index 0000000000000000000000000000000000000000..a61c467517f55159a2b5572277f1a230ee10e171
--- /dev/null
+++ b/software/plantuml/instance.cfg.in
@@ -0,0 +1,123 @@
+[buildout]
+parts =
+  promises
+  publish-connection-parameter
+
+eggs-directory = ${buildout:eggs-directory}
+develop-eggs-directory = ${buildout:develop-eggs-directory}
+offline = true
+
+[fontconfig-conf]
+recipe = slapos.recipe.template
+url = ${font.conf:output}
+output = $${directory:etc}/font.conf
+
+fonts = $${directory:fonts}
+fontcache = $${directory:fontcache}
+installed-fonts =
+  ${liberation-fonts:location}
+  ${ipaex-fonts:location}
+  ${ipa-fonts:location}
+  ${ocrb-fonts:location}
+  ${android-fonts:location}
+  ${dejavu-fonts:location}
+
+[tomcat-server-xml]
+recipe = slapos.recipe.template
+url = ${tomcat-server-xml:output}
+output = $${directory:catalina_conf}/server.xml
+ip = $${instance-parameter:ipv6-random}
+port = 8899
+scheme = https
+
+[tomcat-web-xml]
+recipe = plone.recipe.command
+command = [ -f $${:location} ] || cp ${tomcat9:location}/conf/web.xml $${:location}
+location = $${directory:catalina_conf}/web.xml
+
+[tomcat-keystore]
+recipe = plone.recipe.command
+command =
+  ${java-re-8-output:keytool} \
+    -genkeypair \
+    -alias "tomcat" \
+    -keyalg RSA \
+    -keypass "$${:pass}" \
+    -dname "CN=Web Server,OU=Unit,O=Organization,L=City,S=State,C=Country" \
+    -keystore "$${:file}" \
+    -storepass "$${:pass}"
+file = $${directory:catalina_base}/.keystore
+pass = insecure
+
+[tomcat-instance]
+recipe = slapos.cookbook:wrapper
+wrapper-path = $${directory:services}/$${:_buildout_section_name_}
+command-line = ${tomcat9:location}/bin/catalina.sh run
+environment =
+  JRE_HOME=${java-re-8:location}
+  CATALINA_BASE=$${directory:catalina_base}
+  GRAPHVIZ_DOT=${graphviz:location}/bin/dot
+  FONTCONFIG_FILE=$${fontconfig-conf:output}
+  LD_LIBRARY_PATH=${fontconfig:location}/lib:${freetype:location}/lib
+# XXX java is still loading system fonts ... ( even with $JAVA_FONTS or -Djava.awt.fonts )
+# related links:
+#   https://docs.oracle.com/javase/8/docs/technotes/guides/intl/fontconfig.html
+#   https://bugs.openjdk.java.net/browse/JDK-7175487
+hash-files =
+  $${buildout:directory}/software_release/buildout.cfg
+  $${tomcat-server-xml:output}
+
+ip =  $${tomcat-server-xml:ip}
+port =  $${tomcat-server-xml:port}
+scheme = $${tomcat-server-xml:scheme}
+hostname =  [$${:ip}]
+url = $${:scheme}://$${:hostname}:$${:port}
+needs = $${tomcat-web-xml:location}
+
+[promises]
+recipe =
+instance-promises =
+  $${tomcat-listen-promise:path}
+
+[check-port-listening-promise]
+recipe = slapos.cookbook:check_port_listening
+path = $${directory:promises}/$${:_buildout_section_name_}
+
+[tomcat-listen-promise]
+<= check-port-listening-promise
+hostname= $${tomcat-instance:ip}
+port = $${tomcat-instance:port}
+
+[publish-connection-parameter]
+recipe = slapos.cookbook:publish
+url = $${tomcat-instance:url}
+
+
+[instance-parameter]
+recipe = slapos.cookbook:slapconfiguration
+computer = $${slap-connection:computer-id}
+partition = $${slap-connection:partition-id}
+url = $${slap-connection:server-url}
+key = $${slap-connection:key-file}
+cert = $${slap-connection:cert-file}
+
+[directory]
+recipe = slapos.cookbook:mkdirectory
+etc = $${buildout:directory}/etc
+var = $${buildout:directory}/var
+srv = $${buildout:directory}/srv
+bin = $${buildout:directory}/bin
+tmp = $${buildout:directory}/tmp
+services = $${:etc}/service
+promises = $${:etc}/promise
+
+fonts = $${:srv}/fonts/
+fontcache = $${buildout:directory}/.fontcache/
+
+# tomcat directories
+catalina_base = $${:var}/tomcat
+catalina_logs = $${:catalina_base}/logs
+catalina_temp = $${:catalina_base}/temp
+catalina_webapps = $${:catalina_base}/webapps
+catalina_work = $${:catalina_base}/work
+catalina_conf = $${:catalina_base}/conf
\ No newline at end of file
diff --git a/software/plantuml/server.xml.in b/software/plantuml/server.xml.in
new file mode 100644
index 0000000000000000000000000000000000000000..a22254729b8be55d19bf8905e83297d0987d1309
--- /dev/null
+++ b/software/plantuml/server.xml.in
@@ -0,0 +1,30 @@
+<?xml version='1.0' encoding='utf-8'?>
+<Server port="-1" shutdown="SHUTDOWN">
+  <Service name="Catalina">
+    <Connector
+      protocol="org.apache.coyote.http11.Http11Nio2Protocol"
+      sslImplementationName="org.apache.tomcat.util.net.jsse.JSSEImplementation"
+      address="$${tomcat-server-xml:ip}"
+      port="$${tomcat-server-xml:port}"
+      maxThreads="10"
+      scheme="$${tomcat-server-xml:scheme}"
+      secure="true"
+      clientAuth="false"
+      SSLEnabled="true"
+      keystorePass="$${tomcat-keystore:pass}"
+      keystoreFile="$${tomcat-keystore:file}"
+    />
+
+    <Engine name="Catalina" defaultHost="localhost">
+      <Valve className="org.apache.catalina.valves.AccessLogValve"
+             directory="logs" prefix="localhost_access_log." suffix=".log"
+             pattern="common"/>
+      <Host name="localhost"  appBase="webapps"
+            unpackWARs="true" autoDeploy="true">
+        <Context path="" docBase="${plantuml.war:location}/plantuml.war"
+           privileged="true">
+        </Context>
+      </Host>
+    </Engine>
+  </Service>
+</Server>
\ No newline at end of file
diff --git a/software/plantuml/software.cfg b/software/plantuml/software.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..46fe92a823b3521f02242ad16711015b2bd15635
--- /dev/null
+++ b/software/plantuml/software.cfg
@@ -0,0 +1,39 @@
+[buildout]
+extends =
+  ../../stack/slapos.cfg
+  ../../stack/nodejs.cfg
+  ../../component/fontconfig/buildout.cfg
+  ../../component/freetype/buildout.cfg
+  ../../component/graphviz/buildout.cfg
+  ../../component/java/buildout.cfg
+  ../../component/tomcat/buildout.cfg
+  ../../component/fonts/buildout.cfg
+  buildout.hash.cfg
+
+parts =
+  slapos-cookbook
+  instance
+
+[instance]
+recipe = slapos.recipe.template
+url = ${:_profile_base_location_}/${:filename}
+output = ${buildout:directory}/instance.cfg
+
+[tomcat-server-xml]
+recipe = slapos.recipe.template
+url = ${:_profile_base_location_}/${:filename}
+output = ${buildout:directory}/${:_buildout_section_name_}
+
+[font.conf]
+recipe = slapos.recipe.template
+url = ${:_profile_base_location_}/${:filename}
+output = ${buildout:directory}/${:_buildout_section_name_}
+
+[plantuml.war]
+recipe = slapos.recipe.build:download
+# XXX the war from sourceforge has no version in URL
+url = https://netcologne.dl.sourceforge.net/project/plantuml/plantuml.war#2018-10-21
+md5sum = f956cd28b18ec34740bb1757276f9641
+
+[versions]
+slapos.recipe.template = 4.3
diff --git a/software/plantuml/test/README.md b/software/plantuml/test/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..a8045b99fb8d2e25a970af4028049d69687b1452
--- /dev/null
+++ b/software/plantuml/test/README.md
@@ -0,0 +1 @@
+Tests for PlantUML software release
diff --git a/software/plantuml/test/data/test_ascii_art.txt b/software/plantuml/test/data/test_ascii_art.txt
new file mode 100644
index 0000000000000000000000000000000000000000..f6d896b5c4ea419cb09c510729055058419387a7
--- /dev/null
+++ b/software/plantuml/test/data/test_ascii_art.txt
@@ -0,0 +1,11 @@
+     ┌───┐          ┌─────┐
+     │Bob│          │Alice│
+     └─┬─┘          └──┬──┘
+       │    hello      │   
+       │──────────────>│   
+       │               │   
+       │   Go Away     │   
+       │<──────────────│   
+     ┌─┴─┐          ┌──┴──┐
+     │Bob│          │Alice│
+     └───┘          └─────┘
diff --git a/software/plantuml/test/data/test_class_diagram.png b/software/plantuml/test/data/test_class_diagram.png
new file mode 100644
index 0000000000000000000000000000000000000000..2a3c6d3d17a89914cd44fce81d7b6532e40e3591
Binary files /dev/null and b/software/plantuml/test/data/test_class_diagram.png differ
diff --git a/software/plantuml/test/data/test_fonts.png b/software/plantuml/test/data/test_fonts.png
new file mode 100644
index 0000000000000000000000000000000000000000..16ac44163916f06235ca7e4b28c4320ce3f40374
Binary files /dev/null and b/software/plantuml/test/data/test_fonts.png differ
diff --git a/software/plantuml/test/data/test_sequence_diagram.png b/software/plantuml/test/data/test_sequence_diagram.png
new file mode 100644
index 0000000000000000000000000000000000000000..f6c7eea1455e33dce075dc2eaa281a1f971f813e
Binary files /dev/null and b/software/plantuml/test/data/test_sequence_diagram.png differ
diff --git a/software/plantuml/test/setup.py b/software/plantuml/test/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..25925a9a695a83f52296fe15985a5b854749edca
--- /dev/null
+++ b/software/plantuml/test/setup.py
@@ -0,0 +1,55 @@
+##############################################################################
+#
+# Copyright (c) 2018 Nexedi SA and Contributors. All Rights Reserved.
+#
+# WARNING: This program as such is intended to be used by professional
+# programmers who take the whole responsibility of assessing all potential
+# consequences resulting from its eventual inadequacies and bugs
+# End users who are looking for a ready-to-use solution with commercial
+# guarantees and support are strongly adviced to contract a Free Software
+# Service Company
+#
+# This program is Free Software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 3
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
+#
+##############################################################################
+from setuptools import setup, find_packages
+import glob
+import os
+
+version = '0.0.1.dev0'
+name = 'slapos.test.plantuml'
+long_description = open("README.md").read()
+
+setup(name=name,
+      version=version,
+      description="Test for SlapOS' PlantUML",
+      long_description=long_description,
+      long_description_content_type='text/markdown',
+      maintainer="Nexedi",
+      maintainer_email="info@nexedi.com",
+      url="https://lab.nexedi.com/nexedi/slapos",
+      packages=find_packages(),
+      install_requires=[
+        'slapos.core',
+        'slapos.libnetworkcache',
+        'erp5.util',
+        'supervisor',
+        'psutil',
+        'plantuml',
+        'requests'
+        ],
+      zip_safe=True,
+      test_suite='test',
+    )
diff --git a/software/plantuml/test/test.py b/software/plantuml/test/test.py
new file mode 100644
index 0000000000000000000000000000000000000000..f003abeb9c1072249f7ede9189caef431a1c05f6
--- /dev/null
+++ b/software/plantuml/test/test.py
@@ -0,0 +1,188 @@
+##############################################################################
+# coding: utf-8
+#
+# Copyright (c) 2018 Nexedi SA and Contributors. All Rights Reserved.
+#
+# WARNING: This program as such is intended to be used by professional
+# programmers who take the whole responsibility of assessing all potential
+# consequences resulting from its eventual inadequacies and bugs
+# End users who are looking for a ready-to-use solution with commercial
+# guarantees and support are strongly adviced to contract a Free Software
+# Service Company
+#
+# This program is Free Software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 3
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
+#
+##############################################################################
+
+import os
+import textwrap
+import hashlib
+from io import BytesIO
+
+from PIL import Image
+import requests
+import plantuml
+
+import utils
+
+# for development: debugging logs and install Ctrl+C handler
+if os.environ.get('DEBUG'):
+  import logging
+  logging.basicConfig(level=logging.DEBUG)
+  import unittest
+  unittest.installHandler()
+
+
+class PlantUMLTestCase(utils.SlapOSInstanceTestCase):
+  @classmethod
+  def getSoftwareURLList(cls):
+    return (os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'software.cfg')), )
+
+
+class TestSimpleDiagram(PlantUMLTestCase):
+  def setUp(self):
+    self.url = self.computer_partition.getConnectionParameterDict()["url"]
+    self.plantuml = plantuml.PlantUML(
+      url='{}/png/'.format(self.url),
+      http_opts={"disable_ssl_certificate_validation": True}
+    )
+
+  def assertImagesSimilar(self, i1, i2, tolerance=5):
+    """Assert images difference between images is less than `tolerance` %.
+   taken from https://rosettacode.org/wiki/Percentage_difference_between_images
+    """
+    pairs = zip(i1.getdata(), i2.getdata())
+    if len(i1.getbands()) == 1:
+      # for gray-scale jpegs
+      dif = sum(abs(p1-p2) for p1,p2 in pairs)
+    else:
+      dif = sum(abs(c1-c2) for p1,p2 in pairs for c1,c2 in zip(p1,p2))
+
+    ncomponents = i1.size[0] * i1.size[1] * 3
+    self.assertLessEqual((dif / 255.0 * 100) / ncomponents, tolerance)
+
+  def assertImagesSame(self, i1, i2):
+    """Assert images are exactly same."""
+    self.assertImagesSimilar(i1, i2, 0)
+
+  def test_sequence_diagram(self):
+    png = self.plantuml.processes(textwrap.dedent("""\
+    @startuml
+    Bob -> Alice : hello
+    Alice -> Bob : Go Away
+    @enduml
+    """))
+    # we cannot just compare the hash of the image against a reference that can be found with
+    # http://www.plantuml.com/plantuml/png/SoWkIImgAStDuNBAJrBGjLDmpCbCJbMmKiX8pSd9vuBmWC8WMIi5ztm5n_B4IYw7rBmKe1u0
+    # because plantuml include information about the server in the output image metadata ( you can
+    # use http://exif.regex.info/exif.cgi to see metadata )
+    # So we process the image to remove metadata.
+    reference = Image.open(os.path.join(os.path.dirname(__file__), "data", "test_sequence_diagram.png"))
+    self.assertImagesSame(Image.open(BytesIO(png)), reference)
+
+  def test_class_diagram(self):
+    """Class diagram require a working graphviz installation"""
+    png = self.plantuml.processes(textwrap.dedent("""\
+    @startuml
+    class Car
+
+    Driver - Car : drives >
+    Car *- Wheel : have 4 >
+    Car -- Person : < owns
+
+    @enduml
+    """))
+    # rendering is not exactly same on class diagrams, because of fonts and maybe also something in graphviz.
+    # We just compare that image are similar.
+
+    # http://www.plantuml.com/plantuml/png/SoWkIImgAStDuKhEIImkLd1EBEBYSYdAB4ijKj05yHIi5590t685EouGLqjN8JmZDJK7A9wHM9QgO08LrzLL24WjAixF0qhOAEINvnLpSJcavgK0ZGO0
+    reference = Image.open(os.path.join(os.path.dirname(__file__), "data", "test_class_diagram.png"))
+    self.assertImagesSimilar(Image.open(BytesIO(png)), reference)
+
+  def test_fonts(self):
+    """Test slapos provided fonts are used"""
+    png = self.plantuml.processes(textwrap.dedent("""\
+    @startuml
+    listfonts 私は申し訳ありません:私は日本語を話さない。Je ne parle pas japonais.
+    @enduml
+    """))
+    # URL on the reference implementation would be
+    # http://www.plantuml.com/plantuml/png/SoWkIImgAStDuSh9B2v9oyyhALPulhpnSUFwvrCsFswS_c85a6nwtDJrk77VuyRPZviclzyp2wBWsVIbp-QiUR5gtkEcIIzMRdpSEFLnuwh7ZIsF6vgyKXNoKXKA4ejoG6InGbPYGNvUOcQn7fT3QbuAq3O0
+    # but we don't have same fonts, so we compare against the fonts of a slapos instance.
+    reference = Image.open(os.path.join(os.path.dirname(__file__), "data", "test_fonts.png"))
+    self.assertImagesSimilar(Image.open(BytesIO(png)), reference)
+
+  def test_editor(self):
+    """Test the embedded editor"""
+    r = requests.get('{}/uml/'.format(self.url), verify=False)
+    self.assertEqual(r.status_code, requests.codes.ok)
+
+  def test_svg(self):
+    """Test svg rendering"""
+    image_key = plantuml.deflate_and_encode(textwrap.dedent("""\
+    @startuml
+    Bob -> Alice : hello
+    Alice -> Bob : Go Away
+    @enduml
+    """))
+    svg = requests.get('{}/svg/{}'.format(self.url, image_key), verify=False).text
+    self.assertIn('<?xml version="1.0" encoding="UTF-8"', svg)
+
+  def test_ascii_art(self):
+    """Test ascii art rendering"""
+    image_key = plantuml.deflate_and_encode(textwrap.dedent("""\
+    @startuml
+    Bob -> Alice : hello
+    Alice -> Bob : Go Away
+    @enduml
+    """))
+    aa = requests.get('{}/txt/{}'.format(self.url, image_key), verify=False).content
+    with open(os.path.join(os.path.dirname(__file__), "data", "test_ascii_art.txt"), 'rb') as reference:
+      self.assertEqual(aa, reference.read())
+
+
+class ServicesTestCase(PlantUMLTestCase):
+  @staticmethod
+  def generateHashFromFiles(file_list):
+    hasher = hashlib.md5()
+    for path in file_list:
+      with open(path, 'r') as afile:
+        buf = afile.read()
+      hasher.update("%s\n" % len(buf))
+      hasher.update(buf)
+    hash = hasher.hexdigest()
+    return hash
+
+  def test_hashes(self):
+    hash_files = [
+      'software_release/buildout.cfg',
+      'var/tomcat/conf/server.xml'
+    ]
+    expected_process_names = [
+      'tomcat-instance-{hash}-on-watch',
+    ]
+
+    supervisor = self.getSupervisorRPCServer().supervisor
+    process_names = [process['name']
+                     for process in supervisor.getAllProcessInfo()]
+
+    hash_files = [os.path.join(self.computer_partition_root_path, path)
+                  for path in hash_files]
+
+    for name in expected_process_names:
+      h = ServicesTestCase.generateHashFromFiles(hash_files)
+      expected_process_name = name.format(hash=h)
+
+      self.assertIn(expected_process_name, process_names)
diff --git a/software/plantuml/test/utils.py b/software/plantuml/test/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..9590d72bf8ef36755e0096e482c9de421d01d6b4
--- /dev/null
+++ b/software/plantuml/test/utils.py
@@ -0,0 +1,321 @@
+##############################################################################
+#
+# Copyright (c) 2018 Nexedi SA and Contributors. All Rights Reserved.
+#
+# WARNING: This program as such is intended to be used by professional
+# programmers who take the whole responsibility of assessing all potential
+# consequences resulting from its eventual inadequacies and bugs
+# End users who are looking for a ready-to-use solution with commercial
+# guarantees and support are strongly adviced to contract a Free Software
+# Service Company
+#
+# This program is Free Software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 3
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
+#
+##############################################################################
+
+import unittest
+import os
+import socket
+from contextlib import closing
+import logging
+import StringIO
+import xmlrpclib
+
+import supervisor.xmlrpc
+from erp5.util.testnode.SlapOSControler import SlapOSControler
+from erp5.util.testnode.ProcessManager import ProcessManager
+
+
+# Utility functions
+def findFreeTCPPort(ip=''):
+  """Find a free TCP port to listen to.
+  """
+  family = socket.AF_INET6 if ':' in ip else socket.AF_INET
+  with closing(socket.socket(family, socket.SOCK_STREAM)) as s:
+    s.bind((ip, 0))
+    return s.getsockname()[1]
+
+
+# TODO:
+#  - allow requesting multiple instances ?
+
+class SlapOSInstanceTestCase(unittest.TestCase):
+  """Install one slapos instance.
+
+  This test case install software(s) and request one instance during `setUpClass`
+  and destroy the instance during `tearDownClass`.
+
+  Software Release URL, Instance Software Type and Instance Parameters can be defined
+  on the class.
+
+  All tests from the test class will run with the same instance.
+  
+  The following class attributes are available:
+
+    * `computer_partition`:  the `slapos.core.XXX` computer partition instance.
+
+    * `computer_partition_root_path`: the path of the instance root directory,
+        
+  """
+  
+  # Methods to be defined by subclasses.
+  @classmethod
+  def getSoftwareURLList(cls):
+    """Return URL of software releases to install.
+
+    To be defined by subclasses.
+    """
+    raise NotImplementedError()
+
+  @classmethod
+  def getInstanceParameterDict(cls):
+    """Return instance parameters
+
+    To be defined by subclasses if they need to request instance with specific
+    parameters.
+    """
+    return {}
+
+  @classmethod
+  def getInstanceSoftwareType(cls):
+    """Return software type for instance, default "default"
+
+    To be defined by subclasses if they need to request instance with specific
+    software type.
+    """
+    return "default"
+
+  # Utility methods.
+  def getSupervisorRPCServer(self):
+    """Returns a XML-RPC connection to the supervisor used by slapos node
+
+    Refer to http://supervisord.org/api.html for details of available methods.
+    """
+    # xmlrpc over unix socket https://stackoverflow.com/a/11746051/7294664
+    return xmlrpclib.ServerProxy(
+       'http://slapos-supervisor',
+       transport=supervisor.xmlrpc.SupervisorTransport(
+           None,
+           None,
+           # XXX hardcoded socket path
+           serverurl="unix://{working_directory}/inst/supervisord.socket".format(
+             **self.config)))
+
+  # Unittest methods
+  @classmethod
+  def setUpClass(cls):
+    """Setup the class, build software and request an instance.
+    
+    If you have to override this method, do not forget to call this method on
+    parent class.
+    """
+    try:
+      cls.setUpWorkingDirectory()
+      cls.setUpConfig()
+      cls.setUpSlapOSController()
+
+      cls.runSoftwareRelease()
+      # XXX instead of "runSoftwareRelease", it would be better to be closer to slapos usage:
+      # cls.supplySoftwares()
+      # cls.installSoftwares()
+
+      cls.runComputerPartition()
+      # XXX instead of "runComputerPartition", it would be better to be closer to slapos usage:
+      # cls.requestInstances()
+      # cls.createInstances()
+      # cls.requestInstances()
+
+    except BaseException:
+      cls.stopSlapOSProcesses()
+      cls.setUp = lambda self: self.fail('Setup Class failed.')
+      raise
+
+  @classmethod
+  def tearDownClass(cls):
+    """Tear down class, stop the processes and destroy instance.
+    """
+    cls.stopSlapOSProcesses()
+
+  # Implementation
+  @classmethod
+  def stopSlapOSProcesses(cls):
+    if hasattr(cls, '_process_manager'):
+      cls._process_manager.killPreviousRun()
+
+  @classmethod
+  def setUpWorkingDirectory(cls):
+    """Initialise the directories"""
+    cls.working_directory = os.environ.get(
+        'SLAPOS_TEST_WORKING_DIR',
+        os.path.join(os.path.dirname(__file__), '.slapos'))
+    # To prevent error: Cannot open an HTTP server: socket.error reported
+    # AF_UNIX path too long This `working_directory` should not be too deep.
+    # Socket path is 108 char max on linux
+    # https://github.com/torvalds/linux/blob/3848ec5/net/unix/af_unix.c#L234-L238
+    # Supervisord socket name contains the pid number, which is why we add
+    # .xxxxxxx in this check.
+    if len(cls.working_directory + '/inst/supervisord.socket.xxxxxxx') > 108:
+      raise RuntimeError('working directory ( {} ) is too deep, try setting '
+              'SLAPOS_TEST_WORKING_DIR'.format(cls.working_directory))
+
+    if not os.path.exists(cls.working_directory):
+      os.mkdir(cls.working_directory)
+
+  @classmethod
+  def setUpConfig(cls):
+    """Create slapos configuration"""
+    cls.config = {
+      "working_directory": cls.working_directory,
+      "slapos_directory": cls.working_directory,
+      "log_directory": cls.working_directory,
+      "computer_id": 'slapos.test',  # XXX
+      'proxy_database': os.path.join(cls.working_directory, 'proxy.db'),
+      'partition_reference': cls.__name__,
+      # "proper" slapos command must be in $PATH
+      'slapos_binary': 'slapos',
+    }
+    # Some tests are expecting that local IP is not set to 127.0.0.1
+    ipv4_address = os.environ.get('LOCAL_IPV4', '127.0.1.1')
+    ipv6_address = os.environ['GLOBAL_IPV6']
+
+    cls.config['proxy_host'] = cls.config['ipv4_address'] = ipv4_address
+    cls.config['ipv6_address'] = ipv6_address
+    cls.config['proxy_port'] = findFreeTCPPort(ipv4_address)
+    cls.config['master_url'] = 'http://{proxy_host}:{proxy_port}'.format(
+      **cls.config)
+
+  @classmethod
+  def setUpSlapOSController(cls):
+    """Create the a "slapos controller" and supply softwares from `getSoftwareURLList`.
+
+    This is equivalent to:
+
+    slapos proxy start
+    for sr in getSoftwareURLList; do
+      slapos supply $SR $COMP
+    done
+    """ 
+    cls._process_manager = ProcessManager()
+
+    # XXX this code is copied from testnode code
+    cls.slapos_controler = SlapOSControler(
+        cls.working_directory,
+        cls.config
+    )
+
+    slapproxy_log = os.path.join(cls.config['log_directory'], 'slapproxy.log')
+    logger = logging.getLogger(__name__)
+    logger.debug('Configured slapproxy log to %r', slapproxy_log)
+
+    cls.software_url_list = cls.getSoftwareURLList()
+    cls.slapos_controler.initializeSlapOSControler(
+        slapproxy_log=slapproxy_log,
+        process_manager=cls._process_manager,
+        reset_software=False,
+        software_path_list=cls.software_url_list)
+
+    # XXX we should check *earlier* if that pidfile exist and if supervisord
+    # process still running, because if developer started supervisord (or bugs?)
+    # then another supervisord will start and starting services a second time
+    # will fail.
+    cls._process_manager.supervisord_pid_file = os.path.join(
+      cls.slapos_controler.instance_root, 'var', 'run', 'supervisord.pid')
+
+  @classmethod
+  def runSoftwareRelease(cls):
+    """Run all the software releases that were supplied before.
+
+    This is the equivalent of `slapos node software`.
+
+    The tests will be marked file if software building fail.
+    """
+    logger = logging.getLogger()
+    logger.level = logging.DEBUG
+    stream = StringIO.StringIO()
+    stream_handler = logging.StreamHandler(stream)
+    logger.addHandler(stream_handler)
+
+    try:
+      cls.software_status_dict = cls.slapos_controler.runSoftwareRelease(
+        cls.config, environment=os.environ)
+      stream.seek(0)
+      stream.flush()
+      message = ''.join(stream.readlines()[-100:])
+      assert cls.software_status_dict['status_code'] == 0, message
+    finally:
+      logger.removeHandler(stream_handler)
+      del stream
+
+
+  @classmethod
+  def runComputerPartition(cls):
+    """Instanciate the software.
+
+    This is the equivalent of doing:
+
+    slapos request --type=getInstanceSoftwareType --parameters=getInstanceParameterDict
+    slapos node instance
+
+    and return the slapos request instance parameters.
+
+    This can be called by tests to simulate re-request with different parameters.
+    """
+    logger = logging.getLogger()
+    logger.level = logging.DEBUG
+    stream = StringIO.StringIO()
+    stream_handler = logging.StreamHandler(stream)
+    logger.addHandler(stream_handler)
+
+    if cls.getInstanceSoftwareType() != 'default':
+      raise NotImplementedError
+
+    instance_parameter_dict = cls.getInstanceParameterDict()
+    try:
+      cls.instance_status_dict = cls.slapos_controler.runComputerPartition(
+        cls.config,
+        cluster_configuration=instance_parameter_dict,
+        environment=os.environ)
+      stream.seek(0)
+      stream.flush()
+      message = ''.join(stream.readlines()[-100:])
+      assert cls.instance_status_dict['status_code'] == 0, message
+    finally:
+      logger.removeHandler(stream_handler)
+      del stream
+
+    # FIXME: similar to test node, only one (root) partition is really
+    #        supported for now.
+    computer_partition_list = []
+    for i in range(len(cls.software_url_list)):
+      computer_partition_list.append(
+          cls.slapos_controler.slap.registerOpenOrder().request(
+            cls.software_url_list[i],
+            # This is how testnode's SlapOSControler name created partitions
+            partition_reference='testing partition {i}'.format(
+              i=i, **cls.config),
+            partition_parameter_kw=instance_parameter_dict))
+
+    # expose some class attributes so that tests can use them:
+    # the ComputerPartition instances, to getInstanceParameterDict
+    cls.computer_partition = computer_partition_list[0]
+
+    # the path of the instance on the filesystem, for low level inspection
+    cls.computer_partition_root_path = os.path.join(
+        cls.config['working_directory'],
+        'inst',
+        cls.computer_partition.getId())
+
+
+