diff --git a/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientContainerInspectCommandResponderTests.cs b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientContainerInspectCommandResponderTests.cs new file mode 100644 index 00000000..2c8643a8 --- /dev/null +++ b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientContainerInspectCommandResponderTests.cs @@ -0,0 +1,689 @@ +using System; +using System.Reflection; +using Ductus.FluentDocker.Executors; +using Ductus.FluentDocker.Executors.Parsers; +using Ductus.FluentDocker.Model.Containers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ductus.FluentDocker.Tests.ProcessResponseParsersTests +{ + [TestClass] + public class ClientContainerInspectCommandResponderTests + { + [TestMethod] + public void ProcessShallParseResponse() + { + // Arrange + var stdOut = @"[ + { + ""Id"": ""82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2"", + ""Created"": ""2023-09-27T19:49:02.074054924Z"", + ""Path"": ""docker-entrypoint.sh"", + ""Args"": [ + ""postgres"" + ], + ""State"": { + ""Status"": ""running"", + ""Running"": true, + ""Paused"": false, + ""Restarting"": false, + ""OOMKilled"": false, + ""Dead"": false, + ""Pid"": 30202, + ""ExitCode"": 0, + ""Error"": """", + ""StartedAt"": ""2023-09-27T19:49:02.247208341Z"", + ""FinishedAt"": ""0001-01-01T00:00:00Z"" + }, + ""Image"": ""sha256:fbee27eada86c3e82d62d1a41d2258137cf7004b81b28c696943f20462dc3b0f"", + ""ResolvConfPath"": ""/var/lib/docker/containers/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2/resolv.conf"", + ""HostnamePath"": ""/var/lib/docker/containers/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2/hostname"", + ""HostsPath"": ""/var/lib/docker/containers/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2/hosts"", + ""LogPath"": ""/var/lib/docker/containers/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2-json.log"", + ""Name"": ""/test-postgres"", + ""RestartCount"": 0, + ""Driver"": ""overlay2"", + ""Platform"": ""linux"", + ""MountLabel"": """", + ""ProcessLabel"": """", + ""AppArmorProfile"": """", + ""ExecIDs"": null, + ""HostConfig"": { + ""Binds"": null, + ""ContainerIDFile"": """", + ""LogConfig"": { + ""Type"": ""json-file"", + ""Config"": {} + }, + ""NetworkMode"": ""default"", + ""PortBindings"": {}, + ""RestartPolicy"": { + ""Name"": ""no"", + ""MaximumRetryCount"": 0 + }, + ""AutoRemove"": false, + ""VolumeDriver"": """", + ""VolumesFrom"": null, + ""ConsoleSize"": [ + 25, + 214 + ], + ""CapAdd"": null, + ""CapDrop"": null, + ""CgroupnsMode"": ""private"", + ""Dns"": [], + ""DnsOptions"": [], + ""DnsSearch"": [], + ""ExtraHosts"": null, + ""GroupAdd"": null, + ""IpcMode"": ""private"", + ""Cgroup"": """", + ""Links"": null, + ""OomScoreAdj"": 0, + ""PidMode"": """", + ""Privileged"": false, + ""PublishAllPorts"": false, + ""ReadonlyRootfs"": false, + ""SecurityOpt"": null, + ""UTSMode"": """", + ""UsernsMode"": """", + ""ShmSize"": 67108864, + ""Runtime"": ""runc"", + ""Isolation"": """", + ""CpuShares"": 0, + ""Memory"": 0, + ""NanoCpus"": 0, + ""CgroupParent"": """", + ""BlkioWeight"": 0, + ""BlkioWeightDevice"": [], + ""BlkioDeviceReadBps"": [], + ""BlkioDeviceWriteBps"": [], + ""BlkioDeviceReadIOps"": [], + ""BlkioDeviceWriteIOps"": [], + ""CpuPeriod"": 0, + ""CpuQuota"": 0, + ""CpuRealtimePeriod"": 0, + ""CpuRealtimeRuntime"": 0, + ""CpusetCpus"": """", + ""CpusetMems"": """", + ""Devices"": [], + ""DeviceCgroupRules"": null, + ""DeviceRequests"": null, + ""MemoryReservation"": 0, + ""MemorySwap"": 0, + ""MemorySwappiness"": null, + ""OomKillDisable"": null, + ""PidsLimit"": null, + ""Ulimits"": null, + ""CpuCount"": 0, + ""CpuPercent"": 0, + ""IOMaximumIOps"": 0, + ""IOMaximumBandwidth"": 0, + ""MaskedPaths"": [ + ""/proc/asound"", + ""/proc/acpi"", + ""/proc/kcore"", + ""/proc/keys"", + ""/proc/latency_stats"", + ""/proc/timer_list"", + ""/proc/timer_stats"", + ""/proc/sched_debug"", + ""/proc/scsi"", + ""/sys/firmware"" + ], + ""ReadonlyPaths"": [ + ""/proc/bus"", + ""/proc/fs"", + ""/proc/irq"", + ""/proc/sys"", + ""/proc/sysrq-trigger"" + ] + }, + ""GraphDriver"": { + ""Data"": { + ""LowerDir"": ""/var/lib/docker/overlay2/77bc9003377919ff798e09db0604c618901982b10d209d8226ddfcfa8cdb7650-init/diff:/var/lib/docker/overlay2/eb8f4366ce3fa95e9b6ca6232c68404f6207701a31d093ea9a650b46e1fa8063/diff:/var/lib/docker/overlay2/42edcd520759b2febd882900f711fd94e7361f5062f1ccdfe3234ab2d7fc8779/diff:/var/lib/docker/overlay2/d47154447653c8be2011e7dcba54d6dfa9c0358ec7570b6e9ebb44b6264ce06f/diff:/var/lib/docker/overlay2/08ddb175de10618255c6fff2e87230e3fd9991f44a789c82a31ab3364ccb9bc9/diff:/var/lib/docker/overlay2/b08490a4229abee1bf91d56dfffaa5cbdfac5b89a6c16938bbe8ec491564ecdc/diff:/var/lib/docker/overlay2/fef3614daa405946d03a906656193a47f9774ab860180f4a7067bdfca0482997/diff:/var/lib/docker/overlay2/e21f8bc0da91d55f6c486953b78dc59c9ca6ef1b3f2a5ab8d3934b77c70327e7/diff:/var/lib/docker/overlay2/ea89c59efada74850454eebc885945d3d5d0d5cb37a88353b3ecb56cec51653b/diff:/var/lib/docker/overlay2/82027facf58875506c0c31eef83e43cab5cd63f29411079441803c49c63cf153/diff:/var/lib/docker/overlay2/f8092acd49404ee192ca4cfa94d7498d98c465d3757b4dbfad6b581250a078fe/diff:/var/lib/docker/overlay2/17276a89947fc0c6a8c9c5acb62c1bae9cae60292458f7cb90c030c1d51919fa/diff:/var/lib/docker/overlay2/8b7c9d84bd1dbff3ef2bb3017e1e86f872b34f03f3278cfa737d21b6ab73ddab/diff:/var/lib/docker/overlay2/d6d180a24a2841fa104adacc63edc5718b977167cf35ee2be17cb796f300f270/diff"", + ""MergedDir"": ""/var/lib/docker/overlay2/77bc9003377919ff798e09db0604c618901982b10d209d8226ddfcfa8cdb7650/merged"", + ""UpperDir"": ""/var/lib/docker/overlay2/77bc9003377919ff798e09db0604c618901982b10d209d8226ddfcfa8cdb7650/diff"", + ""WorkDir"": ""/var/lib/docker/overlay2/77bc9003377919ff798e09db0604c618901982b10d209d8226ddfcfa8cdb7650/work"" + }, + ""Name"": ""overlay2"" + }, + ""Mounts"": [ + { + ""Type"": ""volume"", + ""Name"": ""03a7b3ffa92ff257d68cc458f2c0fd52061c37ca8ecaf9234ce33dfd58022c0f"", + ""Source"": ""/var/lib/docker/volumes/03a7b3ffa92ff257d68cc458f2c0fd52061c37ca8ecaf9234ce33dfd58022c0f/_data"", + ""Destination"": ""/var/lib/postgresql/data"", + ""Driver"": ""local"", + ""Mode"": """", + ""RW"": true, + ""Propagation"": """" + } + ], + ""Config"": { + ""Hostname"": ""82b3c01497e3"", + ""Domainname"": """", + ""User"": """", + ""AttachStdin"": false, + ""AttachStdout"": false, + ""AttachStderr"": false, + ""ExposedPorts"": { + ""5432/tcp"": {} + }, + ""Tty"": false, + ""OpenStdin"": false, + ""StdinOnce"": false, + ""Env"": [ + ""POSTGRES_PASSWORD=password"", + ""PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/postgresql/16/bin"", + ""GOSU_VERSION=1.16"", + ""LANG=en_US.utf8"", + ""PG_MAJOR=16"", + ""PG_VERSION=16.0-1.pgdg120+1"", + ""PGDATA=/var/lib/postgresql/data"" + ], + ""Cmd"": [ + ""postgres"" + ], + ""Image"": ""postgres"", + ""Volumes"": { + ""/var/lib/postgresql/data"": {} + }, + ""WorkingDir"": """", + ""Entrypoint"": [ + ""docker-entrypoint.sh"" + ], + ""OnBuild"": null, + ""Labels"": {}, + ""StopSignal"": ""SIGINT"" + }, + ""NetworkSettings"": { + ""Bridge"": """", + ""SandboxID"": ""d8e0b3a54e3b4c6cd059615be734fdff1f7ec9e65319593e321dc13d406b59ad"", + ""HairpinMode"": false, + ""LinkLocalIPv6Address"": """", + ""LinkLocalIPv6PrefixLen"": 0, + ""Ports"": { + ""5432/tcp"": null + }, + ""SandboxKey"": ""/var/run/docker/netns/d8e0b3a54e3b"", + ""SecondaryIPAddresses"": null, + ""SecondaryIPv6Addresses"": null, + ""EndpointID"": ""2135c241ecb5ad1b7b5c68d05b9e2f66fcc96eefdb04614d3cdf6964705a5d18"", + ""Gateway"": ""172.17.0.1"", + ""GlobalIPv6Address"": """", + ""GlobalIPv6PrefixLen"": 0, + ""IPAddress"": ""172.17.0.2"", + ""IPPrefixLen"": 16, + ""IPv6Gateway"": """", + ""MacAddress"": ""02:42:ac:11:00:02"", + ""Networks"": { + ""bridge"": { + ""IPAMConfig"": null, + ""Links"": null, + ""Aliases"": null, + ""NetworkID"": ""d55284e2feee89035ebfad8ee39f3921ee958c7074bc57a263aab435eab5f0b9"", + ""EndpointID"": ""2135c241ecb5ad1b7b5c68d05b9e2f66fcc96eefdb04614d3cdf6964705a5d18"", + ""Gateway"": ""172.17.0.1"", + ""IPAddress"": ""172.17.0.2"", + ""IPPrefixLen"": 16, + ""IPv6Gateway"": """", + ""GlobalIPv6Address"": """", + ""GlobalIPv6PrefixLen"": 0, + ""MacAddress"": ""02:42:ac:11:00:02"", + ""DriverOpts"": null + } + } + } + } + ] + "; + var ctorArgs = new object[] { "command", stdOut, "", 0 }; + var executionResult = (ProcessExecutionResult)Activator.CreateInstance(typeof(ProcessExecutionResult), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.CreateInstance, + null, ctorArgs, null, null); + + var parser = new ClientContainerInspectCommandResponder(); + + // Act + var result = parser.Process(executionResult); + + // Assert + var container = result.Response.Data; + Assert.AreEqual("82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2", container.Id); + Assert.AreEqual("sha256:fbee27eada86c3e82d62d1a41d2258137cf7004b81b28c696943f20462dc3b0f", container.Image); + Assert.AreEqual(new DateTime(2023, 09, 27, 19, 49, 02, DateTimeKind.Utc).AddTicks(740549), container.Created); + Assert.AreEqual("/var/lib/docker/containers/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2/resolv.conf", container.ResolvConfPath); + Assert.AreEqual("/var/lib/docker/containers/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2/hostname", container.HostnamePath); + Assert.AreEqual("/var/lib/docker/containers/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2/hosts", container.HostsPath); + Assert.AreEqual("/var/lib/docker/containers/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2/82b3c01497e365efa2330505d24a08daf67a5c554715fafe0eb6b1f0d34b8cd2-json.log", container.LogPath); + Assert.AreEqual("test-postgres", container.Name); + Assert.AreEqual(0, container.RestartCount); + Assert.AreEqual("overlay2", container.Driver); + + Assert.AreEqual(1, container.Args.Length); + Assert.AreEqual("postgres", container.Args[0]); + + Assert.AreEqual("running", container.State.Status); + Assert.AreEqual(true, container.State.Running); + Assert.AreEqual(false, container.State.Paused); + Assert.AreEqual(false, container.State.Restarting); + Assert.AreEqual(false, container.State.OOMKilled); + Assert.AreEqual(false, container.State.Dead); + Assert.AreEqual(30202, container.State.Pid); + Assert.AreEqual(0, container.State.ExitCode); + Assert.AreEqual("", container.State.Error); + Assert.AreEqual(new DateTime(2023, 09, 27, 19, 49, 02, DateTimeKind.Utc).AddTicks(2472083), container.State.StartedAt); + Assert.AreEqual(new DateTime(1, 1, 1, 0, 0, 0, DateTimeKind.Utc), container.State.FinishedAt); + Assert.IsNull(container.State.Health); + + Assert.AreEqual(1, container.Mounts.Length); + var mount = container.Mounts[0]; + Assert.AreEqual("03a7b3ffa92ff257d68cc458f2c0fd52061c37ca8ecaf9234ce33dfd58022c0f", mount.Name); + Assert.AreEqual("/var/lib/docker/volumes/03a7b3ffa92ff257d68cc458f2c0fd52061c37ca8ecaf9234ce33dfd58022c0f/_data", mount.Source); + Assert.AreEqual("/var/lib/postgresql/data", mount.Destination); + Assert.AreEqual("local", mount.Driver); + Assert.AreEqual("", mount.Mode); + Assert.AreEqual(true, mount.RW); + Assert.AreEqual("", mount.Propagation); + + Assert.AreEqual("82b3c01497e3", container.Config.Hostname); + Assert.AreEqual("", container.Config.DomainName); + Assert.AreEqual("", container.Config.User); + Assert.AreEqual(false, container.Config.AttachStdin); + Assert.AreEqual(false, container.Config.AttachStdout); + Assert.AreEqual(false, container.Config.AttachStderr); + Assert.AreEqual(1, container.Config.ExposedPorts.Count); + Assert.IsTrue(container.Config.ExposedPorts.ContainsKey("5432/tcp")); + Assert.AreEqual(false, container.Config.Tty); + Assert.AreEqual(false, container.Config.OpenStdin); + Assert.AreEqual(false, container.Config.StdinOnce); + var containerEnv = container.Config.Env; + Assert.AreEqual(7, containerEnv.Length); + Assert.AreEqual("POSTGRES_PASSWORD=password", containerEnv[0]); + Assert.AreEqual("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/postgresql/16/bin", containerEnv[1]); + Assert.AreEqual("GOSU_VERSION=1.16", containerEnv[2]); + Assert.AreEqual("LANG=en_US.utf8", containerEnv[3]); + Assert.AreEqual("PG_MAJOR=16", containerEnv[4]); + Assert.AreEqual("PG_VERSION=16.0-1.pgdg120+1", containerEnv[5]); + Assert.AreEqual("PGDATA=/var/lib/postgresql/data", containerEnv[6]); + Assert.AreEqual(1, container.Config.Cmd.Length); + Assert.AreEqual("postgres", container.Config.Cmd[0]); + Assert.AreEqual("postgres", container.Config.Image); + Assert.AreEqual(1, container.Config.Volumes.Count); + Assert.IsTrue(container.Config.Volumes.ContainsKey("/var/lib/postgresql/data")); + Assert.AreEqual("", container.Config.WorkingDir); + Assert.AreEqual(1, container.Config.EntryPoint.Length); + Assert.AreEqual("docker-entrypoint.sh", container.Config.EntryPoint[0]); + Assert.AreEqual(0, container.Config.Labels.Count); + Assert.AreEqual("SIGINT", container.Config.StopSignal); + + Assert.AreEqual("", container.NetworkSettings.Bridge); + Assert.AreEqual("d8e0b3a54e3b4c6cd059615be734fdff1f7ec9e65319593e321dc13d406b59ad", container.NetworkSettings.SandboxID); + Assert.AreEqual(false, container.NetworkSettings.HairpinMode); + Assert.AreEqual("", container.NetworkSettings.LinkLocalIPv6Address); + Assert.AreEqual("0", container.NetworkSettings.LinkLocalIPv6PrefixLen); + Assert.AreEqual(1, container.NetworkSettings.Ports.Count); + Assert.IsNull(container.NetworkSettings.Ports["5432/tcp"]); + Assert.AreEqual("/var/run/docker/netns/d8e0b3a54e3b", container.NetworkSettings.SandboxKey); + Assert.IsNull(container.NetworkSettings.SecondaryIPAddresses); + Assert.IsNull(container.NetworkSettings.SecondaryIPv6Addresses); + Assert.AreEqual("2135c241ecb5ad1b7b5c68d05b9e2f66fcc96eefdb04614d3cdf6964705a5d18", container.NetworkSettings.EndpointID); + Assert.AreEqual("172.17.0.1", container.NetworkSettings.Gateway); + Assert.AreEqual("", container.NetworkSettings.GlobalIPv6Address); + Assert.AreEqual("0", container.NetworkSettings.GlobalIPv6PrefixLen); + Assert.AreEqual("172.17.0.2", container.NetworkSettings.IPAddress); + Assert.AreEqual("16", container.NetworkSettings.IPPrefixLen); + Assert.AreEqual("", container.NetworkSettings.IPv6Gateway); + Assert.AreEqual("02:42:ac:11:00:02", container.NetworkSettings.MacAddress); + Assert.AreEqual(1, container.NetworkSettings.Networks.Count); + var bridgeNetwork = container.NetworkSettings.Networks["bridge"]; + Assert.IsNull(bridgeNetwork.Aliases); + Assert.AreEqual("d55284e2feee89035ebfad8ee39f3921ee958c7074bc57a263aab435eab5f0b9", bridgeNetwork.NetworkID); + Assert.AreEqual("2135c241ecb5ad1b7b5c68d05b9e2f66fcc96eefdb04614d3cdf6964705a5d18", bridgeNetwork.EndpointID); + Assert.AreEqual("172.17.0.1", bridgeNetwork.Gateway); + Assert.AreEqual("172.17.0.2", bridgeNetwork.IPAddress); + Assert.AreEqual(16, bridgeNetwork.IPPrefixLen); + Assert.AreEqual("", bridgeNetwork.IPv6Gateway); + Assert.AreEqual("", bridgeNetwork.GlobalIPv6Address); + Assert.AreEqual(0, bridgeNetwork.GlobalIPv6PrefixLen); + Assert.AreEqual("02:42:ac:11:00:02", bridgeNetwork.MacAddress); + } + + /// + /// Podman output should ideally be identical (or close as possible) to docker output, however, there are some + /// edge cases that don't require wild differences in parsing for the purposes of FluentDocker. These are: + /// - State.Health.Status is present, but empty string "" + /// - Config.Entrypoint is single string value instead of single string value inside of array + /// + /// The below output, captured from podman, is a representative example of these issues + /// + [TestMethod] + public void ProcessShallParsePodmanOutputResponse() + { + // Arrange + var stdOut = @"[ + { + ""Id"": ""f2b2805a9c8f54681f0b4035b730f4466182b6e4a96b32dfe41ad90eec18e0ff"", + ""Created"": ""2023-09-26T07:08:11.781224111+01:00"", + ""Path"": ""docker-entrypoint.sh"", + ""Args"": [ + ""postgres"", + ""-c"", + ""log_statement=all"" + ], + ""State"": { + ""OciVersion"": ""1.1.0-rc.3"", + ""Status"": ""created"", + ""Running"": false, + ""Paused"": false, + ""Restarting"": false, + ""OOMKilled"": false, + ""Dead"": false, + ""Pid"": 0, + ""ExitCode"": 0, + ""Error"": """", + ""StartedAt"": ""0001-01-01T00:00:00Z"", + ""FinishedAt"": ""0001-01-01T00:00:00Z"", + ""Health"": { + ""Status"": """", + ""FailingStreak"": 0, + ""Log"": null + }, + ""CheckpointedAt"": ""0001-01-01T00:00:00Z"", + ""RestoredAt"": ""0001-01-01T00:00:00Z"" + }, + ""Image"": ""83699f7b0d2c6ceef728c0276c97fa5e91f54132920183b1a3a3d4bfd572f6a8"", + ""ImageDigest"": ""sha256:00e6ed9967881099ce9e552be567537d0bb47c990dacb43229cc9494bfddd8a0"", + ""ImageName"": ""docker.io/library/postgres:11.16-alpine"", + ""Rootfs"": """", + ""Pod"": """", + ""ResolvConfPath"": """", + ""HostnamePath"": """", + ""HostsPath"": """", + ""StaticDir"": ""/var/lib/containers/storage/overlay-containers/f2b2805a9c8f54681f0b4035b730f4466182b6e4a96b32dfe41ad90eec18e0ff/userdata"", + ""OCIRuntime"": ""crun"", + ""ConmonPidFile"": ""/run/containers/storage/overlay-containers/f2b2805a9c8f54681f0b4035b730f4466182b6e4a96b32dfe41ad90eec18e0ff/userdata/conmon.pid"", + ""PidFile"": ""/run/containers/storage/overlay-containers/f2b2805a9c8f54681f0b4035b730f4466182b6e4a96b32dfe41ad90eec18e0ff/userdata/pidfile"", + ""Name"": ""test-postgres"", + ""RestartCount"": 0, + ""Driver"": ""overlay"", + ""MountLabel"": ""system_u:object_r:container_file_t:s0:c391,c801"", + ""ProcessLabel"": ""system_u:system_r:container_t:s0:c391,c801"", + ""AppArmorProfile"": """", + ""EffectiveCaps"": [ + ""CAP_CHOWN"", + ""CAP_DAC_OVERRIDE"", + ""CAP_FOWNER"", + ""CAP_FSETID"", + ""CAP_KILL"", + ""CAP_NET_BIND_SERVICE"", + ""CAP_SETFCAP"", + ""CAP_SETGID"", + ""CAP_SETPCAP"", + ""CAP_SETUID"", + ""CAP_SYS_CHROOT"" + ], + ""BoundingCaps"": [ + ""CAP_CHOWN"", + ""CAP_DAC_OVERRIDE"", + ""CAP_FOWNER"", + ""CAP_FSETID"", + ""CAP_KILL"", + ""CAP_NET_BIND_SERVICE"", + ""CAP_SETFCAP"", + ""CAP_SETGID"", + ""CAP_SETPCAP"", + ""CAP_SETUID"", + ""CAP_SYS_CHROOT"" + ], + ""ExecIDs"": [], + ""GraphDriver"": { + ""Name"": ""overlay"", + ""Data"": { + ""LowerDir"": ""/var/lib/containers/storage/overlay/6bf9cd0e63b06eb11653e354cfc55444c686f8362a86db160ab4e4ae12db3e1d/diff:/var/lib/containers/storage/overlay/3acd83daeb2d1d27e89c0546d0e0cd482a435d851cb5b08704b6b07e5959b340/diff:/var/lib/containers/storage/overlay/85718c02503a4fc52100de3d19d187b357cc0d840f7f5af2ddd41486a09dd65d/diff:/var/lib/containers/storage/overlay/ec5fd00bf7d2462b93c53fa6023cd870fb8654159308c33254f4da92d3634d76/diff:/var/lib/containers/storage/overlay/79ef70484e120299629a54bf56f6dd77448d0e5931b4ee7535ea01bb6b6169b4/diff:/var/lib/containers/storage/overlay/b7f9200cdc18821e98a876ee3783bf657eddf58b6b85c941f064b3441305a2d0/diff:/var/lib/containers/storage/overlay/9bdbaa99d8fe24a83bc29c65adad6a6aadd2b3f6647ee476cc7770da63f9f611/diff:/var/lib/containers/storage/overlay/5d3e392a13a0fdfbf8806cb4a5e4b0a92b5021103a146249d8a2c999f06a9772/diff"", + ""UpperDir"": ""/var/lib/containers/storage/overlay/836bd449504a6b8a3b225e0bed18df35d13cbf02250f6095d09aa0b6bd4bc3e3/diff"", + ""WorkDir"": ""/var/lib/containers/storage/overlay/836bd449504a6b8a3b225e0bed18df35d13cbf02250f6095d09aa0b6bd4bc3e3/work"" + } + }, + ""Mounts"": [ + { + ""Type"": ""volume"", + ""Name"": ""7c85b753cfa8603ebbad215e50e20a755f3a91f37de7633f9cded22aab63ef7c"", + ""Source"": ""/var/lib/containers/storage/volumes/7c85b753cfa8603ebbad215e50e20a755f3a91f37de7633f9cded22aab63ef7c/_data"", + ""Destination"": ""/var/lib/postgresql/data"", + ""Driver"": ""local"", + ""Mode"": """", + ""Options"": [ + ""nodev"", + ""exec"", + ""nosuid"", + ""rbind"" + ], + ""RW"": true, + ""Propagation"": ""rprivate"" + } + ], + ""Dependencies"": [], + ""NetworkSettings"": { + ""EndpointID"": """", + ""Gateway"": """", + ""IPAddress"": """", + ""IPPrefixLen"": 0, + ""IPv6Gateway"": """", + ""GlobalIPv6Address"": """", + ""GlobalIPv6PrefixLen"": 0, + ""MacAddress"": """", + ""Bridge"": """", + ""SandboxID"": """", + ""HairpinMode"": false, + ""LinkLocalIPv6Address"": """", + ""LinkLocalIPv6PrefixLen"": 0, + ""Ports"": { + ""5432/tcp"": [ + { + ""HostIp"": """", + ""HostPort"": ""5432"" + } + ] + }, + ""SandboxKey"": """", + ""Networks"": { + ""podman"": { + ""EndpointID"": """", + ""Gateway"": """", + ""IPAddress"": """", + ""IPPrefixLen"": 0, + ""IPv6Gateway"": """", + ""GlobalIPv6Address"": """", + ""GlobalIPv6PrefixLen"": 0, + ""MacAddress"": """", + ""NetworkID"": ""podman"", + ""DriverOpts"": null, + ""IPAMConfig"": null, + ""Links"": null, + ""Aliases"": [ + ""f2b2805a9c8f"" + ] + } + } + }, + ""Namespace"": """", + ""IsInfra"": false, + ""IsService"": false, + ""KubeExitCodePropagation"": ""invalid"", + ""lockNumber"": 1, + ""Config"": { + ""Hostname"": ""f2b2805a9c8f"", + ""Domainname"": """", + ""User"": """", + ""AttachStdin"": false, + ""AttachStdout"": false, + ""AttachStderr"": false, + ""Tty"": false, + ""OpenStdin"": false, + ""StdinOnce"": false, + ""Env"": [ + ""PGDATA=/var/lib/postgresql/data"", + ""LANG=en_US.utf8"", + ""POSTGRES_PASSWORD=password"", + ""PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"", + ""TERM=xterm"", + ""PG_MAJOR=11"", + ""POSTGRES_DB=userdb"", + ""POSTGRES_USER=user"", + ""container=podman"", + ""PG_VERSION=11.16"", + ""PG_SHA256=2dd9e111f0a5949ee7cacc065cea0fb21092929bae310ce05bf01b4ffc5103a5"" + ], + ""Cmd"": [ + ""postgres"", + ""-c"", + ""log_statement=all"" + ], + ""Image"": ""docker.io/library/postgres:11.16-alpine"", + ""Volumes"": null, + ""WorkingDir"": ""/"", + ""Entrypoint"": ""docker-entrypoint.sh"", + ""OnBuild"": null, + ""Labels"": null, + ""Annotations"": null, + ""StopSignal"": 2, + ""HealthcheckOnFailureAction"": ""none"", + ""CreateCommand"": [ + ""podman"", + ""create"", + ""--name"", + ""test-postgres"", + ""-p"", + ""5432:5432"", + ""-e"", + ""POSTGRES_PASSWORD=password"", + ""-e"", + ""POSTGRES_USER=user"", + ""-e"", + ""POSTGRES_DB=userdb"", + ""postgres:11.16-alpine"", + ""postgres"", + ""-c"", + ""log_statement=all"" + ], + ""Umask"": ""0022"", + ""Timeout"": 0, + ""StopTimeout"": 10, + ""Passwd"": true, + ""sdNotifyMode"": ""container"" + }, + ""HostConfig"": { + ""Binds"": [ + ""7c85b753cfa8603ebbad215e50e20a755f3a91f37de7633f9cded22aab63ef7c:/var/lib/postgresql/data:rprivate,rw,nodev,exec,nosuid,rbind"" + ], + ""CgroupManager"": ""systemd"", + ""CgroupMode"": ""private"", + ""ContainerIDFile"": """", + ""LogConfig"": { + ""Type"": ""journald"", + ""Config"": null, + ""Path"": """", + ""Tag"": """", + ""Size"": ""0B"" + }, + ""NetworkMode"": ""bridge"", + ""PortBindings"": { + ""5432/tcp"": [ + { + ""HostIp"": """", + ""HostPort"": ""5432"" + } + ] + }, + ""RestartPolicy"": { + ""Name"": """", + ""MaximumRetryCount"": 0 + }, + ""AutoRemove"": false, + ""VolumeDriver"": """", + ""VolumesFrom"": null, + ""CapAdd"": [], + ""CapDrop"": [], + ""Dns"": [], + ""DnsOptions"": [], + ""DnsSearch"": [], + ""ExtraHosts"": [], + ""GroupAdd"": [], + ""IpcMode"": ""shareable"", + ""Cgroup"": """", + ""Cgroups"": ""default"", + ""Links"": null, + ""OomScoreAdj"": 0, + ""PidMode"": ""private"", + ""Privileged"": false, + ""PublishAllPorts"": false, + ""ReadonlyRootfs"": false, + ""SecurityOpt"": [], + ""Tmpfs"": {}, + ""UTSMode"": ""private"", + ""UsernsMode"": """", + ""ShmSize"": 65536000, + ""Runtime"": ""oci"", + ""ConsoleSize"": [ + 0, + 0 + ], + ""Isolation"": """", + ""CpuShares"": 0, + ""Memory"": 0, + ""NanoCpus"": 0, + ""CgroupParent"": """", + ""BlkioWeight"": 0, + ""BlkioWeightDevice"": null, + ""BlkioDeviceReadBps"": null, + ""BlkioDeviceWriteBps"": null, + ""BlkioDeviceReadIOps"": null, + ""BlkioDeviceWriteIOps"": null, + ""CpuPeriod"": 0, + ""CpuQuota"": 0, + ""CpuRealtimePeriod"": 0, + ""CpuRealtimeRuntime"": 0, + ""CpusetCpus"": """", + ""CpusetMems"": """", + ""Devices"": [], + ""DiskQuota"": 0, + ""KernelMemory"": 0, + ""MemoryReservation"": 0, + ""MemorySwap"": 0, + ""MemorySwappiness"": 0, + ""OomKillDisable"": false, + ""PidsLimit"": 2048, + ""Ulimits"": [ + { + ""Name"": ""RLIMIT_NPROC"", + ""Soft"": 4194304, + ""Hard"": 4194304 + } + ], + ""CpuCount"": 0, + ""CpuPercent"": 0, + ""IOMaximumIOps"": 0, + ""IOMaximumBandwidth"": 0, + ""CgroupConf"": null + } + } + ]"; + var ctorArgs = new object[] { "command", stdOut, "", 0 }; + var executionResult = (ProcessExecutionResult)Activator.CreateInstance(typeof(ProcessExecutionResult), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.CreateInstance, + null, ctorArgs, null, null); + + var parser = new ClientContainerInspectCommandResponder(); + + // Act + var result = parser.Process(executionResult); + + // Assert + Assert.IsNull(result.Response.Data.State.Health.Status); + Assert.AreEqual(1, result.Response.Data.Config.EntryPoint.Length); + Assert.AreEqual("docker-entrypoint.sh", result.Response.Data.Config.EntryPoint[0]); + } + } +} diff --git a/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientTopResponseParserTests.cs b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientTopResponseParserTests.cs new file mode 100644 index 00000000..b7124ac2 --- /dev/null +++ b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientTopResponseParserTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Linq; +using System.Reflection; +using Ductus.FluentDocker.Executors; +using Ductus.FluentDocker.Executors.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ductus.FluentDocker.Tests.ProcessResponseParsersTests +{ + [TestClass] + public class ClientTopResponseParserTests + { + [TestMethod] + public void ProcessShallParseResponse() + { + // Arrange + var stdOut = + @"UID PID PPID C STIME TTY TIME CMD +999 7765 7735 0 20:23 ? 00:00:00 postgres +999 7838 7765 0 20:23 ? 00:00:00 postgres: checkpointer +999 7839 7765 0 20:23 ? 00:00:00 postgres: background writer +999 7841 7765 0 20:23 ? 00:00:00 postgres: walwriter +999 7842 7765 0 20:23 ? 00:00:00 postgres: autovacuum launcher +999 7843 7765 0 20:23 ? 00:00:00 postgres: logical replication launcher"; + var ctorArgs = new object[] { "command", stdOut, "", 0 }; + var executionResult = (ProcessExecutionResult)Activator.CreateInstance(typeof(ProcessExecutionResult), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.CreateInstance, + null, ctorArgs, null, null); + + var parser = new ClientTopResponseParser(); + + // Act + var result = parser.Process(executionResult); + + // Assert + Assert.IsTrue(result.Response.Success); + var processes = result.Response.Data; + Assert.IsTrue(processes.Rows.All(row => row.User == "999")); + Assert.IsTrue(processes.Rows.All(row => row.Started == new TimeSpan(20, 23, 0))); + Assert.IsTrue(processes.Rows.All(row => row.Time == TimeSpan.Zero)); + Assert.IsTrue(processes.Rows.All(row => row.Tty == "?")); + Assert.AreEqual(7765, processes.Rows[0].Pid); + Assert.AreEqual(7838, processes.Rows[1].Pid); + Assert.AreEqual(7839, processes.Rows[2].Pid); + Assert.AreEqual(7841, processes.Rows[3].Pid); + Assert.AreEqual(7842, processes.Rows[4].Pid); + Assert.AreEqual(7843, processes.Rows[5].Pid); + + Assert.AreEqual(7735, processes.Rows[0].ProcessPid); + Assert.AreEqual(7765, processes.Rows[1].ProcessPid); + Assert.AreEqual(7765, processes.Rows[2].ProcessPid); + Assert.AreEqual(7765, processes.Rows[3].ProcessPid); + Assert.AreEqual(7765, processes.Rows[4].ProcessPid); + Assert.AreEqual(7765, processes.Rows[5].ProcessPid); + + Assert.AreEqual("postgres", processes.Rows[0].Command); + Assert.AreEqual("postgres: checkpointer", processes.Rows[1].Command); + Assert.AreEqual("postgres: background writer", processes.Rows[2].Command); + Assert.AreEqual("postgres: walwriter", processes.Rows[3].Command); + Assert.AreEqual("postgres: autovacuum launcher", processes.Rows[4].Command); + Assert.AreEqual("postgres: logical replication launcher", processes.Rows[5].Command); + } + + + [TestMethod] + public void ProcessShallParsePodmanOutput() + { + // Arrange + var stdOut = + @"USER PID PPID %CPU ELAPSED TTY TIME COMMAND +postgres 1 0 0.000 12h0m5.863267473s ? 0s postgres +postgres 55 1 0.000 12h0m4.863350723s ? 0s postgres: checkpointer +postgres 56 1 0.000 12h0m4.863378515s ? 0s postgres: background writer +postgres 58 1 0.000 12h0m4.863404598s ? 0s postgres: walwriter +postgres 59 1 0.000 12h0m4.86343039s ? 0s postgres: autovacuum launcher +postgres 60 1 0.000 12h0m4.863453973s ? 0s postgres: logical replication launcher"; + var ctorArgs = new object[] { "command", stdOut, "", 0 }; + var executionResult = (ProcessExecutionResult)Activator.CreateInstance(typeof(ProcessExecutionResult), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.CreateInstance, + null, ctorArgs, null, null); + + // Act + var parser = new ClientTopResponseParser(); + var result = parser.Process(executionResult); + + // Assert + Assert.IsTrue(result.Response.Success); + var processes = result.Response.Data; + Assert.IsTrue(processes.Rows.All(row => row.User == "postgres")); + Assert.IsTrue(processes.Rows.All(row => row.PercentCpuUtilization == 0f)); + Assert.IsTrue(processes.Rows.All(row => row.Tty == "?")); + Assert.IsTrue(processes.Rows.All(row => row.Time == TimeSpan.Zero)); + Assert.AreEqual(1, processes.Rows[0].Pid); + Assert.AreEqual(55, processes.Rows[1].Pid); + Assert.AreEqual(56, processes.Rows[2].Pid); + Assert.AreEqual(58, processes.Rows[3].Pid); + Assert.AreEqual(59, processes.Rows[4].Pid); + Assert.AreEqual(60, processes.Rows[5].Pid); + + Assert.AreEqual(0, processes.Rows[0].ProcessPid); + Assert.AreEqual(1, processes.Rows[1].ProcessPid); + Assert.AreEqual(1, processes.Rows[2].ProcessPid); + Assert.AreEqual(1, processes.Rows[3].ProcessPid); + Assert.AreEqual(1, processes.Rows[4].ProcessPid); + Assert.AreEqual(1, processes.Rows[5].ProcessPid); + + Assert.AreEqual("postgres", processes.Rows[0].Command); + Assert.AreEqual("postgres: checkpointer", processes.Rows[1].Command); + Assert.AreEqual("postgres: background writer", processes.Rows[2].Command); + Assert.AreEqual("postgres: walwriter", processes.Rows[3].Command); + Assert.AreEqual("postgres: autovacuum launcher", processes.Rows[4].Command); + Assert.AreEqual("postgres: logical replication launcher", processes.Rows[5].Command); + } + } +} diff --git a/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/MinimalNetworkLsResponseParserTests.cs b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/MinimalNetworkLsResponseParserTests.cs new file mode 100644 index 00000000..1b47d3c0 --- /dev/null +++ b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/MinimalNetworkLsResponseParserTests.cs @@ -0,0 +1,36 @@ +using System; +using System.Reflection; +using Ductus.FluentDocker.Executors; +using Ductus.FluentDocker.Executors.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ductus.FluentDocker.Tests.ProcessResponseParsersTests +{ + [TestClass] + public class MinimalNetworkLsResponseParserTests + { + [TestMethod] + public void ProcessShallParseResponse() + { + // Arrange + var id = Guid.NewGuid().ToString(); + var name = Guid.NewGuid().ToString(); + + var stdOut = $"{id};{name}"; + + var ctorArgs = new object[] { "command", stdOut, "", 0 }; + var executionResult = (ProcessExecutionResult)Activator.CreateInstance(typeof(ProcessExecutionResult), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.CreateInstance, + null, ctorArgs, null, null); + + var parser = new MinimalNetworkLsResponseParser(); + + // Act + var result = parser.Process(executionResult).Response.Data[0]; + + // Assert + Assert.AreEqual(id, result.Id); + Assert.AreEqual(name, result.Name); + } + } +} diff --git a/Ductus.FluentDocker/Commands/Network.cs b/Ductus.FluentDocker/Commands/Network.cs index b28816fd..d01d92e5 100644 --- a/Ductus.FluentDocker/Commands/Network.cs +++ b/Ductus.FluentDocker/Commands/Network.cs @@ -23,10 +23,22 @@ public static CommandResponse> NetworkLs(this DockerUri host, if (null != filters && 0 != filters.Length) options = filters.Aggregate(options, (current, filter) => current + $" --filter={filter}"); - return + var fullNetworkDetails = new ProcessExecutor>( "docker".ResolveBinary(), $"{args} network ls {options}").Execute(); + if (fullNetworkDetails.Success) + return fullNetworkDetails; + + options = $" --no-trunc --format \"{MinimalNetworkLsResponseParser.Format}\""; + + if (null != filters && 0 != filters.Length) + options = filters.Aggregate(options, (current, filter) => current + $" --filter={filter}"); + + return + new ProcessExecutor>( + "docker".ResolveBinary(), + $"{args} network ls {options}").Execute(); } public static CommandResponse> NetworkConnect(this DockerUri host, string container, string network, diff --git a/Ductus.FluentDocker/Common/JsonArrayOrSingleConverter.cs b/Ductus.FluentDocker/Common/JsonArrayOrSingleConverter.cs new file mode 100644 index 00000000..bbd38373 --- /dev/null +++ b/Ductus.FluentDocker/Common/JsonArrayOrSingleConverter.cs @@ -0,0 +1,34 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Ductus.FluentDocker.Common +{ + public class JsonArrayOrSingleConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType == typeof(T[]); + } + + public override object ReadJson( + JsonReader reader, + Type objectType, + object existingValue, + JsonSerializer serializer) + { + var token = JToken.Load(reader); + if (token.Type == JTokenType.Array) + { + return token.ToObject(); + } + + return new[] {token.ToObject()}; + } + + public override bool CanWrite => false; + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => + throw new NotImplementedException(); + } +} diff --git a/Ductus.FluentDocker/Executors/Parsers/MinimalNetworkLsResponseParser.cs b/Ductus.FluentDocker/Executors/Parsers/MinimalNetworkLsResponseParser.cs new file mode 100644 index 00000000..1b7d3f6f --- /dev/null +++ b/Ductus.FluentDocker/Executors/Parsers/MinimalNetworkLsResponseParser.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using Ductus.FluentDocker.Model.Containers; +using Ductus.FluentDocker.Model.Networks; + +namespace Ductus.FluentDocker.Executors.Parsers +{ + public sealed class MinimalNetworkLsResponseParser : IProcessResponseParser> + { + public const string Format = "{{.ID}};{{.Name}}"; + + public CommandResponse> Response { get; private set; } + + public IProcessResponse> Process(ProcessExecutionResult response) + { + if (response.ExitCode != 0) + { + Response = response.ToErrorResponse((IList)new List()); + return this; + } + + if (string.IsNullOrEmpty(response.StdOut)) + { + Response = response.ToResponse(false, "No response", (IList)new List()); + return this; + } + + var result = new List(); + + foreach (var row in response.StdOutAsArray) + { + var items = row.Split(';'); + if (items.Length < 2) + continue; + + result.Add(new NetworkRow + { + Id = items[0], + Name = items[1], + }); + } + + Response = response.ToResponse(true, string.Empty, (IList)result); + return this; + } + } +} diff --git a/Ductus.FluentDocker/Model/Containers/ContainerConfig.cs b/Ductus.FluentDocker/Model/Containers/ContainerConfig.cs index f039bac5..e781e9b8 100644 --- a/Ductus.FluentDocker/Model/Containers/ContainerConfig.cs +++ b/Ductus.FluentDocker/Model/Containers/ContainerConfig.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using Ductus.FluentDocker.Common; +using Newtonsoft.Json; namespace Ductus.FluentDocker.Model.Containers { @@ -26,6 +28,7 @@ public string Domainname public string Image { get; set; } public IDictionary Volumes { get; set; } public string WorkingDir { get; set; } + [JsonConverter(typeof(JsonArrayOrSingleConverter))] public string[] EntryPoint { get; set; } public IDictionary Labels { get; set; } public string StopSignal { get; set; } diff --git a/Ductus.FluentDocker/Model/Containers/Health.cs b/Ductus.FluentDocker/Model/Containers/Health.cs index 10e81a2f..00e33496 100644 --- a/Ductus.FluentDocker/Model/Containers/Health.cs +++ b/Ductus.FluentDocker/Model/Containers/Health.cs @@ -2,7 +2,7 @@ namespace Ductus.FluentDocker.Model.Containers { public class Health { - public HealthState Status { get; set; } + public HealthState? Status { get; set; } public int FailingStreak { get; set; } } } diff --git a/Ductus.FluentDocker/Model/Containers/ProcessRow.cs b/Ductus.FluentDocker/Model/Containers/ProcessRow.cs index c9e5ed3a..cd33d186 100644 --- a/Ductus.FluentDocker/Model/Containers/ProcessRow.cs +++ b/Ductus.FluentDocker/Model/Containers/ProcessRow.cs @@ -65,10 +65,10 @@ internal static ProcessRow ToRow(IList columns, IList fullRow) break; case StartConst: case StartTimeConst: - row.Started = TimeSpan.Parse(fullRow[i]); + row.Started = Parse(fullRow[i]); break; case TimeConst: - row.Time = TimeSpan.Parse(fullRow[i]); + row.Time = Parse(fullRow[i]); break; case TerminalConst: row.Tty = fullRow[i]; @@ -77,7 +77,7 @@ internal static ProcessRow ToRow(IList columns, IList fullRow) row.Status = fullRow[i]; break; case CpuTime: - if (TimeSpan.TryParse(fullRow[i], out var cpuTime)) + if (TryParse(fullRow[i], out var cpuTime)) row.Cpu = cpuTime; break; case PercentCpuConst: @@ -91,5 +91,29 @@ internal static ProcessRow ToRow(IList columns, IList fullRow) return row; } + + private static TimeSpan Parse(string value) + { + if (TimeSpan.TryParse(value, out var result)) + return result; + if (TimeSpan.TryParseExact(value, @"%s\s", CultureInfo.InvariantCulture, out result)) // E.G. 0s or 12s + return result; + if (TimeSpan.TryParseExact(value, @"%m\m%s\s", CultureInfo.InvariantCulture, out result)) // E.G. 0m0s or 12m34s + return result; + return TimeSpan.ParseExact(value, @"%h\h%m\m%s\s", CultureInfo.InvariantCulture); // E.G. 0h0m0s or 12h34m56s + } + + private static bool TryParse(string value, out TimeSpan result) + { + if (TimeSpan.TryParse(value, out result)) + return true; + if (TimeSpan.TryParseExact(value, @"%s\s", CultureInfo.InvariantCulture, out result)) // E.G. 0s or 12s + return true; + if (TimeSpan.TryParseExact(value, @"%m\m%s\s", CultureInfo.InvariantCulture, out result)) // E.G. 0m0s or 12m34s + return true; + if (TimeSpan.TryParseExact(value, @"%h\h%m\m%s\s", CultureInfo.InvariantCulture, out result)) // E.G. 0h0m0s or 12h34m56s + return true; + return false; + } } }