diff --git a/examples/phoenix_project/mix.lock b/examples/phoenix_project/mix.lock index 322f72f6..a124252b 100644 --- a/examples/phoenix_project/mix.lock +++ b/examples/phoenix_project/mix.lock @@ -1,11 +1,11 @@ %{ - "castore": {:hex, :castore, "1.0.9", "5cc77474afadf02c7c017823f460a17daa7908e991b0cc917febc90e466a375c", [:mix], [], "hexpm", "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"}, - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, - "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, - "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.1.1", "73b4b2c3ec692f8a64276c43f8c929733a9ab9ac48c34e4c0b3d9d1b5cd69155", [:mix], [], "hexpm", "03a3f6ff16dcbb53e219b99c7af6aab29eb6b88acf80164b4bd76ac18dc890b3"}, "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, @@ -14,17 +14,18 @@ "ex_docker_engine_api": {:hex, :ex_docker_engine_api, "1.43.1", "1161e34b6bea5cef84d8fdc1d5d510fcb0c463941ce84c36f4a0f44a9096eb96", [:mix], [{:hackney, "~> 1.20", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.7", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "ec8fc499389aeef56ddca67e89e9e98098cff50587b56e8b4613279f382793b1"}, "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "floki": {:hex, :floki, "0.35.1", "b21cf592ed38c1207c5ea52120a2e81d6ecba11337a633a3f29ec17a64033178", [:mix], [], "hexpm", "f126e3eb814f131c21befeeeb773d2c4e2331ce05214c1a9844a3edde5c69003"}, + "fs": {:hex, :fs, "11.4.1", "11fb3153bb2e1de851b8263bb5698d526894853c73a525ebeb5e69108b2d25cd", [:rebar3], [], "hexpm", "dd00a61d89eac01d16d3fc51d5b0eb5f0722ef8e3c1a3a547cd086957f3260a9"}, "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, - "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, - "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, - "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "myxql": {:hex, :myxql, "0.6.4", "1502ea37ee23c31b79725b95d4cc3553693c2bda7421b1febc50722fd988c918", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a3307f4671f3009d3708283649adf205bfe280f7e036fc8ef7f16dbf821ab8e9"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, @@ -40,17 +41,18 @@ "plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, - "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, + "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "swoosh": {:hex, :swoosh, "1.12.0", "ecc85ee12947932986243299b8d28e6cdfc192c8d9e24c4c64f6738efdf344cb", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87db7ab0f35e358ba5eac3afc7422ed0c8c168a2d219d2a83ad8cb7a424f6cc9"}, "tailwind": {:hex, :tailwind, "0.2.1", "83d8eadbe71a8e8f67861fe7f8d51658ecfb258387123afe4d9dc194eddc36b0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e8a13f6107c95f73e58ed1b4221744e1eb5a093cd1da244432067e19c8c9a277"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, - "tesla": {:hex, :tesla, "1.12.1", "fe2bf4250868ee72e5d8b8dfa408d13a00747c41b7237b6aa3b9a24057346681", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2391efc6243d37ead43afd0327b520314c7b38232091d4a440c1212626fdd6e7"}, + "tesla": {:hex, :tesla, "1.16.0", "de77d083aea08ebd1982600693ff5d779d68a4bb835d136a0394b08f69714660", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb3bdfc0c6c8a23b4e3d86558e812e3577acff1cb4acb6cfe2da1985a1035b89"}, "testcontainers": {:hex, :testcontainers, "1.2.10", "65529607cb1a7ba75f19865b93aafd7d025d849483d5f93c3eeea61ecd5309d1", [:mix], [{:ecto, "~> 3.10", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:ex_docker_engine_api, "~> 1.43", [hex: :ex_docker_engine_api, repo: "hexpm", optional: false]}, {:uuid, "~> 1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm", "5c472a3653f4e2b58958241cd7d9feb5acd847b1d11bbbb0f6f6c721c5fb265a"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "uniq": {:hex, :uniq, "0.6.2", "51846518c037134c08bc5b773468007b155e543d53c8b39bafe95b0af487e406", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "95aa2a41ea331ef0a52d8ed12d3e730ef9af9dbc30f40646e6af334fbd7bc0fc"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.4", "7af8408e7ed9d56578539594d1ee7d8461e2dd5c3f57b0f2a5352d610ddde757", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d2c238c79c52cbe223fcdae22ca0bb5007a735b9e933870e241fce66afb4f4ab"}, diff --git a/examples/phoenix_project/test/docker-compose.yml b/examples/phoenix_project/test/docker-compose.yml new file mode 100644 index 00000000..5132ad03 --- /dev/null +++ b/examples/phoenix_project/test/docker-compose.yml @@ -0,0 +1,24 @@ +services: + postgres: + image: postgres:16-alpine + ports: + - "5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: hello_compose_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 1s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 1s + timeout: 3s + retries: 10 diff --git a/examples/phoenix_project/test/hello/docker_compose_test.exs b/examples/phoenix_project/test/hello/docker_compose_test.exs new file mode 100644 index 00000000..887e0315 --- /dev/null +++ b/examples/phoenix_project/test/hello/docker_compose_test.exs @@ -0,0 +1,146 @@ +defmodule Hello.DockerComposeTest do + use ExUnit.Case, async: false + + alias Testcontainers.DockerCompose + alias Testcontainers.Compose.ComposeEnvironment + + @compose_path Path.expand("../docker-compose.yml", __DIR__) + + describe "multi-service compose" do + setup do + compose = DockerCompose.new(@compose_path) + {:ok, env} = Testcontainers.start_compose(compose) + on_exit(fn -> Testcontainers.stop_compose(env) end) + %{env: env} + end + + test "starts both postgres and redis", %{env: env} do + assert %ComposeEnvironment{} = env + + # Verify postgres + pg_service = ComposeEnvironment.get_service(env, "postgres") + assert pg_service.service_name == "postgres" + assert pg_service.state == "running" + + pg_host = ComposeEnvironment.get_service_host(env, "postgres") + pg_port = ComposeEnvironment.get_service_port(env, "postgres", 5432) + + {:ok, pid} = + Postgrex.start_link( + hostname: pg_host, + port: pg_port, + username: "postgres", + password: "postgres", + database: "hello_compose_test" + ) + + assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", []) + GenServer.stop(pid) + + # Verify redis + redis_service = ComposeEnvironment.get_service(env, "redis") + assert redis_service.service_name == "redis" + assert redis_service.state == "running" + + redis_host = ComposeEnvironment.get_service_host(env, "redis") + redis_port = ComposeEnvironment.get_service_port(env, "redis", 6379) + + {:ok, conn} = :gen_tcp.connect(~c"#{redis_host}", redis_port, [:binary, active: false], 5000) + :gen_tcp.send(conn, "PING\r\n") + {:ok, response} = :gen_tcp.recv(conn, 0, 5000) + assert response =~ "PONG" + :gen_tcp.close(conn) + end + end +end + +defmodule Hello.DockerComposeSharedTest do + use ExUnit.Case, async: false + + import Testcontainers.ExUnit + + alias Testcontainers.DockerCompose + alias Testcontainers.Compose.ComposeEnvironment + + @compose_path Path.expand("../docker-compose.yml", __DIR__) + + compose :env, DockerCompose.new(@compose_path), shared: true + + test "can connect to postgres (shared)", %{env: env} do + assert %ComposeEnvironment{} = env + + host = ComposeEnvironment.get_service_host(env, "postgres") + port = ComposeEnvironment.get_service_port(env, "postgres", 5432) + + {:ok, pid} = + Postgrex.start_link( + hostname: host, + port: port, + username: "postgres", + password: "postgres", + database: "hello_compose_test" + ) + + assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", []) + GenServer.stop(pid) + end + + test "can connect to redis (shared)", %{env: env} do + host = ComposeEnvironment.get_service_host(env, "redis") + port = ComposeEnvironment.get_service_port(env, "redis", 6379) + + {:ok, conn} = :gen_tcp.connect(~c"#{host}", port, [:binary, active: false], 5000) + :gen_tcp.send(conn, "PING\r\n") + {:ok, response} = :gen_tcp.recv(conn, 0, 5000) + assert response =~ "PONG" + :gen_tcp.close(conn) + end + + test "shared env has same project name across tests", %{env: env} do + assert is_binary(env.project_name) + assert String.starts_with?(env.project_name, "tc-") + end +end + +defmodule Hello.DockerComposePerTestTest do + use ExUnit.Case, async: false + + import Testcontainers.ExUnit + + alias Testcontainers.DockerCompose + alias Testcontainers.Compose.ComposeEnvironment + + @compose_path Path.expand("../docker-compose.yml", __DIR__) + + compose :env, DockerCompose.new(@compose_path), shared: false + + test "can connect to postgres (per-test)", %{env: env} do + assert %ComposeEnvironment{} = env + + host = ComposeEnvironment.get_service_host(env, "postgres") + port = ComposeEnvironment.get_service_port(env, "postgres", 5432) + + {:ok, pid} = + Postgrex.start_link( + hostname: host, + port: port, + username: "postgres", + password: "postgres", + database: "hello_compose_test" + ) + + assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", []) + GenServer.stop(pid) + end + + test "can connect to redis (per-test)", %{env: env} do + host = ComposeEnvironment.get_service_host(env, "redis") + port = ComposeEnvironment.get_service_port(env, "redis", 6379) + + {:ok, conn} = :gen_tcp.connect(~c"#{host}", port, [:binary, active: false], 5000) + :gen_tcp.send(conn, "PING\r\n") + {:ok, response} = :gen_tcp.recv(conn, 0, 5000) + assert response =~ "PONG" + :gen_tcp.close(conn) + end +end diff --git a/lib/compose/cli.ex b/lib/compose/cli.ex new file mode 100644 index 00000000..c9d06113 --- /dev/null +++ b/lib/compose/cli.ex @@ -0,0 +1,212 @@ +defmodule Testcontainers.Compose.Cli do + @moduledoc """ + Subprocess wrapper for Docker Compose CLI interaction. + """ + + require Logger + + alias Testcontainers.DockerCompose + + @doc """ + Runs `docker compose up -d --wait` with the given compose configuration. + """ + def up(%DockerCompose{} = compose) do + args = build_up_args(compose) + + case execute(compose, args) do + {_output, 0} -> :ok + {output, exit_code} -> {:error, {:compose_up_failed, exit_code, output}} + end + end + + @doc """ + Runs `docker compose down` with the given compose configuration. + """ + def down(%DockerCompose{} = compose) do + args = build_down_args(compose) + + case execute(compose, args) do + {_output, 0} -> :ok + {output, exit_code} -> {:error, {:compose_down_failed, exit_code, output}} + end + end + + @doc """ + Runs `docker compose ps --format=json` and parses the output into a list of maps. + """ + def ps(%DockerCompose{} = compose) do + args = build_ps_args(compose) + + case execute(compose, args) do + {output, 0} -> {:ok, parse_ps_output(output)} + {output, exit_code} -> {:error, {:compose_ps_failed, exit_code, output}} + end + end + + @doc """ + Runs `docker compose pull` with the given compose configuration. + """ + def pull(%DockerCompose{} = compose) do + args = build_pull_args(compose) + + case execute(compose, args) do + {_output, 0} -> :ok + {output, exit_code} -> {:error, {:compose_pull_failed, exit_code, output}} + end + end + + @doc """ + Runs `docker compose logs ` and returns the output. + """ + def logs(%DockerCompose{} = compose, service_name) when is_binary(service_name) do + args = build_logs_args(compose, service_name) + + case execute(compose, args) do + {output, 0} -> {:ok, output} + {output, exit_code} -> {:error, {:compose_logs_failed, exit_code, output}} + end + end + + # Command building functions - public for testability + + @doc """ + Builds the argument list for `docker compose up`. + """ + def build_up_args(%DockerCompose{} = compose) do + base_args(compose) ++ ["up", "-d", "--wait"] ++ build_args(compose) ++ compose.services + end + + @doc """ + Builds the argument list for `docker compose down`. + """ + def build_down_args(%DockerCompose{} = compose) do + args = base_args(compose) ++ ["down"] + + if compose.remove_volumes do + args ++ ["-v"] + else + args + end + end + + @doc """ + Builds the argument list for `docker compose ps`. + """ + def build_ps_args(%DockerCompose{} = compose) do + base_args(compose) ++ ["ps", "--format=json"] + end + + @doc """ + Builds the argument list for `docker compose pull`. + """ + def build_pull_args(%DockerCompose{} = compose) do + base_args(compose) ++ ["pull"] + end + + @doc """ + Builds the argument list for `docker compose logs`. + """ + def build_logs_args(%DockerCompose{} = compose, service_name) do + base_args(compose) ++ ["logs", service_name] + end + + @doc """ + Parses the JSON output from `docker compose ps`. + + Each line is a separate JSON object with fields like Service, ID, State, Publishers. + """ + def parse_ps_output(output) when is_binary(output) do + output + |> String.trim() + |> String.split("\n", trim: true) + |> Enum.flat_map(fn line -> + case Jason.decode(line) do + {:ok, %{} = parsed} -> + [parsed] + + {:ok, list} when is_list(list) -> + list + + {:error, _} -> + [] + end + end) + end + + @doc """ + Parses the Publishers field from a `docker compose ps` JSON entry + into a list of `{container_port, host_port}` tuples. + """ + def parse_publishers(nil), do: [] + def parse_publishers([]), do: [] + + def parse_publishers(publishers) when is_list(publishers) do + publishers + |> Enum.filter(fn pub -> + published = Map.get(pub, "PublishedPort", 0) + published != 0 + end) + |> Enum.map(fn pub -> + target = Map.get(pub, "TargetPort", 0) + published = Map.get(pub, "PublishedPort", 0) + {target, published} + end) + |> Enum.uniq() + end + + # Private functions + + defp base_args(%DockerCompose{} = compose) do + args = ["compose"] + + args = + if compose.project_name do + args ++ ["-p", compose.project_name] + else + args + end + + args = + Enum.reduce(compose.compose_files, args, fn file, acc -> + acc ++ ["-f", file] + end) + + Enum.reduce(compose.profiles, args, fn profile, acc -> + acc ++ ["--profile", profile] + end) + end + + defp build_args(%DockerCompose{} = compose) do + args = [] + + args = + if compose.build do + args ++ ["--build"] + else + args + end + + case compose.pull do + :always -> args ++ ["--pull", "always"] + :never -> args ++ ["--pull", "never"] + :missing -> args + end + end + + defp execute(%DockerCompose{} = compose, args) do + dir = resolve_directory(compose.filepath) + env_vars = Enum.map(compose.env, fn {k, v} -> {to_string(k), to_string(v)} end) + + Logger.debug("Running: docker #{Enum.join(args, " ")} in #{dir}") + + System.cmd("docker", args, cd: dir, env: env_vars, stderr_to_stdout: true) + end + + defp resolve_directory(filepath) do + if File.dir?(filepath) do + filepath + else + Path.dirname(filepath) + end + end +end diff --git a/lib/compose/compose_environment.ex b/lib/compose/compose_environment.ex new file mode 100644 index 00000000..32c9255a --- /dev/null +++ b/lib/compose/compose_environment.ex @@ -0,0 +1,34 @@ +defmodule Testcontainers.Compose.ComposeEnvironment do + @moduledoc """ + Represents the started state of a Docker Compose environment. + """ + + alias Testcontainers.Compose.ComposeService + + defstruct [:compose, :project_name, :docker_host, services: %{}] + + @doc """ + Returns the service struct for the given service name. + """ + def get_service(%__MODULE__{} = env, service_name) when is_binary(service_name) do + Map.get(env.services, service_name) + end + + @doc """ + Returns the docker host for a service. + """ + def get_service_host(%__MODULE__{} = env, _service_name) do + env.docker_host + end + + @doc """ + Returns the mapped host port for a service and container port. + """ + def get_service_port(%__MODULE__{} = env, service_name, port) + when is_binary(service_name) and is_integer(port) do + case get_service(env, service_name) do + nil -> nil + service -> ComposeService.mapped_port(service, port) + end + end +end diff --git a/lib/compose/compose_service.ex b/lib/compose/compose_service.ex new file mode 100644 index 00000000..f1d032b1 --- /dev/null +++ b/lib/compose/compose_service.ex @@ -0,0 +1,23 @@ +defmodule Testcontainers.Compose.ComposeService do + @moduledoc """ + A lightweight struct representing a service within a Docker Compose environment. + """ + + defstruct [ + :service_name, + :container_id, + :state, + exposed_ports: [] + ] + + @doc """ + Returns the mapped host port for the given container port. + """ + def mapped_port(%__MODULE__{} = service, port) when is_integer(port) do + service.exposed_ports + |> Enum.find_value(nil, fn + {^port, host_port} -> host_port + _ -> nil + end) + end +end diff --git a/lib/compose/docker_compose.ex b/lib/compose/docker_compose.ex new file mode 100644 index 00000000..e9a491e6 --- /dev/null +++ b/lib/compose/docker_compose.ex @@ -0,0 +1,121 @@ +defmodule Testcontainers.DockerCompose do + @moduledoc """ + A struct with builder functions for creating a Docker Compose configuration. + """ + + defstruct [ + :filepath, + compose_files: [], + project_name: nil, + env: %{}, + wait_strategies: %{}, + wait_timeout: 120_000, + pull: :missing, + services: [], + build: false, + profiles: [], + remove_volumes: true + ] + + @doc """ + Creates a new DockerCompose configuration. + + The `filepath` can be a path to a directory containing a docker-compose.yml file, + or a path to a specific compose file. + """ + def new(filepath) when is_binary(filepath) do + %__MODULE__{ + filepath: filepath, + project_name: generate_project_name() + } + end + + @doc """ + Sets an environment variable for the compose environment. + """ + def with_env(%__MODULE__{} = config, key, value) + when (is_binary(key) or is_atom(key)) and is_binary(value) do + %__MODULE__{config | env: Map.put(config.env, to_string(key), value)} + end + + @doc """ + Sets a wait strategy for a specific service. + """ + def with_wait_strategy(%__MODULE__{} = config, service_name, wait_strategy) + when is_binary(service_name) and is_struct(wait_strategy) do + strategies = Map.get(config.wait_strategies, service_name, []) + + %__MODULE__{ + config + | wait_strategies: + Map.put(config.wait_strategies, service_name, [wait_strategy | strategies]) + } + end + + @doc """ + Sets the specific services to start. If empty, all services are started. + """ + def with_services(%__MODULE__{} = config, services) when is_list(services) do + %__MODULE__{config | services: services} + end + + @doc """ + Sets whether to build images before starting containers. + """ + def with_build(%__MODULE__{} = config, build) when is_boolean(build) do + %__MODULE__{config | build: build} + end + + @doc """ + Adds a profile to enable when starting compose. + """ + def with_profile(%__MODULE__{} = config, profile) when is_binary(profile) do + %__MODULE__{config | profiles: [profile | config.profiles]} + end + + @doc """ + Sets the pull policy for compose services. + """ + def with_pull(%__MODULE__{} = config, pull) when pull in [:always, :missing, :never] do + %__MODULE__{config | pull: pull} + end + + @doc """ + Sets whether to remove volumes when stopping compose. + """ + def with_remove_volumes(%__MODULE__{} = config, remove_volumes) + when is_boolean(remove_volumes) do + %__MODULE__{config | remove_volumes: remove_volumes} + end + + @doc """ + Sets the wait timeout in milliseconds. + """ + def with_wait_timeout(%__MODULE__{} = config, timeout) + when is_integer(timeout) and timeout > 0 do + %__MODULE__{config | wait_timeout: timeout} + end + + @doc """ + Sets the project name for the compose environment. + """ + def with_project_name(%__MODULE__{} = config, project_name) when is_binary(project_name) do + %__MODULE__{config | project_name: project_name} + end + + @doc """ + Adds additional compose files to use with the -f flag. + """ + def with_compose_file(%__MODULE__{} = config, file) when is_binary(file) do + %__MODULE__{config | compose_files: config.compose_files ++ [file]} + end + + defp generate_project_name do + hex = + :crypto.strong_rand_bytes(8) + |> Base.encode16(case: :lower) + |> binary_part(0, 12) + + "tc-#{hex}" + end +end diff --git a/lib/exunit.ex b/lib/exunit.ex index 49650da9..da6df680 100644 --- a/lib/exunit.ex +++ b/lib/exunit.ex @@ -75,4 +75,54 @@ defmodule Testcontainers.ExUnit do end end end + + @doc """ + Creates and manages the lifecycle of a Docker Compose environment within ExUnit tests. + + When the `:shared` option is set to `true`, the compose environment is created once for all + tests in the module. When omitted or set to `false`, a new compose environment is created + for each individual test. + + ## Parameters + + * `name`: The key that should be used to reference the compose environment in test cases. + * `config`: A `%Testcontainers.DockerCompose{}` struct with the compose configuration. + * `options`: Optional keyword list. Supports the following options: + * `:shared` - If set to `true`, the compose environment is shared across all tests. + + ## Examples + + defmodule MyComposeTest do + use ExUnit.Case + + alias Testcontainers.DockerCompose + + compose :my_env, DockerCompose.new("test/fixtures") + # ... + end + """ + defmacro compose(name, config, options \\ []) do + run_block = + quote do + {:ok, env} = Testcontainers.start_compose(unquote(config)) + ExUnit.Callbacks.on_exit(fn -> Testcontainers.stop_compose(env) end) + {:ok, %{unquote(name) => env}} + end + + case Keyword.get(options, :shared, false) do + true -> + quote do + setup_all do + unquote(run_block) + end + end + + _ -> + quote do + setup do + unquote(run_block) + end + end + end + end end diff --git a/lib/testcontainers.ex b/lib/testcontainers.ex index c6dff579..72660c84 100644 --- a/lib/testcontainers.ex +++ b/lib/testcontainers.ex @@ -18,6 +18,10 @@ defmodule Testcontainers do alias Testcontainers.ContainerBuilder alias Testcontainers.PullPolicy alias Testcontainers.Util.PropertiesParser + alias Testcontainers.DockerCompose + alias Testcontainers.Compose.Cli, as: ComposeCli + alias Testcontainers.Compose.ComposeService + alias Testcontainers.Compose.ComposeEnvironment import Testcontainers.Constants import Testcontainers.Container, only: [os_type: 0] @@ -71,7 +75,8 @@ defmodule Testcontainers do properties: properties, networks: MapSet.new(), containers: MapSet.new(), - images: MapSet.new() + images: MapSet.new(), + compose_envs: [] }} else error -> @@ -135,6 +140,38 @@ defmodule Testcontainers do wait_for_call({:stop_container, container_id}, name) end + @doc """ + Starts a Docker Compose environment based on the provided configuration. + + ## Parameters + + - `config`: A `%DockerCompose{}` struct containing the configuration. + + ## Returns + + - `{:ok, compose_env}` if the compose environment starts successfully. + - `{:error, reason}` on failure. + """ + def start_compose(config, name \\ __MODULE__) do + wait_for_call({:start_compose, config}, name) + end + + @doc """ + Stops a running Docker Compose environment. + + ## Parameters + + - `compose_env`: A `%ComposeEnvironment{}` struct representing the running environment. + + ## Returns + + - `:ok` if the compose environment stops successfully. + - `{:error, reason}` on failure. + """ + def stop_compose(compose_env, name \\ __MODULE__) do + wait_for_call({:stop_compose, compose_env}, name) + end + @doc """ Creates a Docker network. @@ -187,6 +224,19 @@ defmodule Testcontainers do {:noreply, %{state | images: MapSet.put(state.images, image)}} end + def handle_cast({:track_compose_env, compose_env}, state) do + {:noreply, %{state | compose_envs: [compose_env | state.compose_envs]}} + end + + def handle_cast({:untrack_compose_env, compose_env}, state) do + updated = + Enum.reject(state.compose_envs, fn env -> + env.project_name == compose_env.project_name + end) + + {:noreply, %{state | compose_envs: updated}} + end + @impl true def handle_info(_msg, state) do {:noreply, state} @@ -194,6 +244,11 @@ defmodule Testcontainers do @impl true def terminate(_reason, state) do + for compose_env <- Map.get(state, :compose_envs, []) do + Logger.info("Stopping compose environment #{compose_env.project_name}") + ComposeCli.down(compose_env.compose) + end + for container_id <- state.containers do Logger.info("Terminating container #{container_id}") Api.stop_container(container_id, state.conn) @@ -281,8 +336,107 @@ defmodule Testcontainers do {:noreply, %{state | networks: MapSet.delete(state.networks, network_name)}} end + @impl true + def handle_call({:start_compose, %DockerCompose{} = compose}, from, state) do + self_pid = self() + + Task.async(fn -> + result = start_compose_env(compose, state) + + case result do + {:ok, compose_env} -> + GenServer.cast(self_pid, {:track_compose_env, compose_env}) + + _ -> + :ok + end + + GenServer.reply(from, result) + end) + + {:noreply, state} + end + + @impl true + def handle_call({:stop_compose, %ComposeEnvironment{} = compose_env}, from, state) do + self_pid = self() + + Task.async(fn -> + result = ComposeCli.down(compose_env.compose) + GenServer.cast(self_pid, {:untrack_compose_env, compose_env}) + GenServer.reply(from, result) + end) + + {:noreply, state} + end + # private functions + defp start_compose_env(%DockerCompose{} = compose, state) do + with :ok <- ComposeCli.up(compose), + {:ok, ps_entries} <- ComposeCli.ps(compose) do + services = + ps_entries + |> Enum.map(fn entry -> + service_name = Map.get(entry, "Service", "") + container_id = Map.get(entry, "ID", "") + service_state = Map.get(entry, "State", "") + publishers = Map.get(entry, "Publishers", []) + ports = ComposeCli.parse_publishers(publishers) + + %ComposeService{ + service_name: service_name, + container_id: container_id, + state: service_state, + exposed_ports: ports + } + end) + |> Map.new(fn service -> {service.service_name, service} end) + + # Run per-service wait strategies if configured + with :ok <- run_compose_wait_strategies(compose, services, state) do + compose_env = %ComposeEnvironment{ + compose: compose, + project_name: compose.project_name, + docker_host: state.docker_hostname, + services: services + } + + {:ok, compose_env} + end + end + end + + defp run_compose_wait_strategies(%DockerCompose{} = compose, services, state) do + Enum.reduce_while(compose.wait_strategies, :ok, fn {service_name, strategies}, :ok -> + case Map.get(services, service_name) do + nil -> + {:halt, {:error, {:service_not_found, service_name}}} + + %ComposeService{container_id: container_id} -> + case Api.get_container(container_id, state.conn) do + {:ok, container} -> + result = + Enum.reduce(strategies, :ok, fn + strategy, :ok -> + WaitStrategy.wait_until_container_is_ready(strategy, container, state.conn) + + _, error -> + error + end) + + case result do + :ok -> {:cont, :ok} + error -> {:halt, error} + end + + {:error, _} = error -> + {:halt, error} + end + end + end) + end + defp get_docker_hostname(docker_host_url, conn) do case URI.parse(docker_host_url) do uri when uri.scheme == "http" or uri.scheme == "https" -> diff --git a/test/compose/cli_test.exs b/test/compose/cli_test.exs new file mode 100644 index 00000000..7576daa5 --- /dev/null +++ b/test/compose/cli_test.exs @@ -0,0 +1,275 @@ +defmodule Testcontainers.Compose.CliTest do + use ExUnit.Case, async: true + + alias Testcontainers.Compose.Cli + alias Testcontainers.DockerCompose + + describe "build_up_args/1" do + test "builds basic up args" do + compose = DockerCompose.new("/tmp/test") |> Map.put(:project_name, "tc-test123") + args = Cli.build_up_args(compose) + + assert args == ["compose", "-p", "tc-test123", "up", "-d", "--wait"] + end + + test "includes --build when build is true" do + compose = + DockerCompose.new("/tmp/test") + |> Map.put(:project_name, "tc-test123") + |> DockerCompose.with_build(true) + + args = Cli.build_up_args(compose) + + assert args == ["compose", "-p", "tc-test123", "up", "-d", "--wait", "--build"] + end + + test "includes --pull always when pull is :always" do + compose = + DockerCompose.new("/tmp/test") + |> Map.put(:project_name, "tc-test123") + |> DockerCompose.with_pull(:always) + + args = Cli.build_up_args(compose) + + assert args == ["compose", "-p", "tc-test123", "up", "-d", "--wait", "--pull", "always"] + end + + test "includes --pull never when pull is :never" do + compose = + DockerCompose.new("/tmp/test") + |> Map.put(:project_name, "tc-test123") + |> DockerCompose.with_pull(:never) + + args = Cli.build_up_args(compose) + + assert args == ["compose", "-p", "tc-test123", "up", "-d", "--wait", "--pull", "never"] + end + + test "includes services when specified" do + compose = + DockerCompose.new("/tmp/test") + |> Map.put(:project_name, "tc-test123") + |> DockerCompose.with_services(["redis", "postgres"]) + + args = Cli.build_up_args(compose) + + assert args == ["compose", "-p", "tc-test123", "up", "-d", "--wait", "redis", "postgres"] + end + + test "includes compose files with -f flags" do + compose = + DockerCompose.new("/tmp/test") + |> Map.put(:project_name, "tc-test123") + |> DockerCompose.with_compose_file("docker-compose.yml") + |> DockerCompose.with_compose_file("docker-compose.override.yml") + + args = Cli.build_up_args(compose) + + assert args == [ + "compose", + "-p", + "tc-test123", + "-f", + "docker-compose.yml", + "-f", + "docker-compose.override.yml", + "up", + "-d", + "--wait" + ] + end + + test "includes profiles" do + compose = + DockerCompose.new("/tmp/test") + |> Map.put(:project_name, "tc-test123") + |> DockerCompose.with_profile("debug") + + args = Cli.build_up_args(compose) + + assert args == [ + "compose", + "-p", + "tc-test123", + "--profile", + "debug", + "up", + "-d", + "--wait" + ] + end + end + + describe "build_down_args/1" do + test "builds basic down args with -v by default" do + compose = DockerCompose.new("/tmp/test") |> Map.put(:project_name, "tc-test123") + args = Cli.build_down_args(compose) + + assert args == ["compose", "-p", "tc-test123", "down", "-v"] + end + + test "omits -v when remove_volumes is false" do + compose = + DockerCompose.new("/tmp/test") + |> Map.put(:project_name, "tc-test123") + |> DockerCompose.with_remove_volumes(false) + + args = Cli.build_down_args(compose) + + assert args == ["compose", "-p", "tc-test123", "down"] + end + end + + describe "build_ps_args/1" do + test "builds ps args" do + compose = DockerCompose.new("/tmp/test") |> Map.put(:project_name, "tc-test123") + args = Cli.build_ps_args(compose) + + assert args == ["compose", "-p", "tc-test123", "ps", "--format=json"] + end + end + + describe "build_pull_args/1" do + test "builds pull args" do + compose = DockerCompose.new("/tmp/test") |> Map.put(:project_name, "tc-test123") + args = Cli.build_pull_args(compose) + + assert args == ["compose", "-p", "tc-test123", "pull"] + end + end + + describe "build_logs_args/2" do + test "builds logs args" do + compose = DockerCompose.new("/tmp/test") |> Map.put(:project_name, "tc-test123") + args = Cli.build_logs_args(compose, "redis") + + assert args == ["compose", "-p", "tc-test123", "logs", "redis"] + end + end + + describe "parse_ps_output/1" do + test "parses single JSON line" do + output = + ~s|{"ID":"abc123","Name":"tc-test-redis-1","Service":"redis","State":"running","Publishers":[{"URL":"0.0.0.0","TargetPort":6379,"PublishedPort":32768,"Protocol":"tcp"}]}| + + result = Cli.parse_ps_output(output) + + assert length(result) == 1 + [entry] = result + assert entry["Service"] == "redis" + assert entry["ID"] == "abc123" + assert entry["State"] == "running" + end + + test "parses multiple JSON lines" do + output = + ~s|{"ID":"abc123","Service":"redis","State":"running","Publishers":[]}\n{"ID":"def456","Service":"postgres","State":"running","Publishers":[]}| + + result = Cli.parse_ps_output(output) + + assert length(result) == 2 + assert Enum.at(result, 0)["Service"] == "redis" + assert Enum.at(result, 1)["Service"] == "postgres" + end + + test "parses JSON array output" do + output = + ~s|[{"ID":"abc123","Service":"redis","State":"running","Publishers":[]},{"ID":"def456","Service":"postgres","State":"running","Publishers":[]}]| + + result = Cli.parse_ps_output(output) + + assert length(result) == 2 + end + + test "handles empty output" do + assert Cli.parse_ps_output("") == [] + end + + test "skips invalid JSON lines" do + output = + ~s|not json\n{"ID":"abc123","Service":"redis","State":"running","Publishers":[]}| + + result = Cli.parse_ps_output(output) + + assert length(result) == 1 + assert Enum.at(result, 0)["Service"] == "redis" + end + end + + describe "parse_publishers/1" do + test "parses publishers into port tuples" do + publishers = [ + %{ + "URL" => "0.0.0.0", + "TargetPort" => 6379, + "PublishedPort" => 32768, + "Protocol" => "tcp" + } + ] + + assert Cli.parse_publishers(publishers) == [{6379, 32768}] + end + + test "filters out publishers with PublishedPort of 0" do + publishers = [ + %{ + "URL" => "0.0.0.0", + "TargetPort" => 6379, + "PublishedPort" => 0, + "Protocol" => "tcp" + } + ] + + assert Cli.parse_publishers(publishers) == [] + end + + test "handles multiple publishers" do + publishers = [ + %{ + "URL" => "0.0.0.0", + "TargetPort" => 6379, + "PublishedPort" => 32768, + "Protocol" => "tcp" + }, + %{ + "URL" => "0.0.0.0", + "TargetPort" => 5432, + "PublishedPort" => 32769, + "Protocol" => "tcp" + } + ] + + result = Cli.parse_publishers(publishers) + assert length(result) == 2 + assert {6379, 32768} in result + assert {5432, 32769} in result + end + + test "deduplicates port tuples" do + publishers = [ + %{ + "URL" => "0.0.0.0", + "TargetPort" => 6379, + "PublishedPort" => 32768, + "Protocol" => "tcp" + }, + %{ + "URL" => "::", + "TargetPort" => 6379, + "PublishedPort" => 32768, + "Protocol" => "tcp" + } + ] + + assert Cli.parse_publishers(publishers) == [{6379, 32768}] + end + + test "handles nil publishers" do + assert Cli.parse_publishers(nil) == [] + end + + test "handles empty publishers" do + assert Cli.parse_publishers([]) == [] + end + end +end diff --git a/test/compose/compose_integration_test.exs b/test/compose/compose_integration_test.exs new file mode 100644 index 00000000..376d9ee4 --- /dev/null +++ b/test/compose/compose_integration_test.exs @@ -0,0 +1,47 @@ +defmodule Testcontainers.Compose.ComposeIntegrationTest do + use ExUnit.Case, async: false + + @moduletag :integration + + alias Testcontainers.DockerCompose + alias Testcontainers.Compose.ComposeEnvironment + + @fixtures_path Path.expand("../fixtures", __DIR__) + + describe "full compose lifecycle" do + test "starts and stops a compose environment with redis" do + compose = DockerCompose.new(@fixtures_path) + + {:ok, env} = Testcontainers.start_compose(compose) + + assert %ComposeEnvironment{} = env + assert is_binary(env.project_name) + assert String.starts_with?(env.project_name, "tc-") + assert is_binary(env.docker_host) + + # Verify redis service is present + redis_service = ComposeEnvironment.get_service(env, "redis") + assert redis_service != nil + assert redis_service.service_name == "redis" + assert redis_service.state == "running" + + # Verify port mapping + host = ComposeEnvironment.get_service_host(env, "redis") + port = ComposeEnvironment.get_service_port(env, "redis", 6379) + + assert is_binary(host) + assert is_integer(port) + assert port > 0 + + # Verify connectivity to redis + {:ok, conn} = :gen_tcp.connect(~c"#{host}", port, [:binary, active: false], 5000) + :gen_tcp.send(conn, "PING\r\n") + {:ok, response} = :gen_tcp.recv(conn, 0, 5000) + assert response =~ "PONG" + :gen_tcp.close(conn) + + # Stop the compose environment + assert :ok = Testcontainers.stop_compose(env) + end + end +end diff --git a/test/compose/docker_compose_test.exs b/test/compose/docker_compose_test.exs new file mode 100644 index 00000000..86498e05 --- /dev/null +++ b/test/compose/docker_compose_test.exs @@ -0,0 +1,189 @@ +defmodule Testcontainers.DockerComposeTest do + use ExUnit.Case, async: true + + alias Testcontainers.DockerCompose + + describe "new/1" do + test "creates a new DockerCompose with filepath" do + compose = DockerCompose.new("/tmp/my-project") + + assert compose.filepath == "/tmp/my-project" + assert is_binary(compose.project_name) + assert String.starts_with?(compose.project_name, "tc-") + assert compose.env == %{} + assert compose.wait_strategies == %{} + assert compose.wait_timeout == 120_000 + assert compose.pull == :missing + assert compose.services == [] + assert compose.build == false + assert compose.profiles == [] + assert compose.remove_volumes == true + assert compose.compose_files == [] + end + + test "generates unique project names" do + compose1 = DockerCompose.new("/tmp/test") + compose2 = DockerCompose.new("/tmp/test") + + assert compose1.project_name != compose2.project_name + end + end + + describe "with_env/3" do + test "sets an environment variable" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_env("MY_VAR", "my_value") + + assert compose.env == %{"MY_VAR" => "my_value"} + end + + test "accepts atom keys" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_env(:MY_VAR, "my_value") + + assert compose.env == %{"MY_VAR" => "my_value"} + end + + test "overwrites existing environment variable" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_env("MY_VAR", "first") + |> DockerCompose.with_env("MY_VAR", "second") + + assert compose.env == %{"MY_VAR" => "second"} + end + end + + describe "with_wait_strategy/3" do + test "adds a wait strategy for a service" do + strategy = %Testcontainers.CommandWaitStrategy{command: ["echo", "ok"]} + + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_wait_strategy("redis", strategy) + + assert Map.has_key?(compose.wait_strategies, "redis") + assert length(compose.wait_strategies["redis"]) == 1 + end + + test "accumulates wait strategies for the same service" do + strategy1 = %Testcontainers.CommandWaitStrategy{command: ["echo", "1"]} + strategy2 = %Testcontainers.CommandWaitStrategy{command: ["echo", "2"]} + + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_wait_strategy("redis", strategy1) + |> DockerCompose.with_wait_strategy("redis", strategy2) + + assert length(compose.wait_strategies["redis"]) == 2 + end + end + + describe "with_services/2" do + test "sets specific services to start" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_services(["redis", "postgres"]) + + assert compose.services == ["redis", "postgres"] + end + end + + describe "with_build/2" do + test "enables build" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_build(true) + + assert compose.build == true + end + end + + describe "with_profile/2" do + test "adds a profile" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_profile("debug") + + assert compose.profiles == ["debug"] + end + + test "accumulates profiles" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_profile("debug") + |> DockerCompose.with_profile("test") + + assert "debug" in compose.profiles + assert "test" in compose.profiles + end + end + + describe "with_pull/2" do + test "sets pull to :always" do + compose = DockerCompose.new("/tmp/test") |> DockerCompose.with_pull(:always) + assert compose.pull == :always + end + + test "sets pull to :never" do + compose = DockerCompose.new("/tmp/test") |> DockerCompose.with_pull(:never) + assert compose.pull == :never + end + + test "sets pull to :missing" do + compose = DockerCompose.new("/tmp/test") |> DockerCompose.with_pull(:missing) + assert compose.pull == :missing + end + end + + describe "with_remove_volumes/2" do + test "sets remove_volumes to false" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_remove_volumes(false) + + assert compose.remove_volumes == false + end + end + + describe "with_wait_timeout/2" do + test "sets the wait timeout" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_wait_timeout(60_000) + + assert compose.wait_timeout == 60_000 + end + end + + describe "with_project_name/2" do + test "sets the project name" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_project_name("my-project") + + assert compose.project_name == "my-project" + end + end + + describe "with_compose_file/2" do + test "adds a compose file" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_compose_file("docker-compose.yml") + + assert compose.compose_files == ["docker-compose.yml"] + end + + test "accumulates compose files in order" do + compose = + DockerCompose.new("/tmp/test") + |> DockerCompose.with_compose_file("docker-compose.yml") + |> DockerCompose.with_compose_file("docker-compose.override.yml") + + assert compose.compose_files == ["docker-compose.yml", "docker-compose.override.yml"] + end + end +end diff --git a/test/fixtures/docker-compose.yml b/test/fixtures/docker-compose.yml new file mode 100644 index 00000000..ac4003c4 --- /dev/null +++ b/test/fixtures/docker-compose.yml @@ -0,0 +1,10 @@ +services: + redis: + image: redis:7-alpine + ports: + - "6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 1s + timeout: 3s + retries: 10