this repo has no description

test(interop): add Go memberlist test infrastructure

- Add interop/main.go: Go memberlist server for testing
- Add bin/debug_*.ml: OCaml debug tools for codec/ping testing
- Add test_interop.sh: script to run interop test
- Update .gitignore for compiled binaries

+4
.gitignore
··· 2 2 *.install 3 3 .merlin 4 4 .idea/ 5 + 6 + # Compiled binaries 7 + interop/memberlist-server 8 + interop/debug/debug_sender
+39
bin/debug_codec.ml
··· 1 + open Swim.Types 2 + 3 + let hex_of_string s = 4 + String.to_seq s 5 + |> Seq.map (fun c -> Printf.sprintf "%02x" (Char.code c)) 6 + |> List.of_seq |> String.concat "" 7 + 8 + let () = 9 + let node = 10 + make_node_info 11 + ~id:(node_id_of_string "test-node") 12 + ~addr:(`Udp (Eio.Net.Ipaddr.of_raw "\127\000\000\001", 7947)) 13 + ~meta:"" 14 + in 15 + let ping = 16 + Ping { seq = 1; target = node_id_of_string "go-node"; sender = node } 17 + in 18 + 19 + let wire_msg = msg_to_wire ~self_name:"ocaml-node" ~self_port:7947 ping in 20 + Printf.printf "Wire message type: %s\n" 21 + (match wire_msg with 22 + | Wire.Ping _ -> "Ping" 23 + | Wire.Indirect_ping _ -> "Indirect_ping" 24 + | Wire.Ack _ -> "Ack" 25 + | Wire.Nack _ -> "Nack" 26 + | Wire.Suspect _ -> "Suspect" 27 + | Wire.Alive _ -> "Alive" 28 + | Wire.Dead _ -> "Dead" 29 + | Wire.User_data _ -> "User_data" 30 + | Wire.Compound _ -> "Compound" 31 + | Wire.Compressed _ -> "Compressed" 32 + | Wire.Err _ -> "Err"); 33 + 34 + let encoded = 35 + Swim.Codec.encode_internal_msg ~self_name:"ocaml-node" ~self_port:7947 ping 36 + in 37 + Printf.printf "Encoded length: %d bytes\n" (String.length encoded); 38 + Printf.printf "Encoded hex: %s\n" (hex_of_string encoded); 39 + Printf.printf "First byte (msg type): %d\n" (Char.code encoded.[0])
+65
bin/debug_ping.ml
··· 1 + open Swim.Types 2 + 3 + external env_cast : 'a -> 'b = "%identity" 4 + 5 + let hex_of_cstruct cs = 6 + let len = Cstruct.length cs in 7 + let buf = Buffer.create (len * 2) in 8 + for i = 0 to len - 1 do 9 + Buffer.add_string buf (Printf.sprintf "%02x" (Cstruct.get_uint8 cs i)) 10 + done; 11 + Buffer.contents buf 12 + 13 + let () = 14 + Eio_main.run @@ fun env -> 15 + let env = env_cast env in 16 + Eio.Switch.run @@ fun sw -> 17 + let net = env#net in 18 + let sock = 19 + Swim.Transport.create_udp_socket net ~sw ~addr:"\127\000\000\001" ~port:7947 20 + in 21 + 22 + Printf.printf "Bound to 127.0.0.1:7947\n%!"; 23 + 24 + (* Create and send a ping to Go memberlist *) 25 + let self = 26 + make_node_info 27 + ~id:(node_id_of_string "ocaml-node") 28 + ~addr:(`Udp (Eio.Net.Ipaddr.of_raw "\127\000\000\001", 7947)) 29 + ~meta:"" 30 + in 31 + let target_id = node_id_of_string "go-node" in 32 + let ping = Ping { seq = 1; target = target_id; sender = self } in 33 + 34 + let packet = { cluster = ""; primary = ping; piggyback = [] } in 35 + let send_buf = Cstruct.create 1500 in 36 + match Swim.Codec.encode_packet packet ~buf:send_buf with 37 + | Error _ -> Printf.eprintf "Encode failed\n" 38 + | Ok len -> 39 + let encoded = Cstruct.sub send_buf 0 len in 40 + Printf.printf "Sending ping (%d bytes): %s\n%!" len 41 + (hex_of_cstruct encoded); 42 + 43 + let dst = `Udp (Eio.Net.Ipaddr.of_raw "\127\000\000\001", 7946) in 44 + Eio.Net.send sock ~dst [ encoded ]; 45 + Printf.printf "Sent! Waiting for ack...\n%!"; 46 + 47 + (* Wait for response *) 48 + let recv_buf = Cstruct.create 1500 in 49 + for i = 1 to 5 do 50 + Printf.printf "Waiting for packet %d (with 2s timeout)...\n%!" i; 51 + Eio.Fiber.fork ~sw (fun () -> 52 + let src, n = Eio.Net.recv sock recv_buf in 53 + let received = Cstruct.sub recv_buf 0 n in 54 + Printf.printf "Received %d bytes from %s\n%!" n 55 + (match src with 56 + | `Udp (ip, port) -> 57 + Printf.sprintf "%s:%d" 58 + (Fmt.to_to_string Eio.Net.Ipaddr.pp ip) 59 + port 60 + | _ -> "unknown"); 61 + Printf.printf "Hex: %s\n%!" (hex_of_cstruct received); 62 + Printf.printf "First byte (msg type): %d\n%!" 63 + (Cstruct.get_uint8 received 0)); 64 + Eio.Time.sleep env#clock 2.0 65 + done
+41
bin/debug_recv.ml
··· 1 + open Swim.Types 2 + 3 + external env_cast : 'a -> 'b = "%identity" 4 + 5 + let hex_of_cstruct cs = 6 + let len = Cstruct.length cs in 7 + let buf = Buffer.create (len * 2) in 8 + for i = 0 to len - 1 do 9 + Buffer.add_string buf (Printf.sprintf "%02x" (Cstruct.get_uint8 cs i)) 10 + done; 11 + Buffer.contents buf 12 + 13 + let () = 14 + Eio_main.run @@ fun env -> 15 + let env = env_cast env in 16 + Eio.Switch.run @@ fun sw -> 17 + let net = env#net in 18 + let sock = 19 + Swim.Transport.create_udp_socket net ~sw ~addr:"\127\000\000\001" ~port:7947 20 + in 21 + 22 + Printf.printf "Listening on 127.0.0.1:7947 for UDP packets...\n%!"; 23 + 24 + let buf = Cstruct.create 1500 in 25 + for i = 1 to 10 do 26 + Printf.printf "Waiting for packet %d...\n%!" i; 27 + let src, n = Eio.Net.recv sock buf in 28 + let received = Cstruct.sub buf 0 n in 29 + Printf.printf "Received %d bytes from %s\n%!" n 30 + (match src with 31 + | `Udp (ip, port) -> 32 + Printf.sprintf "%s:%d" (Fmt.to_to_string Eio.Net.Ipaddr.pp ip) port 33 + | _ -> "unknown"); 34 + Printf.printf "Hex: %s\n%!" (hex_of_cstruct received); 35 + Printf.printf "First byte (msg type): %d\n%!" (Cstruct.get_uint8 received 0); 36 + 37 + (* Try to decode *) 38 + match Swim.Codec.decode_packet received with 39 + | Ok packet -> Printf.printf "Decoded! Cluster: %s\n%!" packet.cluster 40 + | Error e -> Printf.printf "Decode error: %s\n%!" (decode_error_to_string e) 41 + done
+49
interop/debug/debug_sender.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "net" 6 + 7 + "github.com/hashicorp/go-msgpack/codec" 8 + ) 9 + 10 + type ping struct { 11 + SeqNo uint32 12 + Node string 13 + SourceAddr []byte 14 + SourcePort uint16 15 + SourceNode string 16 + } 17 + 18 + func main() { 19 + conn, err := net.Dial("udp", "127.0.0.1:7946") 20 + if err != nil { 21 + panic(err) 22 + } 23 + defer conn.Close() 24 + 25 + p := ping{ 26 + SeqNo: 1, 27 + Node: "test-node", 28 + SourceAddr: []byte{127, 0, 0, 1}, 29 + SourcePort: 7947, 30 + SourceNode: "ocaml-node", 31 + } 32 + 33 + var buf []byte 34 + enc := codec.NewEncoderBytes(&buf, &codec.MsgpackHandle{}) 35 + if err := enc.Encode(&p); err != nil { 36 + panic(err) 37 + } 38 + 39 + msg := append([]byte{0}, buf...) 40 + fmt.Printf("Sending %d bytes: %x\n", len(msg), msg) 41 + fmt.Printf("Message type: 0 (ping)\n") 42 + fmt.Printf("Msgpack payload: %x\n", buf) 43 + 44 + n, err := conn.Write(msg) 45 + if err != nil { 46 + panic(err) 47 + } 48 + fmt.Printf("Sent %d bytes\n", n) 49 + }
+5
interop/debug/go.mod
··· 1 + module debug-sender 2 + 3 + go 1.21 4 + 5 + require github.com/hashicorp/go-msgpack v0.5.3
+2
interop/debug/go.sum
··· 1 + github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= 2 + github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
+21
interop/go.mod
··· 1 + module swim-interop 2 + 3 + go 1.21 4 + 5 + require github.com/hashicorp/memberlist v0.5.0 6 + 7 + require ( 8 + github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da // indirect 9 + github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect 10 + github.com/hashicorp/errwrap v1.0.0 // indirect 11 + github.com/hashicorp/go-immutable-radix v1.0.0 // indirect 12 + github.com/hashicorp/go-msgpack v0.5.3 // indirect 13 + github.com/hashicorp/go-multierror v1.0.0 // indirect 14 + github.com/hashicorp/go-sockaddr v1.0.0 // indirect 15 + github.com/hashicorp/golang-lru v0.5.0 // indirect 16 + github.com/miekg/dns v1.1.26 // indirect 17 + github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect 18 + golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392 // indirect 19 + golang.org/x/net v0.0.0-20190923162816-aa69164e4478 // indirect 20 + golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect 21 + )
+51
interop/go.sum
··· 1 + github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I= 2 + github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= 3 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 + github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= 6 + github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 7 + github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 8 + github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 9 + github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= 10 + github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= 11 + github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= 12 + github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= 13 + github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= 14 + github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 15 + github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= 16 + github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= 17 + github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= 18 + github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 19 + github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= 20 + github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 21 + github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= 22 + github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= 23 + github.com/miekg/dns v1.1.26 h1:gPxPSwALAeHJSjarOs00QjVdV9QoBvc1D2ujQUr5BzU= 24 + github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= 25 + github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs= 26 + github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= 27 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 + github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= 30 + github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= 31 + github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 32 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 33 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 34 + golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392 h1:ACG4HJsFiNMf47Y4PeRoebLNy/2lXT9EtprMuTFWt1M= 35 + golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= 36 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 37 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 38 + golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g= 39 + golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 40 + golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= 41 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 42 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 43 + golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 + golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 45 + golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= 46 + golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 48 + golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 49 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 50 + golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 51 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+103
interop/main.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/hex" 5 + "flag" 6 + "log" 7 + "os" 8 + "os/signal" 9 + "syscall" 10 + "time" 11 + 12 + "github.com/hashicorp/memberlist" 13 + ) 14 + 15 + type eventDelegate struct{} 16 + 17 + func (e *eventDelegate) NotifyJoin(node *memberlist.Node) { 18 + log.Printf("[EVENT] Node joined: %s (%s:%d)", node.Name, node.Addr, node.Port) 19 + } 20 + 21 + func (e *eventDelegate) NotifyLeave(node *memberlist.Node) { 22 + log.Printf("[EVENT] Node left: %s", node.Name) 23 + } 24 + 25 + func (e *eventDelegate) NotifyUpdate(node *memberlist.Node) { 26 + log.Printf("[EVENT] Node updated: %s", node.Name) 27 + } 28 + 29 + func main() { 30 + name := flag.String("name", "go-node", "Node name") 31 + bind := flag.String("bind", "127.0.0.1", "Bind address") 32 + port := flag.Int("port", 7946, "Bind port") 33 + join := flag.String("join", "", "Address to join (host:port)") 34 + secretKey := flag.String("key", "", "Secret key (hex encoded, 16 bytes for AES-128)") 35 + flag.Parse() 36 + 37 + config := memberlist.DefaultLANConfig() 38 + config.Name = *name 39 + config.BindAddr = *bind 40 + config.BindPort = *port 41 + config.AdvertisePort = *port 42 + config.Events = &eventDelegate{} 43 + 44 + config.LogOutput = os.Stdout 45 + 46 + if *secretKey != "" { 47 + keyBytes, err := hex.DecodeString(*secretKey) 48 + if err != nil { 49 + log.Fatalf("Invalid hex key: %v", err) 50 + } 51 + if len(keyBytes) != 16 && len(keyBytes) != 24 && len(keyBytes) != 32 { 52 + log.Fatalf("Key must be 16, 24, or 32 bytes (got %d)", len(keyBytes)) 53 + } 54 + keyring, err := memberlist.NewKeyring(nil, keyBytes) 55 + if err != nil { 56 + log.Fatalf("Failed to create keyring: %v", err) 57 + } 58 + config.Keyring = keyring 59 + config.GossipVerifyIncoming = true 60 + config.GossipVerifyOutgoing = true 61 + log.Printf("Encryption enabled with %d-byte key", len(keyBytes)) 62 + } 63 + 64 + list, err := memberlist.Create(config) 65 + if err != nil { 66 + log.Fatalf("Failed to create memberlist: %v", err) 67 + } 68 + 69 + log.Printf("Memberlist started: %s on %s:%d", *name, *bind, *port) 70 + 71 + if *join != "" { 72 + log.Printf("Joining cluster via %s", *join) 73 + n, err := list.Join([]string{*join}) 74 + if err != nil { 75 + log.Printf("Failed to join: %v", err) 76 + } else { 77 + log.Printf("Joined %d nodes", n) 78 + } 79 + } 80 + 81 + go func() { 82 + ticker := time.NewTicker(5 * time.Second) 83 + for range ticker.C { 84 + members := list.Members() 85 + log.Printf("--- Members (%d) ---", len(members)) 86 + for _, m := range members { 87 + log.Printf(" %s: %s:%d (state=%v)", m.Name, m.Addr, m.Port, m.State) 88 + } 89 + } 90 + }() 91 + 92 + sigCh := make(chan os.Signal, 1) 93 + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 94 + <-sigCh 95 + 96 + log.Println("Shutting down...") 97 + if err := list.Leave(time.Second); err != nil { 98 + log.Printf("Leave error: %v", err) 99 + } 100 + if err := list.Shutdown(); err != nil { 101 + log.Printf("Shutdown error: %v", err) 102 + } 103 + }
+18
test_interop.sh
··· 1 + #!/bin/bash 2 + set -e 3 + 4 + echo "Starting Go memberlist server WITHOUT encryption..." 5 + cd /home/gdiazlo/data/src/swim/interop 6 + ./memberlist-server -name go-node -port 7946 & 7 + GO_PID=$! 8 + sleep 2 9 + 10 + echo "Starting OCaml SWIM client..." 11 + cd /home/gdiazlo/data/src/swim 12 + timeout 25 ./_build/default/bin/interop_test.exe || true 13 + 14 + echo "Killing Go server..." 15 + kill $GO_PID 2>/dev/null || true 16 + wait $GO_PID 2>/dev/null || true 17 + 18 + echo "Done"