From ff3b5288e7e31fbb741429d73b013542775efeb0 Mon Sep 17 00:00:00 2001 From: Rory Reid Date: Wed, 27 Sep 2023 21:46:10 +0100 Subject: [PATCH 1/5] Add tests for docker inspect parsing This includes a failing test for a result observed with podman --- ...ntContainerInspectCommandResponderTests.cs | 689 ++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientContainerInspectCommandResponderTests.cs diff --git a/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientContainerInspectCommandResponderTests.cs b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientContainerInspectCommandResponderTests.cs new file mode 100644 index 0000000..2c8643a --- /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]); + } + } +} From 1dd0dd66699eb5b90dd31c5258a75eb49b86c532 Mon Sep 17 00:00:00 2001 From: Rory Reid Date: Thu, 28 Sep 2023 21:58:31 +0100 Subject: [PATCH 2/5] Change Container.State.Health.Status to be nullable The only place this is used (.WaitForHealthy()) already performs a null- safe check against the Status, comparing it to "Healthy" only. Changing the property to nullable allows more defensive parsing if it is an empty string for whatever reason, and is still interpreted as "Unhealthy" where it matters. --- Ductus.FluentDocker/Model/Containers/Health.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Ductus.FluentDocker/Model/Containers/Health.cs b/Ductus.FluentDocker/Model/Containers/Health.cs index 10e81a2..00e3349 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; } } } From b43feb4ef0bc8934d2159499a8c35a46076d44fa Mon Sep 17 00:00:00 2001 From: Rory Reid Date: Thu, 28 Sep 2023 22:07:38 +0100 Subject: [PATCH 3/5] Parse container EntryPoint from array or string This adds and uses a custom converter which will parse Container.Config.Entrypoint from a json string array I.E. ```json { "EntryPoint": [ "my-entrypoint" ] } ``` Or from a single string value, I.E: ```json { "EntryPoint": "my-entrypoint" } ``` --- .../Common/JsonArrayOrSingleConverter.cs | 34 +++++++++++++++++++ .../Model/Containers/ContainerConfig.cs | 3 ++ 2 files changed, 37 insertions(+) create mode 100644 Ductus.FluentDocker/Common/JsonArrayOrSingleConverter.cs diff --git a/Ductus.FluentDocker/Common/JsonArrayOrSingleConverter.cs b/Ductus.FluentDocker/Common/JsonArrayOrSingleConverter.cs new file mode 100644 index 0000000..bbd3837 --- /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/Model/Containers/ContainerConfig.cs b/Ductus.FluentDocker/Model/Containers/ContainerConfig.cs index f039bac..e781e9b 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; } From d37ad0a5b98eb8fb58270b2001f32894dfd12015 Mon Sep 17 00:00:00 2001 From: Rory Reid Date: Fri, 29 Sep 2023 21:43:22 +0100 Subject: [PATCH 4/5] Parse minimal network ls output if full output fails In some cases the `--format` specification doesn't match the output resulting in an error such as: `Error: template: ls:1:43: executing "ls" at <.Scope>: can't evaluate field Scope in type network.ListPrintReports"` If the full command is unsuccessful, this falls back to parsing just the ID and Name, which prevents crashes at the cost of a less rich experience. --- .../MinimalNetworkLsResponseParserTests.cs | 36 ++++++++++++++ Ductus.FluentDocker/Commands/Network.cs | 14 +++++- .../Parsers/MinimalNetworkLsResponseParser.cs | 47 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 Ductus.FluentDocker.Tests/ProcessResponseParsersTests/MinimalNetworkLsResponseParserTests.cs create mode 100644 Ductus.FluentDocker/Executors/Parsers/MinimalNetworkLsResponseParser.cs diff --git a/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/MinimalNetworkLsResponseParserTests.cs b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/MinimalNetworkLsResponseParserTests.cs new file mode 100644 index 0000000..1b47d3c --- /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 b28816f..d01d92e 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/Executors/Parsers/MinimalNetworkLsResponseParser.cs b/Ductus.FluentDocker/Executors/Parsers/MinimalNetworkLsResponseParser.cs new file mode 100644 index 0000000..1b7d3f6 --- /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; + } + } +} From 083f53339c5c881dcf16520096e86eb6ded41a00 Mon Sep 17 00:00:00 2001 From: Rory Reid Date: Fri, 29 Sep 2023 21:46:46 +0100 Subject: [PATCH 5/5] Parse process timespans with format 0h0m0s This introduces a slightly more complex parsing method for timespans in the ProcessRow class in order to deal with more diverse formats (such as the format output by `podman top` - 1h2m3s vs 01:02:03). This also adds a simple test to validate the parser's current behaviour with docker, and a test to confirm it can parse podman output too without crashing. --- .../ClientTopResponseParserTests.cs | 115 ++++++++++++++++++ .../Model/Containers/ProcessRow.cs | 30 ++++- 2 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientTopResponseParserTests.cs diff --git a/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientTopResponseParserTests.cs b/Ductus.FluentDocker.Tests/ProcessResponseParsersTests/ClientTopResponseParserTests.cs new file mode 100644 index 0000000..b7124ac --- /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/Model/Containers/ProcessRow.cs b/Ductus.FluentDocker/Model/Containers/ProcessRow.cs index c9e5ed3..cd33d18 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; + } } }