diff --git a/package.json b/package.json index 3f28195036c99..36b8f694f6485 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "check-disk-space": "^3.4.0", "chokidar": "^3.5.3", "compare-versions": "^6.1.0", + "date.js": "^0.3.3", "dockerode": "^3.3.5", "electron-context-menu": "^3.6.1", "electron-updater": "6.1.1", diff --git a/packages/main/src/plugin/api/container-info.ts b/packages/main/src/plugin/api/container-info.ts index c62f77589a3fd..3a584c116ca7e 100644 --- a/packages/main/src/plugin/api/container-info.ts +++ b/packages/main/src/plugin/api/container-info.ts @@ -18,7 +18,14 @@ import type Dockerode from 'dockerode'; -export interface ContainerInfo extends Dockerode.ContainerInfo { +export interface ContainerPortInfo { + IP: string; + PrivatePort: number; + PublicPort: number; + Type: string; +} + +export interface ContainerInfo { engineId: string; engineName: string; engineType: 'podman' | 'docker'; @@ -29,6 +36,16 @@ export interface ContainerInfo extends Dockerode.ContainerInfo { status: string; engineId: string; }; + Id: string; + Names: string[]; + Image: string; + ImageID: string; + Command: string; + Created: number; + Ports: ContainerPortInfo[]; + Labels: { [label: string]: string }; + State: string; + Status?: string; } export interface SimpleContainerInfo extends Dockerode.ContainerInfo { diff --git a/packages/main/src/plugin/container-registry.spec.ts b/packages/main/src/plugin/container-registry.spec.ts index 80770c87f9e51..2e96e93344bae 100644 --- a/packages/main/src/plugin/container-registry.spec.ts +++ b/packages/main/src/plugin/container-registry.spec.ts @@ -27,9 +27,13 @@ import type { ApiSenderType } from '/@/plugin/api.js'; import Dockerode from 'dockerode'; import { EventEmitter } from 'node:events'; import type * as podmanDesktopAPI from '@podman-desktop/api'; +import nock from 'nock'; +import { LibpodDockerode } from './dockerode/libpod-dockerode.js'; +import moment from 'moment'; /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-null/no-null */ const fakeContainerWithComposeProject: Dockerode.ContainerInfo = { Id: '1234567890', @@ -523,3 +527,210 @@ test('getFirstRunningConnection', async () => { expect(connection[0].name).toBe('podman1'); expect(connection[0].endpoint.socketPath).toBe('/podman1.socket'); }); + +describe('listContainers', () => { + test('list containers with Podman API', async () => { + const containersWithPodmanAPI = [ + { + AutoRemove: false, + Command: ['httpd-foreground'], + Created: '2023-08-10T15:37:44.555961563+02:00', + CreatedAt: '', + Exited: true, + ExitedAt: 1691674673, + ExitCode: 0, + Id: '31a4b282691420be2611817f203765402d8da7e13cd530f80a6ddd1bb4aa63b4', + Image: 'docker.io/library/httpd:latest', + ImageID: '911d72fc5020723f0c003a134a8d2f062b4aea884474a11d1db7dcd28ce61d6a', + IsInfra: false, + Labels: { + 'io.buildah.version': '1.30.0', + maintainer: 'Podman Maintainers', + }, + Mounts: [], + Names: ['admiring_wing'], + Namespaces: {}, + Networks: ['podman'], + Pid: 0, + Pod: '', + PodName: '', + Ports: [ + { + host_ip: '', + container_port: 8080, + host_port: 8080, + range: 1, + protocol: 'tcp', + }, + ], + Restarts: 0, + Size: null, + StartedAt: 1691674664, + State: 'running', + Status: '', + }, + ]; + + nock('http://localhost').get('/v4.2.0/libpod/containers/json?all=true').reply(200, containersWithPodmanAPI); + + // mock listPods + + nock('http://localhost').get('/v4.2.0/libpod/pods/json').reply(200, []); + + const dockerAPI = new Dockerode({ protocol: 'http', host: 'localhost' }); + + const libpod = new LibpodDockerode(); + libpod.enhancePrototypeWithLibPod(); + + // set providers with docker being first + containerRegistry.addInternalProvider('podman1', { + name: 'podman', + id: 'podman1', + api: dockerAPI, + libpodApi: dockerAPI, + connection: { + type: 'podman', + }, + } as unknown as InternalContainerProvider); + + const containers = await containerRegistry.listContainers(); + + // ensure the field are correct + expect(containers).toBeDefined(); + expect(containers).toHaveLength(1); + const container = containers[0]; + expect(container.engineId).toBe('podman1'); + expect(container.engineName).toBe('podman'); + expect(container.engineType).toBe('podman'); + expect(container.StartedAt).toBe('2023-08-10T13:37:44.000Z'); + expect(container.pod).toBeUndefined(); + expect(container.Id).toBe('31a4b282691420be2611817f203765402d8da7e13cd530f80a6ddd1bb4aa63b4'); + expect(container.Command).toBe('httpd-foreground'); + expect(container.Names).toStrictEqual(['/admiring_wing']); + expect(container.Image).toBe('docker.io/library/httpd:latest'); + expect(container.ImageID).toBe('sha256:911d72fc5020723f0c003a134a8d2f062b4aea884474a11d1db7dcd28ce61d6a'); + expect(container.Created).toBe(1691674664); + expect(container.Ports).toStrictEqual([ + { + IP: '', + PrivatePort: 8080, + PublicPort: 8080, + Type: 'tcp', + }, + ]); + expect(container.Labels).toStrictEqual({ + 'io.buildah.version': '1.30.0', + maintainer: 'Podman Maintainers', + }); + expect(container.State).toBe('running'); + }); + + test('list containers with Docker API', async () => { + const containersWithDockerAPI = [ + { + Id: '31a4b282691420be2611817f203765402d8da7e13cd530f80a6ddd1bb4aa63b4', + Names: ['/admiring_wing'], + Image: 'docker.io/library/httpd:latest', + ImageID: 'sha256:911d72fc5020723f0c003a134a8d2f062b4aea884474a11d1db7dcd28ce61d6a', + Command: 'httpd-foreground', + Created: 1691674664, + Ports: [ + { + PrivatePort: 8080, + PublicPort: 8080, + Type: 'tcp', + }, + ], + Labels: { + 'io.buildah.version': '1.30.0', + maintainer: 'Podman Maintainers', + }, + State: 'running', + Status: 'Up 2 minutes', + NetworkSettings: { + Networks: { + podman: { + IPAMConfig: null, + Links: null, + Aliases: ['31a4b2826914'], + NetworkID: 'podman', + EndpointID: '', + Gateway: '10.88.0.1', + IPAddress: '10.88.0.4', + IPPrefixLen: 16, + IPv6Gateway: '', + GlobalIPv6Address: '', + GlobalIPv6PrefixLen: 0, + MacAddress: '7e:49:fe:9b:2e:3a', + DriverOpts: null, + }, + }, + }, + Mounts: [], + Name: '', + Config: null, + NetworkingConfig: null, + Platform: null, + AdjustCPUShares: false, + }, + ]; + + nock('http://localhost').get('/containers/json?all=true').reply(200, containersWithDockerAPI); + + // mock listPods + + nock('http://localhost').get('/v4.2.0/libpod/pods/json').reply(200, []); + + const dockerAPI = new Dockerode({ protocol: 'http', host: 'localhost' }); + + // set providers with docker being first + containerRegistry.addInternalProvider('docker', { + name: 'docker', + id: 'docker1', + api: dockerAPI, + connection: { + type: 'docker', + }, + } as unknown as InternalContainerProvider); + + const containers = await containerRegistry.listContainers(); + + // ensure the field are correct + expect(containers).toBeDefined(); + expect(containers).toHaveLength(1); + const container = containers[0]; + expect(container.engineId).toBe('docker1'); + expect(container.engineName).toBe('docker'); + expect(container.engineType).toBe('docker'); + + // grab StartedAt from the containerWithDockerAPI + const started = container.StartedAt; + + //convert with moment + const diff = moment.now() - moment(started).toDate().getTime(); + const delta = Math.round(moment.duration(diff).asMinutes()); + + // expect delta to be 2 minutes + expect(delta).toBe(2); + expect(container.pod).toBeUndefined(); + + expect(container.Id).toBe('31a4b282691420be2611817f203765402d8da7e13cd530f80a6ddd1bb4aa63b4'); + expect(container.Command).toBe('httpd-foreground'); + expect(container.Names).toStrictEqual(['/admiring_wing']); + expect(container.Image).toBe('docker.io/library/httpd:latest'); + expect(container.ImageID).toBe('sha256:911d72fc5020723f0c003a134a8d2f062b4aea884474a11d1db7dcd28ce61d6a'); + expect(container.Created).toBe(1691674664); + expect(container.Ports).toStrictEqual([ + { + PrivatePort: 8080, + PublicPort: 8080, + Type: 'tcp', + }, + ]); + expect(container.Labels).toStrictEqual({ + 'io.buildah.version': '1.30.0', + maintainer: 'Podman Maintainers', + }); + expect(container.State).toBe('running'); + }); +}); diff --git a/packages/main/src/plugin/container-registry.ts b/packages/main/src/plugin/container-registry.ts index aeb4c3babea05..84c5e3f66e965 100644 --- a/packages/main/src/plugin/container-registry.ts +++ b/packages/main/src/plugin/container-registry.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (C) 2022 Red Hat, Inc. + * Copyright (C) 2022-2023 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,12 @@ import type * as containerDesktopAPI from '@podman-desktop/api'; import { Disposable } from './types/disposable.js'; import Dockerode from 'dockerode'; import StreamValues from 'stream-json/streamers/StreamValues.js'; -import type { ContainerCreateOptions, ContainerInfo, SimpleContainerInfo } from './api/container-info.js'; +import type { + ContainerCreateOptions, + ContainerInfo, + ContainerPortInfo, + SimpleContainerInfo, +} from './api/container-info.js'; import type { ImageInfo } from './api/image-info.js'; import type { PodInfo, PodInspectInfo } from './api/pod-info.js'; import type { ImageInspectInfo } from './api/image-inspect-info.js'; @@ -52,6 +57,7 @@ import { pipeline } from 'node:stream/promises'; import type { ApiSenderType } from './api.js'; import type { Stream } from 'stream'; import { Writable } from 'stream'; +import datejs from 'date.js'; export interface InternalContainerProvider { name: string; @@ -290,44 +296,99 @@ export class ContainerProviderRegistry { return []; } - // grab time from the provider - const providerInfo = await providerApi.info(); - // Current system-time in RFC 3339 format with nano-seconds. - const vmTime = providerInfo.SystemTime; - - // we can't trust the time from the VM, so need to compute delta - // between our time and the VM time - // https://github.com/containers/podman/issues/11541 - const delta = moment().diff(vmTime); + // local type used to convert Podman containers to Dockerode containers + interface CompatContainerInfo { + Id: string; + Names: string[]; + Image: string; + ImageID: string; + Command: string; + Created: number; + Ports: ContainerPortInfo[]; + Labels: { [label: string]: string }; + State: string; + StartedAt?: string; + Status?: string; + } - let pods: LibpodPodInfo[] = []; + // if we have a libpod API, grab containers using Podman API + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let containers: CompatContainerInfo[] = []; if (provider.libpodApi) { - pods = await provider.libpodApi.listPods(); - } + const podmanContainers = await provider.libpodApi.listPodmanContainers({ all: true }); + + // convert Podman containers to Dockerode containers + containers = podmanContainers.map(podmanContainer => { + // get labels or nothing + const Labels: { [label: string]: string } = {}; + if (podmanContainer.Labels) { + // copy all labels + for (const label of Object.keys(podmanContainer.Labels)) { + Labels[label] = podmanContainer.Labels[label]; + } + } - const containers = await providerApi.listContainers({ all: true }); - return Promise.all( - containers.map(async container => { + // get labels or nothing + let Ports: ContainerPortInfo[] = []; + + if (podmanContainer.Ports) { + Ports = podmanContainer.Ports.map(port => { + return { + PrivatePort: port.container_port, + PublicPort: port.host_port, + Type: port.protocol, + IP: port.host_ip, + }; + }); + } + + // convert StartedAt which is a unix timestamp to a iso8601 date + const StartedAt = moment.unix(podmanContainer.StartedAt).toISOString(); + return { + Id: podmanContainer.Id, + Names: podmanContainer.Names.map(name => `/${name}`), + ImageID: `sha256:${podmanContainer.ImageID}`, + Image: podmanContainer.Image, + // convert to unix timestamp + Created: moment(podmanContainer.Created).unix(), + State: podmanContainer.State, + StartedAt, + Command: podmanContainer.Command[0], + Labels, + Ports, + }; + }); + } else { + containers = await providerApi.listContainers({ all: true }); + containers.forEach(container => { let StartedAt; - if (container.State.toUpperCase() === 'RUNNING') { - // grab additional field like StartedAt + if (container.State.toUpperCase() === 'RUNNING' && !container.StartedAt && container.Status) { + // convert the Status like "Up 2 minutes" to a date + // remove up from the status + const status = container.Status.replace('Up ', ''); + // add ago at the end + const statusWithAgo = status.concat(' ago'); + try { - const containerData = providerApi.getContainer(container.Id); - const containerInspect = await containerData.inspect(); - // needs to adjust - const correctedDate = moment(containerInspect.State.StartedAt) - .add(delta, 'milliseconds') - .toISOString(); - StartedAt = correctedDate; + StartedAt = new Date(datejs(statusWithAgo)).toISOString(); } catch (error) { - console.debug('Unable to get container, probably container is gone due to a short TTL', error); StartedAt = ''; telemetryOptions = { error: error }; } - } else { - StartedAt = ''; + + // update the StartedAt value + container.StartedAt = StartedAt; } + }); + } + + let pods: LibpodPodInfo[] = []; + if (provider.libpodApi) { + pods = await provider.libpodApi.listPods(); + } + return Promise.all( + containers.map(async container => { // do we have a matching pod for this container ? let pod; const matchingPod = pods.find(pod => @@ -347,7 +408,8 @@ export class ContainerProviderRegistry { engineName: provider.name, engineId: provider.id, engineType: provider.connection.type, - StartedAt, + StartedAt: container.StartedAt || '', + Status: container.Status, }; return containerInfo; }), diff --git a/packages/renderer/src/lib/container/ContainerDetails.spec.ts b/packages/renderer/src/lib/container/ContainerDetails.spec.ts index 795a707a3f882..5ff0f056b764c 100644 --- a/packages/renderer/src/lib/container/ContainerDetails.spec.ts +++ b/packages/renderer/src/lib/container/ContainerDetails.spec.ts @@ -45,9 +45,6 @@ const myContainer: ContainerInfo = { Created: 0, Ports: undefined, State: undefined, - HostConfig: undefined, - NetworkSettings: undefined, - Mounts: undefined, }; const deleteContainerMock = vi.fn(); diff --git a/types/additional.d.ts b/types/additional.d.ts index ddff6e6087898..79fc5ead090b7 100644 --- a/types/additional.d.ts +++ b/types/additional.d.ts @@ -1,3 +1,4 @@ declare module 'win-ca/api'; declare module 'zip-local'; declare module 'micromark-extension-directive'; +declare module 'date.js'; diff --git a/yarn.lock b/yarn.lock index 17bb8ad6dd517..aa3df987c4c63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5904,6 +5904,13 @@ data-urls@^4.0.0: whatwg-mimetype "^3.0.0" whatwg-url "^12.0.0" +date.js@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/date.js/-/date.js-0.3.3.tgz#ef1e92332f507a638795dbb985e951882e50bbda" + integrity sha512-HgigOS3h3k6HnW011nAb43c5xx5rBXk8P2v/WIT9Zv4koIaVXiH2BURguI78VVp+5Qc076T7OR378JViCnZtBw== + dependencies: + debug "~3.1.0" + debug@2.6.9, debug@^2.2.0, debug@^2.6.0, debug@^2.6.8: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" @@ -5925,6 +5932,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + decamelize-keys@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8"