mirror of
https://github.com/rancher-sandbox/rancher-desktop.git
synced 2021-10-13 00:04:06 +03:00
Merge pull request #709 from rancher-sandbox/697-migrate-from-kim-to-nerdctl
697 migrate from kim to nerdctl
This commit is contained in:
12
README.md
12
README.md
@@ -9,12 +9,12 @@ Rancher Desktop provides the following features in the form of a desktop applica
|
||||
|
||||
- The version of Kubernetes you choose
|
||||
- Ability to test upgrading Kubernetes to a new version and see how your workloads respond
|
||||
- Build, push, and pull images (powered by [KIM])
|
||||
- Run containers, and build, push, and pull images (powered by [nerdctl])
|
||||
- Expose an application in Kubernetes for local access
|
||||
|
||||
All of this is wrapped in an open-source application.
|
||||
|
||||
[KIM]: https://github.com/rancher/kim
|
||||
[nerdctl]: https://github.com/containerd/nerdctl
|
||||
|
||||
## Get The App
|
||||
|
||||
@@ -38,7 +38,7 @@ https://github.com/rancher-sandbox/rancher-desktop/actions/workflows/package.yam
|
||||
|
||||
Rancher Desktop is an Electron application with the primary business logic
|
||||
written in TypeScript and JavaScript. It leverages several other pieces of
|
||||
technology to provide the platform elements which include k3s, kim, kubectl,
|
||||
technology to provide the platform elements which include k3s, kubectl, nerdctl
|
||||
WSL, qemu, and more. The application wraps numerous pieces of technology to
|
||||
provide one cohesive application.
|
||||
|
||||
@@ -58,9 +58,9 @@ be installed to build the source. On Windows, [Go] is also required.
|
||||
|
||||
#### Windows
|
||||
|
||||
There are two options for building from source on Windows: with a
|
||||
[Development VM Setup](#development-vm-setup) or
|
||||
[Manual Development Environment Setup](#manual-development-environment-setup)
|
||||
There are two options for building from source on Windows: with a
|
||||
[Development VM Setup](#development-vm-setup) or
|
||||
[Manual Development Environment Setup](#manual-development-environment-setup)
|
||||
with an existing Windows installation.
|
||||
##### Development VM Setup
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import Electron from 'electron';
|
||||
import _ from 'lodash';
|
||||
|
||||
import mainEvents from '@/main/mainEvents';
|
||||
import { setupKim } from '@/main/kim';
|
||||
import { ImageProcessor } from '@/k8s-engine/images/imageProcessor';
|
||||
import { ImageProcessorName } from '@/k8s-engine/images/imageFactory';
|
||||
import { setupImageProcessor } from '@/main/imageEvents';
|
||||
import * as settings from '@/config/settings';
|
||||
import * as window from '@/window';
|
||||
import * as K8s from '@/k8s-engine/k8s';
|
||||
@@ -25,8 +27,12 @@ import buildApplicationMenu from '@/main/mainmenu';
|
||||
Electron.app.setName('Rancher Desktop');
|
||||
|
||||
const console = Logging.background;
|
||||
// If/when we support more than one image processor this will be a pref with a watcher
|
||||
// for changes, but it's fine as a constant now.
|
||||
const ImageProviderName: ImageProcessorName = 'nerdctl';
|
||||
|
||||
const k8smanager = newK8sManager();
|
||||
let imageProcessor: ImageProcessor;
|
||||
|
||||
setupPaths();
|
||||
|
||||
@@ -121,7 +127,7 @@ async function doFirstRun() {
|
||||
if (os.platform() === 'darwin') {
|
||||
await Promise.all([
|
||||
linkResource('helm', true),
|
||||
linkResource('kim', true),
|
||||
linkResource('kim', true), // TODO: Remove when we stop shipping kim
|
||||
linkResource('kubectl', true),
|
||||
linkResource('nerdctl', true),
|
||||
]);
|
||||
@@ -181,7 +187,7 @@ async function startBackend(cfg: settings.Settings) {
|
||||
await checkBackendValid();
|
||||
|
||||
k8smanager.start(cfg.kubernetes).catch(handleFailure);
|
||||
setupKim(k8smanager);
|
||||
imageProcessor = setupImageProcessor(ImageProviderName, k8smanager);
|
||||
}
|
||||
|
||||
Electron.app.on('second-instance', async() => {
|
||||
@@ -237,6 +243,26 @@ Electron.ipcMain.on('settings-read', (event) => {
|
||||
event.returnValue = cfg;
|
||||
});
|
||||
|
||||
async function relayImageProcessorNamespaces() {
|
||||
try {
|
||||
const namespaces = await imageProcessor.getNamespaces();
|
||||
const comparator = Intl.Collator(undefined, { sensitivity: 'base' }).compare;
|
||||
|
||||
if (!namespaces.includes('default')) {
|
||||
namespaces.push('default');
|
||||
}
|
||||
window.send('images-namespaces', namespaces.sort(comparator));
|
||||
} catch (err) {
|
||||
console.log('Error getting image namespaces:', err);
|
||||
}
|
||||
}
|
||||
|
||||
Electron.ipcMain.on('images-namespaces-read', (event) => {
|
||||
if ([K8s.State.VM_STARTED, K8s.State.STARTED].includes(k8smanager.state)) {
|
||||
relayImageProcessorNamespaces().catch();
|
||||
}
|
||||
});
|
||||
|
||||
// Partial<T> (https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype)
|
||||
// only allows missing properties on the top level; if anything is given, then all
|
||||
// properties of that top-level property must exist. RecursivePartial<T> instead
|
||||
@@ -255,6 +281,12 @@ function writeSettings(arg: RecursivePartial<settings.Settings>) {
|
||||
mainEvents.emit('settings-update', cfg);
|
||||
|
||||
Electron.ipcMain.emit('k8s-restart-required');
|
||||
if (imageProcessor && imageProcessor.namespace !== cfg.images.namespace) {
|
||||
imageProcessor.namespace = cfg.images.namespace;
|
||||
imageProcessor.refreshImages().catch((err: Error) => {
|
||||
console.log(`Error refreshing images:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Electron.ipcMain.handle('settings-write', (event, arg) => {
|
||||
@@ -319,6 +351,7 @@ Electron.ipcMain.on('k8s-restart', async() => {
|
||||
try {
|
||||
switch (k8smanager.state) {
|
||||
case K8s.State.STOPPED:
|
||||
case K8s.State.VM_STARTED:
|
||||
case K8s.State.STARTED:
|
||||
// Calling start() will restart the backend, possible switching versions
|
||||
// as a side-effect.
|
||||
@@ -404,7 +437,7 @@ Electron.ipcMain.on('factory-reset', async() => {
|
||||
await k8smanager.factoryReset();
|
||||
if (os.platform() === 'darwin') {
|
||||
// Unlink binaries
|
||||
for (const name of ['helm', 'kim', 'kubectl']) {
|
||||
for (const name of ['helm', 'kim', 'kubectl', 'nerdctl']) {
|
||||
Electron.ipcMain.emit('install-set', { reply: () => { } }, name, false);
|
||||
}
|
||||
}
|
||||
@@ -429,9 +462,9 @@ Electron.ipcMain.on('troubleshooting/show-logs', async(event) => {
|
||||
|
||||
console.error(`Failed to open logs: ${ error }`);
|
||||
if (browserWindow) {
|
||||
Electron.dialog.showMessageBox(browserWindow, options);
|
||||
await Electron.dialog.showMessageBox(browserWindow, options);
|
||||
} else {
|
||||
Electron.dialog.showMessageBox(options);
|
||||
await Electron.dialog.showMessageBox(options);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -531,6 +564,7 @@ function newK8sManager() {
|
||||
if (!cfg.kubernetes.version) {
|
||||
writeSettings({ kubernetes: { version: mgr.version } });
|
||||
}
|
||||
relayImageProcessorNamespaces().catch();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
# Images
|
||||
|
||||
Rancher Desktop provides the ability to build, push, and pull images via the
|
||||
[KIM](https://github.com/rancher/kim) project.
|
||||
[NERDCTL](https://github.com/containerd/nerdctl) project.
|
||||
|
||||
Note, `kim` is put into the path automatically. This occurs during the
|
||||
Note, `nerdctl` is put into the path automatically. This occurs during the
|
||||
installer on Windows, and upon first run on macOS.
|
||||
|
||||
## Using KIM
|
||||
## Using NERDCTL
|
||||
|
||||
You can learn about all of the command options by running `kim -h`. This will
|
||||
display the help documentation.
|
||||
You can learn about all of the command options by running `nerdctl -h`. This will
|
||||
display the help documentation. The command requires Rancher Desktop to be running
|
||||
for it to work.
|
||||
|
||||
KIM has a client side and server side component. The server side part is a
|
||||
container running in Kubernetes while the client side application runs on
|
||||
Mac or Windows. Images are stored in the same containerd that Kubernetes uses.
|
||||
The initial set of images are stored in the same containerd that Kubernetes uses,
|
||||
and are part of the `k8s.io` namespace. You can also switch to a namespace called
|
||||
`default` if you wish to build or pull images into a different namespace. Currently
|
||||
the only way to create other namespaces is to build or pull an image with the
|
||||
`nerdctl` CLI, using the `--namespace <NAMESPACE_NAME>` option.
|
||||
|
||||
## Building Images
|
||||
|
||||
@@ -22,7 +25,7 @@ running `kim` from a directory with a `Dockerfile` where the `Dockerfile` is
|
||||
using a scratch image.
|
||||
|
||||
```console
|
||||
❯ kim build .
|
||||
❯ nerdctl build .
|
||||
[+] Building 0.1s (4/4) FINISHED
|
||||
=> [internal] load build definition from Dockerfile
|
||||
=> => transferring dockerfile: 31B
|
||||
@@ -33,8 +36,8 @@ using a scratch image.
|
||||
=> CACHED [1/1] ADD anvil-app /
|
||||
```
|
||||
|
||||
`kim` has tags for tagging at the same time as building and other options you've
|
||||
`nerdctl` has tags for tagging at the same time as building and other options you've
|
||||
come to expect.
|
||||
|
||||
If you want to tag an existing image you've built you can use the `kim tag`
|
||||
If you want to tag an existing image you've built you can use the `nerdctl tag`
|
||||
command.
|
||||
|
||||
@@ -164,10 +164,10 @@ suffix:
|
||||
# Components & Pages
|
||||
##############################
|
||||
images:
|
||||
title: Images
|
||||
state:
|
||||
title: Images
|
||||
state:
|
||||
k8sUnready: Waiting for Kubernetes to be ready
|
||||
kimUnready: Waiting for image manager to be ready
|
||||
imagesUnready: Waiting for image manager to be ready
|
||||
unknown: 'Error: Unknown state; please reload.'
|
||||
close: Close Output to Continue
|
||||
manager:
|
||||
|
||||
@@ -14,13 +14,26 @@
|
||||
:table-actions="false"
|
||||
:paging="true"
|
||||
>
|
||||
<template #header-left>
|
||||
<label>Image Namespace:</label>
|
||||
<select class="select-namespace" :value="selectedNamespace" @change="handleChangeNamespace($event)">
|
||||
<option v-for="item in imageNamespaces" :key="item" :value="item" :selected="item === selectedNamespace">
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template #header-middle>
|
||||
<Checkbox
|
||||
:value="showAll"
|
||||
:label="t('images.manager.table.label')"
|
||||
:disabled="!supportsShowAll"
|
||||
@input="handleShowAllCheckbox"
|
||||
/>
|
||||
</template>
|
||||
<!-- The SortableTable component puts the Filter box goes in the #header-right slot
|
||||
Too bad, because it means we can't use a css grid to manage the relative
|
||||
positions of these three widgets
|
||||
-->
|
||||
</SortableTable>
|
||||
|
||||
<Card :show-highlight-border="false" :show-actions="false">
|
||||
@@ -88,11 +101,8 @@
|
||||
</Card>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h3 v-if="state === 'K8S_UNREADY'">
|
||||
{{ t('images.state.k8sUnready') }}
|
||||
</h3>
|
||||
<h3 v-else-if="state === 'KIM_UNREADY'">
|
||||
{{ t('images.state.kimUnready') }}
|
||||
<h3 v-if="state === 'IMAGE_MANAGER_UNREADY'">
|
||||
{{ t('images.state.imagesUnready') }}
|
||||
</h3>
|
||||
<h3 v-else>
|
||||
{{ t('images.state.unknown') }}
|
||||
@@ -118,10 +128,18 @@ export default {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
imageNamespaces: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectedNamespace: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
state: {
|
||||
type: String,
|
||||
default: 'K8S_UNREADY',
|
||||
validator: value => ['K8S_UNREADY', 'KIM_UNREADY', 'READY'].includes(value),
|
||||
default: 'IMAGE_MANAGER_UNREADY',
|
||||
validator: value => ['IMAGE_MANAGER_UNREADY', 'READY'].includes(value),
|
||||
},
|
||||
showAll: {
|
||||
type: Boolean,
|
||||
@@ -168,7 +186,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
filteredImages() {
|
||||
if (this.showAll) {
|
||||
if (!this.supportsShowAll || this.showAll) {
|
||||
return this.images;
|
||||
}
|
||||
|
||||
@@ -241,17 +259,20 @@ export default {
|
||||
imageManagerProcessFinishedWithFailure() {
|
||||
return this.imageManagerProcessIsFinished && !this.completionStatus;
|
||||
},
|
||||
supportsShowAll() {
|
||||
return this.selectedNamespace === 'k8s.io';
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.main = document.getElementsByTagName('main')[0];
|
||||
ipcRenderer.on('kim-process-cancelled', (event) => {
|
||||
ipcRenderer.on('images-process-cancelled', (event) => {
|
||||
this.handleProcessCancelled();
|
||||
});
|
||||
ipcRenderer.on('kim-process-ended', (event, status) => {
|
||||
ipcRenderer.on('images-process-ended', (event, status) => {
|
||||
this.handleProcessEnd(status);
|
||||
});
|
||||
ipcRenderer.on('kim-process-output', (event, data, isStderr) => {
|
||||
ipcRenderer.on('images-process-output', (event, data, isStderr) => {
|
||||
this.appendImageManagerOutput(data, isStderr);
|
||||
});
|
||||
},
|
||||
@@ -477,6 +498,9 @@ export default {
|
||||
handleShowAllCheckbox(value) {
|
||||
this.$emit('toggledShowAll', value);
|
||||
},
|
||||
handleChangeNamespace(event) {
|
||||
this.$emit('switchNamespace', event.target.value);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -512,4 +536,8 @@ export default {
|
||||
.imagesTable::v-deep tr.highlightFade {
|
||||
animation: highlightFade 1s;
|
||||
}
|
||||
|
||||
.imagesTable::v-deep div.search {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,10 @@ const defaultSettings = {
|
||||
port: 6443,
|
||||
},
|
||||
portForwarding: { includeKubernetesServices: false },
|
||||
images: { showAll: true },
|
||||
images: {
|
||||
showAll: true,
|
||||
namespace: 'k8s.io',
|
||||
},
|
||||
telemetry: true,
|
||||
/** Whether we should check for updates and apply them. */
|
||||
updater: true,
|
||||
|
||||
26
src/k8s-engine/images/imageFactory.ts
Normal file
26
src/k8s-engine/images/imageFactory.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ImageProcessor } from '@/k8s-engine/images/imageProcessor';
|
||||
import NerdctlImageProcessor from '@/k8s-engine/images/nerdctlImageProcessor';
|
||||
import * as K8s from '@/k8s-engine/k8s';
|
||||
|
||||
/**
|
||||
* An or-barred enum of valid string values for the names of supported image processors
|
||||
*/
|
||||
export type ImageProcessorName = 'nerdctl'; // | 'kim' has been dropped
|
||||
|
||||
/**
|
||||
* Currently there's only one image processor.
|
||||
* But at one point, when we transitioned from kim to nerdctl, there were two.
|
||||
* And there might be new ones in the future, so the only changes are adding the new
|
||||
* module and three lines to this file (one for the import, two for the switch stmt).
|
||||
* @param processorName
|
||||
* @param k8sManager
|
||||
*/
|
||||
|
||||
export function createImageProcessor(processorName: ImageProcessorName, k8sManager: K8s.KubernetesBackend): ImageProcessor {
|
||||
switch (processorName) {
|
||||
case 'nerdctl':
|
||||
return new NerdctlImageProcessor(k8sManager);
|
||||
default:
|
||||
throw new Error(`No image processor called ${ processorName }`);
|
||||
}
|
||||
}
|
||||
286
src/k8s-engine/images/imageProcessor.ts
Normal file
286
src/k8s-engine/images/imageProcessor.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import { Console } from 'console';
|
||||
import { EventEmitter } from 'events';
|
||||
import os from 'os';
|
||||
import timers from 'timers';
|
||||
|
||||
import * as K8s from '@/k8s-engine/k8s';
|
||||
import mainEvents from '@/main/mainEvents';
|
||||
import Logging from '@/utils/logging';
|
||||
import LimaBackend from '@/k8s-engine/lima';
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 1000;
|
||||
const APP_NAME = 'rancher-desktop';
|
||||
const console = new Console(Logging.images.stream);
|
||||
|
||||
/**
|
||||
* The fields that cover the results of a finished process.
|
||||
* Not all fields are set for every process.
|
||||
*/
|
||||
export interface childResultType {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields for display in the images table
|
||||
*/
|
||||
export interface imageType {
|
||||
imageName: string,
|
||||
tag: string,
|
||||
imageID: string,
|
||||
size: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Define all methods common to all ImageProcessor subclasses here.
|
||||
* Abstract methods need to be implemented in concrete subclasses.
|
||||
*/
|
||||
export abstract class ImageProcessor extends EventEmitter {
|
||||
protected k8sManager: K8s.KubernetesBackend|null;
|
||||
// Sometimes the `images` subcommand repeatedly fires the same error message.
|
||||
// Instead of logging it every time, keep track of the current error and give a count instead.
|
||||
private lastErrorMessage = '';
|
||||
private sameErrorMessageCount = 0;
|
||||
protected showedStderr = false;
|
||||
private refreshInterval: ReturnType<typeof timers.setInterval> | null = null;
|
||||
protected images:imageType[] = [];
|
||||
protected _isReady = false;
|
||||
private isK8sReady = false;
|
||||
private hasImageListeners = false;
|
||||
private isWatching = false;
|
||||
_refreshImages: () => Promise<void>;
|
||||
protected currentNamespace = 'default';
|
||||
|
||||
constructor(k8sManager: K8s.KubernetesBackend) {
|
||||
super();
|
||||
this.k8sManager = k8sManager;
|
||||
this._refreshImages = this.refreshImages.bind(this);
|
||||
this.on('newListener', (event: string | symbol) => {
|
||||
if (event === 'images-changed' && !this.hasImageListeners) {
|
||||
this.hasImageListeners = true;
|
||||
this.updateWatchStatus();
|
||||
}
|
||||
});
|
||||
this.on('removeListener', (event: string | symbol) => {
|
||||
if (event === 'images-changed' && this.hasImageListeners) {
|
||||
this.hasImageListeners = this.listeners('images-changed').length > 0;
|
||||
this.updateWatchStatus();
|
||||
}
|
||||
});
|
||||
mainEvents.on('k8s-check-state', (mgr: K8s.KubernetesBackend) => {
|
||||
this.isK8sReady = [K8s.State.VM_STARTED, K8s.State.STARTED].includes(mgr.state);
|
||||
this.updateWatchStatus();
|
||||
});
|
||||
}
|
||||
|
||||
protected updateWatchStatus() {
|
||||
const shouldWatch = this.isK8sReady && this.hasImageListeners;
|
||||
|
||||
if (this.isWatching === shouldWatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.refreshInterval) {
|
||||
timers.clearInterval(this.refreshInterval);
|
||||
}
|
||||
if (shouldWatch) {
|
||||
this.refreshInterval = timers.setInterval(this._refreshImages, REFRESH_INTERVAL);
|
||||
timers.setImmediate(this._refreshImages);
|
||||
}
|
||||
this.isWatching = shouldWatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Are images ready for display in the UI?
|
||||
*/
|
||||
get isReady() {
|
||||
return this._isReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around the trivy command to scan the specified image.
|
||||
* @param taggedImageName
|
||||
*/
|
||||
async scanImage(taggedImageName: string): Promise<childResultType> {
|
||||
return await this.runTrivyCommand(['image', '--no-progress', '--format', 'template',
|
||||
'--template', '@/var/lib/trivy.tpl', taggedImageName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method figures out which command to run for scanning, based on the platform
|
||||
* and provided args.
|
||||
* @param args
|
||||
* @param sendNotifications
|
||||
*/
|
||||
async runTrivyCommand(args: string[], sendNotifications = true): Promise<childResultType> {
|
||||
let child: ChildProcess;
|
||||
const subcommandName = args[0];
|
||||
|
||||
if (os.platform().startsWith('win')) {
|
||||
args = ['-d', APP_NAME, 'trivy'].concat(args);
|
||||
child = spawn('wsl', args);
|
||||
} else if (os.platform().startsWith('darwin') || os.platform().startsWith('linux')) {
|
||||
const limaBackend = this.k8sManager as LimaBackend;
|
||||
|
||||
args = ['trivy'].concat(args);
|
||||
child = limaBackend.limaSpawn(args);
|
||||
} else {
|
||||
throw new Error(`Don't know how to run trivy on platform ${ os.platform() }`);
|
||||
}
|
||||
|
||||
return await this.processChildOutput(child, subcommandName, sendNotifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current list of cached images.
|
||||
*/
|
||||
listImages(): imageType[] {
|
||||
return this.images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the current cache of processed iamges.
|
||||
*/
|
||||
async refreshImages() {
|
||||
try {
|
||||
const result:childResultType = await this.getImages();
|
||||
|
||||
if (result.stderr) {
|
||||
if (!this.showedStderr) {
|
||||
console.log(`${ this.processorName } images: ${ result.stderr } `);
|
||||
this.showedStderr = true;
|
||||
}
|
||||
} else {
|
||||
this.showedStderr = false;
|
||||
}
|
||||
this.images = this.parse(result.stdout);
|
||||
if (!this._isReady) {
|
||||
this._isReady = true;
|
||||
this.emit('readiness-changed', true);
|
||||
}
|
||||
this.emit('images-changed', this.images);
|
||||
} catch (err) {
|
||||
if (!this.showedStderr) {
|
||||
if (err.stderr && !err.stdout && !err.signal) {
|
||||
console.log(err.stderr);
|
||||
} else {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
this.showedStderr = true;
|
||||
if ('code' in err && this._isReady) {
|
||||
this._isReady = false;
|
||||
this.emit('readiness-changed', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected parse(data: string): imageType[] {
|
||||
const results = data.trimEnd().split(/\r?\n/).slice(1).map((line) => {
|
||||
const [imageName, tag, imageID, size] = line.split(/\s+/);
|
||||
|
||||
return {
|
||||
imageName, tag, imageID, size
|
||||
};
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the `childProcess` returned by a command like `child_process.spawn` and processes the
|
||||
* output streams and exit code and signal.
|
||||
*
|
||||
* @param child
|
||||
* @param subcommandName - used for error messages only
|
||||
* @param sendNotifications
|
||||
*/
|
||||
async processChildOutput(child: ChildProcess, subcommandName: string, sendNotifications: boolean): Promise<childResultType> {
|
||||
const result = { stdout: '', stderr: '' };
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
const dataString = data.toString();
|
||||
|
||||
if (sendNotifications) {
|
||||
this.emit('images-process-output', dataString, false);
|
||||
}
|
||||
result.stdout += dataString;
|
||||
});
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
let dataString = data.toString();
|
||||
|
||||
if (this.processorName === 'nerdctl' && subcommandName === 'images') {
|
||||
/**
|
||||
* `nerdctl images` issues some dubious error messages
|
||||
* (see https://github.com/containerd/nerdctl/issues/353 , logged 2021-09-10)
|
||||
* Pull them out for now
|
||||
*/
|
||||
dataString = dataString
|
||||
.replace(/time=".+?"\s+level=.+?\s+msg="failed to compute image\(s\) size"\s*/g, '')
|
||||
.replace(/time=".+?"\s+level=.+?\s+msg="unparsable image name.*?sha256:[0-9a-fA-F]{64}.*?\\""\s*/g, '');
|
||||
if (!dataString) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
result.stderr += dataString;
|
||||
if (sendNotifications) {
|
||||
this.emit('images-process-output', dataString, true);
|
||||
}
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (result.stderr) {
|
||||
const timeLessMessage = result.stderr.replace(/\btime=".*?"/g, '');
|
||||
|
||||
if (this.lastErrorMessage !== timeLessMessage) {
|
||||
this.lastErrorMessage = timeLessMessage;
|
||||
this.sameErrorMessageCount = 1;
|
||||
console.log(result.stderr.replace(/(?!<\r)\n/g, '\r\n'));
|
||||
} else {
|
||||
const m = /(Error: .*)/.exec(this.lastErrorMessage);
|
||||
|
||||
this.sameErrorMessageCount += 1;
|
||||
console.log(`${ this.processorName } ${ subcommandName }: ${ m ? m[1] : 'same error message' } #${ this.sameErrorMessageCount }\r`);
|
||||
}
|
||||
}
|
||||
if (code === 0) {
|
||||
resolve({ ...result, code });
|
||||
} else if (signal) {
|
||||
reject({
|
||||
...result, code: -1, signal
|
||||
});
|
||||
} else {
|
||||
reject({ ...result, code });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get namespace() {
|
||||
return this.currentNamespace;
|
||||
}
|
||||
|
||||
set namespace(value: string) {
|
||||
this.currentNamespace = value;
|
||||
}
|
||||
|
||||
/* Subclass-specific method definitions here: */
|
||||
|
||||
protected abstract get processorName(): string;
|
||||
|
||||
abstract getNamespaces(): Promise<Array<string>>;
|
||||
|
||||
abstract buildImage(dirPart: string, filePart: string, taggedImageName: string): Promise<childResultType>;
|
||||
|
||||
abstract deleteImage(imageID: string): Promise<childResultType>;
|
||||
|
||||
abstract pullImage(taggedImageName: string): Promise<childResultType>;
|
||||
|
||||
abstract pushImage(taggedImageName: string): Promise<childResultType>;
|
||||
|
||||
abstract getImages(): Promise<childResultType>;
|
||||
}
|
||||
111
src/k8s-engine/images/nerdctlImageProcessor.ts
Normal file
111
src/k8s-engine/images/nerdctlImageProcessor.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { Console } from 'console';
|
||||
import path from 'path';
|
||||
|
||||
import Logging from '@/utils/logging';
|
||||
import resources from '@/resources';
|
||||
import * as imageProcessor from '@/k8s-engine/images/imageProcessor';
|
||||
import * as childProcess from '@/utils/childProcess';
|
||||
|
||||
const console = new Console(Logging.images.stream);
|
||||
|
||||
export default class NerdctlImageProcessor extends imageProcessor.ImageProcessor {
|
||||
protected get processorName() {
|
||||
return 'nerdctl';
|
||||
}
|
||||
|
||||
protected async runImagesCommand(args: string[], sendNotifications = true): Promise<imageProcessor.childResultType> {
|
||||
const subcommandName = args[0];
|
||||
const namespacedArgs = ['--namespace', this.currentNamespace].concat(args);
|
||||
|
||||
return await this.processChildOutput(spawn(resources.executable('nerdctl'), namespacedArgs), subcommandName, sendNotifications);
|
||||
}
|
||||
|
||||
async buildImage(dirPart: string, filePart: string, taggedImageName: string): Promise<imageProcessor.childResultType> {
|
||||
const args = ['build',
|
||||
'--file', path.join(dirPart, filePart),
|
||||
'--tag', taggedImageName,
|
||||
dirPart];
|
||||
|
||||
return await this.runImagesCommand(args);
|
||||
}
|
||||
|
||||
async deleteImage(imageID: string): Promise<imageProcessor.childResultType> {
|
||||
return await this.runImagesCommand(['rmi', imageID]);
|
||||
}
|
||||
|
||||
async pullImage(taggedImageName: string): Promise<imageProcessor.childResultType> {
|
||||
return await this.runImagesCommand(['pull', taggedImageName, '--debug']);
|
||||
}
|
||||
|
||||
async pushImage(taggedImageName: string): Promise<imageProcessor.childResultType> {
|
||||
return await this.runImagesCommand(['push', taggedImageName]);
|
||||
}
|
||||
|
||||
async getImages(): Promise<imageProcessor.childResultType> {
|
||||
return await this.runImagesCommand(
|
||||
['images', '--format', '{{json .}}'],
|
||||
false);
|
||||
}
|
||||
|
||||
async scanImage(taggedImageName: string): Promise<imageProcessor.childResultType> {
|
||||
return await this.runTrivyCommand(['image', '--no-progress', '--format', 'template',
|
||||
'--template', '@/var/lib/trivy.tpl', taggedImageName]);
|
||||
}
|
||||
|
||||
async getNamespaces(): Promise<Array<string>> {
|
||||
const { stdout, stderr } = await childProcess.spawnFile(resources.executable('nerdctl'),
|
||||
['namespace', 'list', '--quiet'],
|
||||
{ stdio: ['inherit', 'pipe', 'pipe'] });
|
||||
|
||||
if (stderr) {
|
||||
console.log(`Error getting namespaces: ${ stderr }`, stderr);
|
||||
}
|
||||
|
||||
return stdout.trim().split(/\r?\n/).map(line => line.trim()).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample output (line-oriented JSON output, as opposed to one JSON document):
|
||||
*
|
||||
* {"CreatedAt":"2021-10-05 22:04:12 +0000 UTC","CreatedSince":"20 hours ago","ID":"171689e43026","Repository":"","Tag":"","Size":"119.2 MiB"}
|
||||
* {"CreatedAt":"2021-10-05 22:04:20 +0000 UTC","CreatedSince":"20 hours ago","ID":"55fe4b211a51","Repository":"rancher/kim","Tag":"v0.1.0-beta.6","Size":"46.2 MiB"}
|
||||
* ...
|
||||
*/
|
||||
|
||||
parse(data: string): imageProcessor.imageType[] {
|
||||
const images: Array<imageProcessor.imageType> = [];
|
||||
const records = data.split(/\r?\n/)
|
||||
.filter(line => line.trim().length > 0)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (err) {
|
||||
console.log(`Error json-parsing line [${ line }]:`, err);
|
||||
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(record => record);
|
||||
|
||||
for (const record of records) {
|
||||
if (['', 'sha256'].includes(record.Repository)) {
|
||||
continue;
|
||||
}
|
||||
images.push({
|
||||
imageName: record.Repository,
|
||||
tag: record.Tag,
|
||||
imageID: record.ID,
|
||||
size: record.Size
|
||||
});
|
||||
}
|
||||
|
||||
return images.sort(imageComparator);
|
||||
}
|
||||
}
|
||||
|
||||
function imageComparator(a: imageProcessor.imageType, b: imageProcessor.imageType): number {
|
||||
return a.imageName.localeCompare(b.imageName) ||
|
||||
a.tag.localeCompare(b.tag) ||
|
||||
a.imageID.localeCompare(b.imageID);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export { KubeClient as Client, ServiceEntry } from './client';
|
||||
export enum State {
|
||||
STOPPED = 0, // The engine is not running.
|
||||
STARTING, // The engine is attempting to start.
|
||||
VM_STARTED, // The VM is up, but k8s hasn't started yet
|
||||
STARTED, // The engine is started; the dashboard is not yet ready.
|
||||
STOPPING, // The engine is attempting to stop.
|
||||
ERROR, // There is an error and we cannot recover automatically.
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import timers from 'timers';
|
||||
import tls from 'tls';
|
||||
import util from 'util';
|
||||
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
|
||||
import * as childProcess from '@/utils/childProcess';
|
||||
import * as K8s from '@/k8s-engine/k8s';
|
||||
import mainEvents from '@/main/mainEvents';
|
||||
import Logging from '@/utils/logging';
|
||||
import resources from '@/resources';
|
||||
import LimaBackend from '@/k8s-engine/lima';
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 1000;
|
||||
const APP_NAME = 'rancher-desktop';
|
||||
const KUBE_CONTEXT = 'rancher-desktop';
|
||||
|
||||
const console = Logging.kim;
|
||||
|
||||
function defined<T>(input: T | undefined | null): input is T {
|
||||
return typeof input !== 'undefined' && input !== null;
|
||||
}
|
||||
|
||||
interface childResultType {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
interface imageType {
|
||||
imageName: string,
|
||||
tag: string,
|
||||
imageID: string,
|
||||
size: string,
|
||||
}
|
||||
|
||||
interface Kim extends EventEmitter {
|
||||
/**
|
||||
* Emitted when the images are different. Note that we will only refresh the
|
||||
* image list when listeners are registered for this event.
|
||||
*/
|
||||
on(event: 'images-changed', listener: (images: imageType[]) => void): this;
|
||||
|
||||
/**
|
||||
* Emitted when command output is received.
|
||||
*/
|
||||
on(event: 'kim-process-output', listener: (data: string, isStderr: boolean) => void): this;
|
||||
|
||||
/**
|
||||
* Emitted when the Kim backend readiness has changed.
|
||||
*/
|
||||
on(event: 'readiness-changed', listener: (isReady: boolean) => void): this;
|
||||
|
||||
// Inherited, for internal handling.
|
||||
on(event: 'newListener', listener: (eventName: string | symbol, listener: (...args: any[]) => void) => void): this;
|
||||
on(event: 'removeListener', listener: (eventName: string | symbol, listener: (...args: any[]) => void) => void): this;
|
||||
}
|
||||
|
||||
class Kim extends EventEmitter {
|
||||
private showedStderr = false;
|
||||
private refreshInterval: ReturnType<typeof timers.setInterval> | null = null;
|
||||
// During startup `kim images` repeatedly fires the same error message. Instead,
|
||||
// keep track of the current error and give a count instead.
|
||||
private lastErrorMessage = '';
|
||||
private sameErrorMessageCount = 0;
|
||||
private images: imageType[] = [];
|
||||
private _isReady = false;
|
||||
private isK8sReady = false;
|
||||
private hasImageListeners = false;
|
||||
private isWatching = false;
|
||||
private k8sManager: K8s.KubernetesBackend|null;
|
||||
|
||||
constructor(k8sManager: K8s.KubernetesBackend) {
|
||||
super();
|
||||
this.k8sManager = k8sManager;
|
||||
this._refreshImages = this.refreshImages.bind(this);
|
||||
this.on('newListener', (event: string | symbol) => {
|
||||
if (event === 'images-changed' && !this.hasImageListeners) {
|
||||
this.hasImageListeners = true;
|
||||
this.updateWatchStatus();
|
||||
}
|
||||
});
|
||||
this.on('removeListener', (event: string | symbol) => {
|
||||
if (event === 'images-changed' && this.hasImageListeners) {
|
||||
this.hasImageListeners = this.listeners('images-changed').length > 0;
|
||||
this.updateWatchStatus();
|
||||
}
|
||||
});
|
||||
mainEvents.on('k8s-check-state', async(mgr: K8s.KubernetesBackend) => {
|
||||
this.isK8sReady = mgr.state === K8s.State.STARTED;
|
||||
this.updateWatchStatus();
|
||||
if (this.isK8sReady) {
|
||||
let endpoint: string | undefined;
|
||||
|
||||
// XXX temporary hack: use a fixed address for kim endpoint
|
||||
if (mgr.backend === 'lima') {
|
||||
endpoint = '127.0.0.1';
|
||||
}
|
||||
|
||||
const needsForce = !(await this.isInstallValid(mgr, endpoint));
|
||||
|
||||
this.install(mgr, needsForce, endpoint);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected updateWatchStatus() {
|
||||
const shouldWatch = this.isK8sReady && this.hasImageListeners;
|
||||
|
||||
if (this.isWatching === shouldWatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.refreshInterval) {
|
||||
timers.clearInterval(this.refreshInterval);
|
||||
}
|
||||
if (shouldWatch) {
|
||||
this.refreshInterval = timers.setInterval(this._refreshImages, REFRESH_INTERVAL);
|
||||
timers.setImmediate(this._refreshImages);
|
||||
}
|
||||
this.isWatching = shouldWatch;
|
||||
}
|
||||
|
||||
get isReady() {
|
||||
return this._isReady;
|
||||
}
|
||||
|
||||
protected async runKimCommand(args: string[], sendNotifications = true): Promise<childResultType> {
|
||||
// Insert options needed for all calls to kim.
|
||||
const finalArgs = ['--context', KUBE_CONTEXT].concat(args);
|
||||
|
||||
return await this.processChildOutput(spawn(resources.executable('kim'), finalArgs), args[0], sendNotifications);
|
||||
}
|
||||
|
||||
async runTrivyCommand(args: string[], sendNotifications = true): Promise<childResultType> {
|
||||
let child: ChildProcess;
|
||||
const subcommandName = args[0];
|
||||
|
||||
if (os.platform().startsWith('win')) {
|
||||
args = ['-d', APP_NAME, 'trivy'].concat(args);
|
||||
child = spawn('wsl', args);
|
||||
} else if (os.platform().startsWith('darwin') || os.platform().startsWith('linux')) {
|
||||
const limaBackend = this.k8sManager as LimaBackend;
|
||||
|
||||
args = ['trivy'].concat(args);
|
||||
child = limaBackend.limaSpawn(args);
|
||||
} else {
|
||||
throw new Error(`Don't know how to run trivy on platform ${ os.platform() }`);
|
||||
}
|
||||
|
||||
return await this.processChildOutput(child, subcommandName, sendNotifications);
|
||||
}
|
||||
|
||||
async processChildOutput(child: ChildProcess, subcommandName: string, sendNotifications: boolean): Promise<childResultType> {
|
||||
const result = { stdout: '', stderr: '' };
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
const dataString = data.toString();
|
||||
|
||||
if (sendNotifications) {
|
||||
this.emit('kim-process-output', dataString, false);
|
||||
}
|
||||
result.stdout += dataString;
|
||||
});
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
const dataString = data.toString();
|
||||
|
||||
result.stderr += dataString;
|
||||
if (sendNotifications) {
|
||||
this.emit('kim-process-output', dataString, true);
|
||||
}
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (result.stderr) {
|
||||
const timeLessMessage = result.stderr.replace(/\btime=".*?"/g, '');
|
||||
|
||||
if (this.lastErrorMessage !== timeLessMessage) {
|
||||
this.lastErrorMessage = timeLessMessage;
|
||||
this.sameErrorMessageCount = 1;
|
||||
console.log(result.stderr.replace(/(?!<\r)\n/g, '\r\n'));
|
||||
} else {
|
||||
const m = /(Error: .*)/.exec(this.lastErrorMessage);
|
||||
|
||||
this.sameErrorMessageCount += 1;
|
||||
console.log(`kim ${ subcommandName }: ${ m ? m[1] : 'same error message' } #${ this.sameErrorMessageCount }\r`);
|
||||
}
|
||||
}
|
||||
if (code === 0) {
|
||||
resolve({ ...result, code });
|
||||
} else if (signal) {
|
||||
reject({
|
||||
...result, code: -1, signal
|
||||
});
|
||||
} else {
|
||||
reject({ ...result, code });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the Kim service needs to be reinstalled.
|
||||
*/
|
||||
protected async isInstallValid(mgr: K8s.KubernetesBackend, endpoint?: string): Promise<boolean> {
|
||||
const host = await mgr.ipAddress;
|
||||
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const client = new k8s.KubeConfig();
|
||||
|
||||
client.loadFromDefault();
|
||||
client.setCurrentContext(KUBE_CONTEXT);
|
||||
const api = client.makeApiClient(k8s.CoreV1Api);
|
||||
|
||||
// Remove any stale pods; do this first, as we may end up having an invalid
|
||||
// configuration but with stale pods. Note that `kim builder install --force`
|
||||
// will _not_ fix any stale pods. We need to wait for the node IP to be
|
||||
// correct first, though, to ensure that we don't end up with a recreated
|
||||
// pod with the stale address.
|
||||
await this.waitForNodeIP(api, host);
|
||||
await this.removeStalePods(api);
|
||||
|
||||
const wantedEndpoint = endpoint || host;
|
||||
|
||||
// Check if the endpoint has the correct address
|
||||
try {
|
||||
const { body: endpointBody } = await api.readNamespacedEndpoints('builder', 'kube-image');
|
||||
const subset = endpointBody.subsets?.find(subset => subset.ports?.some(port => port.name === 'kim'));
|
||||
|
||||
if (!(subset?.addresses || []).some(address => address.ip === wantedEndpoint)) {
|
||||
console.log('Existing kim install invalid: incorrect endpoint address.');
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (ex.statusCode === 404) {
|
||||
console.log('Existing kim install invalid: missing endpoint');
|
||||
|
||||
return false;
|
||||
}
|
||||
console.error('Error looking for endpoints:', ex);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
// Check if the certificate has the correct address
|
||||
const { body: secretBody } = await api.readNamespacedSecret('kim-tls-server', 'kube-image');
|
||||
const encodedCert = (secretBody.data || {})['tls.crt'];
|
||||
|
||||
// If we don't have a cert, that's fine — kim will fix it.
|
||||
if (encodedCert) {
|
||||
const cert = Buffer.from(encodedCert, 'base64');
|
||||
const secureContext = tls.createSecureContext({ cert });
|
||||
const socket = new tls.TLSSocket(new net.Socket(), { secureContext });
|
||||
const parsedCert = socket.getCertificate();
|
||||
|
||||
console.log(parsedCert);
|
||||
if (parsedCert && 'subjectaltname' in parsedCert) {
|
||||
const { subjectaltname } = parsedCert;
|
||||
const names = subjectaltname.split(',').map(s => s.trim());
|
||||
const acceptable = [`IP Address:${ wantedEndpoint }`, `DNS:${ wantedEndpoint }`];
|
||||
|
||||
if (!names.some(name => acceptable.includes(name))) {
|
||||
console.log(`Existing kim install invalid: incorrect certificate (${ subjectaltname } does not contain ${ wantedEndpoint }).`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the Kubernetes node to have the expected IP address.
|
||||
*
|
||||
* When the (single-node) cluster initially starts up, the node (internal)
|
||||
* address can take a while to be updated.
|
||||
* @param api API to communicate with Kubernetes.
|
||||
* @param hostAddr The expected node address.
|
||||
*/
|
||||
protected async waitForNodeIP(api: k8s.CoreV1Api, hostAddr: string) {
|
||||
console.log(`Waiting for Kubernetes node IP to become ${ hostAddr }...`);
|
||||
while (true) {
|
||||
const { body: nodeList } = await api.listNode();
|
||||
const addresses = nodeList.items
|
||||
.flatMap(node => node.status?.addresses)
|
||||
.filter(defined)
|
||||
.filter(address => address.type === 'InternalIP')
|
||||
.flatMap(address => address.address);
|
||||
|
||||
if (addresses.includes(hostAddr)) {
|
||||
break;
|
||||
}
|
||||
await util.promisify(setTimeout)(1_000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When we start the cluster, we may have leftover pods from the builder
|
||||
* daemonset that have stale addresses. They will not work correctly (not
|
||||
* listening on the new address), but their existence will prevent a new,
|
||||
* correct pod from being created.
|
||||
*
|
||||
* @param api API to communicate with Kubernetes.
|
||||
* @param hostAddr The expected node address.
|
||||
*/
|
||||
protected async removeStalePods(api: k8s.CoreV1Api) {
|
||||
const { body: nodeList } = await api.listNode();
|
||||
const addresses = nodeList.items
|
||||
.flatMap(node => node.status?.addresses)
|
||||
.filter(defined)
|
||||
.filter(address => address.type === 'InternalIP')
|
||||
.flatMap(address => address.address);
|
||||
|
||||
const { body: podList } = await api.listNamespacedPod(
|
||||
'kube-image', undefined, undefined, undefined, undefined,
|
||||
'app.kubernetes.io/name=kim,app.kubernetes.io/component=builder');
|
||||
|
||||
for (const pod of podList.items) {
|
||||
const { namespace, name } = pod.metadata || {};
|
||||
|
||||
if (!namespace || !name) {
|
||||
continue;
|
||||
}
|
||||
const currentAddress = pod.status?.podIP;
|
||||
|
||||
if (currentAddress && !addresses.includes(currentAddress)) {
|
||||
console.log(`Deleting stale builder pod ${ namespace }:${ name } - pod IP ${ currentAddress } not in ${ addresses }`);
|
||||
api.deleteNamespacedPod(name, namespace);
|
||||
} else {
|
||||
console.log(`Keeping builder pod ${ namespace }:${ name } - pod IP ${ currentAddress } in ${ addresses }`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the kim backend if required; this returns when the backend is ready.
|
||||
* @param force If true, force a reinstall of the backend.
|
||||
*/
|
||||
async install(backend: K8s.KubernetesBackend, force = false, address?: string) {
|
||||
if (!force && await backend.isServiceReady('kube-image', 'builder')) {
|
||||
console.log('Skipping kim reinstall: service is ready, and without --force');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const maxWaitTime = 120_000;
|
||||
const waitTime = 3_000;
|
||||
const args = ['builder', 'install'];
|
||||
|
||||
if (force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
if (address) {
|
||||
args.push('--endpoint-addr', address);
|
||||
}
|
||||
|
||||
console.log(`Installing kim: kim ${ args.join(' ') }`);
|
||||
|
||||
try {
|
||||
await childProcess.spawnFile(
|
||||
resources.executable('kim'),
|
||||
args,
|
||||
{ stdio: console, windowsHide: true });
|
||||
|
||||
while (true) {
|
||||
const currentTime = Date.now();
|
||||
|
||||
if ((currentTime - startTime) > maxWaitTime) {
|
||||
console.log(`Waited more than ${ maxWaitTime / 1000 } secs, it might start up later`);
|
||||
break;
|
||||
}
|
||||
if (await backend.isServiceReady('kube-image', 'builder')) {
|
||||
break;
|
||||
}
|
||||
await util.promisify(setTimeout)(waitTime);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to restart the kim builder: ${ e.message }.`);
|
||||
console.error('The images page will probably be empty');
|
||||
}
|
||||
}
|
||||
|
||||
async buildImage(dirPart: string, filePart: string, taggedImageName: string): Promise<childResultType> {
|
||||
const args = ['build'];
|
||||
|
||||
args.push('--file');
|
||||
args.push(path.join(dirPart, filePart));
|
||||
args.push('--tag');
|
||||
args.push(taggedImageName);
|
||||
args.push(dirPart);
|
||||
|
||||
return await this.runKimCommand(args);
|
||||
}
|
||||
|
||||
async deleteImage(imageID: string): Promise<childResultType> {
|
||||
return await this.runKimCommand(['rmi', imageID]);
|
||||
}
|
||||
|
||||
async pullImage(taggedImageName: string): Promise<childResultType> {
|
||||
return await this.runKimCommand(['pull', taggedImageName, '--debug']);
|
||||
}
|
||||
|
||||
async pushImage(taggedImageName: string): Promise<childResultType> {
|
||||
return await this.runKimCommand(['push', taggedImageName, '--debug']);
|
||||
}
|
||||
|
||||
async getImages(): Promise<childResultType> {
|
||||
return await this.runKimCommand(['images', '--all'], false);
|
||||
}
|
||||
|
||||
async scanImage(taggedImageName: string): Promise<childResultType> {
|
||||
return await this.runTrivyCommand(['image', '--no-progress', '--format', 'template',
|
||||
'--template', '@/var/lib/trivy.tpl', taggedImageName]);
|
||||
}
|
||||
|
||||
parse(data: string): imageType[] {
|
||||
const results = data.trimEnd().split(/\r?\n/).slice(1).map((line) => {
|
||||
const [imageName, tag, imageID, size] = line.split(/\s+/);
|
||||
|
||||
return {
|
||||
imageName, tag, imageID, size
|
||||
};
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
listImages(): imageType[] {
|
||||
return this.images;
|
||||
}
|
||||
|
||||
async refreshImages() {
|
||||
try {
|
||||
const result: childResultType = await this.getImages();
|
||||
|
||||
if (result.stderr) {
|
||||
if (!this.showedStderr) {
|
||||
console.log(`kim images: ${ result.stderr } `);
|
||||
this.showedStderr = true;
|
||||
}
|
||||
} else {
|
||||
this.showedStderr = false;
|
||||
}
|
||||
this.images = this.parse(result.stdout);
|
||||
if (!this._isReady) {
|
||||
this._isReady = true;
|
||||
this.emit('readiness-changed', true);
|
||||
}
|
||||
this.emit('images-changed', this.images);
|
||||
} catch (err) {
|
||||
if (!this.showedStderr) {
|
||||
if (err.stderr && !err.stdout && !err.signal) {
|
||||
console.log(err.stderr);
|
||||
} else {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
this.showedStderr = true;
|
||||
if ('code' in err && this._isReady) {
|
||||
this._isReady = false;
|
||||
this.emit('readiness-changed', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_refreshImages: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default Kim;
|
||||
@@ -30,13 +30,6 @@ import K3sHelper, { ShortVersion } from './k3sHelper';
|
||||
import ProgressTracker from './progressTracker';
|
||||
import * as K8s from './k8s';
|
||||
|
||||
// Helpers for setting progress
|
||||
enum Progress {
|
||||
INDETERMINATE = '<indeterminate>',
|
||||
DONE = '<done>',
|
||||
EMPTY = '<empty>',
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumeration for tracking what operation the backend is undergoing.
|
||||
*/
|
||||
@@ -753,6 +746,7 @@ export default class LimaBackend extends events.EventEmitter implements K8s.Kube
|
||||
console.debug('/etc/rancher/k3s/k3s.yaml is ready.');
|
||||
}
|
||||
);
|
||||
this.setState(K8s.State.VM_STARTED);
|
||||
await this.progressTracker.action(
|
||||
'Updating kubeconfig',
|
||||
50,
|
||||
|
||||
@@ -6,6 +6,7 @@ import resources from '@/resources';
|
||||
import PathConflictManager from '@/main/pathConflictManager';
|
||||
import * as window from '@/window';
|
||||
|
||||
// TODO: Remove 'kim' when we stop shipping kim
|
||||
const INTEGRATIONS = ['helm', 'kim', 'kubectl', 'nerdctl'];
|
||||
const console = Logging.background;
|
||||
const PUBLIC_LINK_DIR = '/usr/local/bin';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Kuberentes backend for Windows, based on WSL2 + k3s
|
||||
// Kubernetes backend for Windows, based on WSL2 + k3s
|
||||
|
||||
import crypto from 'crypto';
|
||||
import events from 'events';
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
/**
|
||||
* This module contains code for handling kim (images).
|
||||
* This module contains code for handling image-processor events (nerdctl, kim).
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import util from 'util';
|
||||
|
||||
import Electron from 'electron';
|
||||
|
||||
import Kim from '@/k8s-engine/kim';
|
||||
import { ImageProcessor } from '@/k8s-engine/images/imageProcessor';
|
||||
import { createImageProcessor, ImageProcessorName } from '@/k8s-engine/images/imageFactory';
|
||||
import Logging from '@/utils/logging';
|
||||
import * as window from '@/window';
|
||||
import * as K8s from '@/k8s-engine/k8s';
|
||||
|
||||
const console = Logging.kim;
|
||||
|
||||
let imageManager: Kim;
|
||||
let imageManager: ImageProcessor;
|
||||
let lastBuildDirectory = '';
|
||||
let mountCount = 0;
|
||||
|
||||
export function setupKim(k8sManager: K8s.KubernetesBackend) {
|
||||
imageManager = imageManager ?? new Kim(k8sManager);
|
||||
/**
|
||||
* Map image-related events to the associated image processor's methods
|
||||
* @param imageProcessorName
|
||||
* @param k8sManager
|
||||
*/
|
||||
|
||||
interface KimImage {
|
||||
export function setupImageProcessor(imageProcessorName: ImageProcessorName, k8sManager: K8s.KubernetesBackend): ImageProcessor {
|
||||
imageManager = imageManager ?? createImageProcessor(imageProcessorName, k8sManager);
|
||||
|
||||
interface ImageContents {
|
||||
imageName: string,
|
||||
tag: string,
|
||||
imageID: string,
|
||||
@@ -30,11 +36,11 @@ export function setupKim(k8sManager: K8s.KubernetesBackend) {
|
||||
imageManager.on('readiness-changed', (state: boolean) => {
|
||||
window.send('images-check-state', state);
|
||||
});
|
||||
imageManager.on('kim-process-output', (data: string, isStderr: boolean) => {
|
||||
window.send('kim-process-output', data, isStderr);
|
||||
imageManager.on('images-process-output', (data: string, isStderr: boolean) => {
|
||||
window.send('images-process-output', data, isStderr);
|
||||
});
|
||||
|
||||
function onImagesChanged(images: KimImage[]) {
|
||||
function onImagesChanged(images: ImageContents[]) {
|
||||
window.send('images-changed', images);
|
||||
}
|
||||
Electron.ipcMain.handle('images-mounted', (_, mounted) => {
|
||||
@@ -50,30 +56,15 @@ export function setupKim(k8sManager: K8s.KubernetesBackend) {
|
||||
|
||||
Electron.ipcMain.on('do-image-deletion', async(event, imageName, imageID) => {
|
||||
try {
|
||||
const maxNumAttempts = 2;
|
||||
// On macOS a second attempt is needed to actually delete the image.
|
||||
// Probably due to a timing issue on the server part of kim, but not determined why.
|
||||
// Leave this in for windows in case it can happen there too.
|
||||
let i = 0;
|
||||
|
||||
for (i = 0; i < maxNumAttempts; i++) {
|
||||
await imageManager.deleteImage(imageID);
|
||||
await imageManager.refreshImages();
|
||||
if (!imageManager.listImages().some(image => image.imageID === imageID)) {
|
||||
break;
|
||||
}
|
||||
await util.promisify(setTimeout)(500);
|
||||
}
|
||||
if (i === maxNumAttempts) {
|
||||
console.log(`Failed to delete ${ imageID } in ${ maxNumAttempts } tries`);
|
||||
}
|
||||
event.reply('kim-process-ended', 0);
|
||||
await imageManager.deleteImage(imageID);
|
||||
await imageManager.refreshImages();
|
||||
event.reply('images-process-ended', 0);
|
||||
} catch (err) {
|
||||
await Electron.dialog.showMessageBox({
|
||||
message: `Error trying to delete image ${ imageName } (${ imageID }):\n\n ${ err.stderr } `,
|
||||
type: 'error'
|
||||
});
|
||||
event.reply('kim-process-ended', 1);
|
||||
event.reply('images-process-ended', 1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -90,13 +81,13 @@ export function setupKim(k8sManager: K8s.KubernetesBackend) {
|
||||
const results = Electron.dialog.showOpenDialogSync(options);
|
||||
|
||||
if (results === undefined) {
|
||||
event.reply('kim-process-cancelled');
|
||||
event.reply('images-process-cancelled');
|
||||
|
||||
return;
|
||||
}
|
||||
if (results.length !== 1) {
|
||||
console.log(`Expecting exactly one result, got ${ results.join(', ') }`);
|
||||
event.reply('kim-process-cancelled');
|
||||
event.reply('images-process-cancelled');
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +105,7 @@ export function setupKim(k8sManager: K8s.KubernetesBackend) {
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
event.reply('kim-process-ended', code);
|
||||
event.reply('images-process-ended', code);
|
||||
});
|
||||
|
||||
Electron.ipcMain.on('do-image-pull', async(event, imageName) => {
|
||||
@@ -134,7 +125,7 @@ export function setupKim(k8sManager: K8s.KubernetesBackend) {
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
event.reply('kim-process-ended', code);
|
||||
event.reply('images-process-ended', code);
|
||||
});
|
||||
|
||||
Electron.ipcMain.on('do-image-scan', async(event, imageName) => {
|
||||
@@ -154,7 +145,7 @@ export function setupKim(k8sManager: K8s.KubernetesBackend) {
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
event.reply('kim-process-ended', code);
|
||||
event.reply('images-process-ended', code);
|
||||
});
|
||||
|
||||
Electron.ipcMain.on('do-image-push', async(event, imageName, imageID, tag) => {
|
||||
@@ -170,10 +161,12 @@ export function setupKim(k8sManager: K8s.KubernetesBackend) {
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
event.reply('kim-process-ended', code);
|
||||
event.reply('images-process-ended', code);
|
||||
});
|
||||
|
||||
Electron.ipcMain.handle('images-check-state', () => {
|
||||
return imageManager.isReady;
|
||||
});
|
||||
|
||||
return imageManager;
|
||||
}
|
||||
@@ -174,11 +174,12 @@ export class Tray {
|
||||
|
||||
protected updateMenu() {
|
||||
const labels = {
|
||||
[State.STOPPED]: 'Kubernetes is stopped',
|
||||
[State.STARTING]: 'Kubernetes is starting',
|
||||
[State.STARTED]: 'Kubernetes is running',
|
||||
[State.STOPPING]: 'Kubernetes is shutting down',
|
||||
[State.ERROR]: 'Kubernetes has encountered an error',
|
||||
[State.STOPPED]: 'Kubernetes is stopped',
|
||||
[State.STARTING]: 'Kubernetes is starting',
|
||||
[State.VM_STARTED]: 'VM is ready',
|
||||
[State.STARTED]: 'Kubernetes is running',
|
||||
[State.STOPPING]: 'Kubernetes is shutting down',
|
||||
[State.ERROR]: 'Kubernetes has encountered an error',
|
||||
};
|
||||
|
||||
let icon = resources.get('icons/kubernetes-icon-black.png');
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
<Images
|
||||
class="content"
|
||||
:images="images"
|
||||
:image-namespaces="imageNamespaces"
|
||||
:state="state"
|
||||
:show-all="settings.images.showAll"
|
||||
:selected-namespace="settings.images.namespace"
|
||||
@toggledShowAll="onShowAllImagesChanged"
|
||||
@switchNamespace="onChangeNamespace"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,20 +22,21 @@ export default {
|
||||
components: { Images },
|
||||
data() {
|
||||
return {
|
||||
settings: ipcRenderer.sendSync('settings-read'),
|
||||
k8sState: ipcRenderer.sendSync('k8s-state'),
|
||||
kimState: false,
|
||||
images: [],
|
||||
settings: ipcRenderer.sendSync('settings-read'),
|
||||
k8sState: ipcRenderer.sendSync('k8s-state'),
|
||||
imageManagerState: false,
|
||||
images: [],
|
||||
imageNamespaces: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
state() {
|
||||
if (this.k8sState !== K8s.State.STARTED) {
|
||||
return 'K8S_UNREADY';
|
||||
if (![K8s.State.VM_STARTED, K8s.State.STARTED].includes(this.k8sState)) {
|
||||
return 'IMAGE_MANAGER_UNREADY';
|
||||
}
|
||||
|
||||
return this.kimState ? 'READY' : 'KIM_UNREADY';
|
||||
return this.imageManagerState ? 'READY' : 'IMAGE_MANAGER_UNREADY';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -43,12 +47,18 @@ export default {
|
||||
);
|
||||
ipcRenderer.on('images-changed', (event, images) => {
|
||||
this.$data.images = images;
|
||||
if (this.imageNamespaces.length === 0) {
|
||||
// This happens if the user clicked on the Images panel before data was ready,
|
||||
// so no namespaces were available when it initially asked for them.
|
||||
// When the data is ready, images are pushed in, but namespaces aren't.
|
||||
ipcRenderer.send('images-namespaces-read');
|
||||
}
|
||||
});
|
||||
ipcRenderer.on('k8s-check-state', (event, state) => {
|
||||
this.$data.k8sState = state;
|
||||
});
|
||||
ipcRenderer.on('images-check-state', (event, state) => {
|
||||
this.kimState = state;
|
||||
this.imageManagerState = state;
|
||||
});
|
||||
ipcRenderer.on('settings-update', (event, settings) => {
|
||||
// TODO: put in a status bar
|
||||
@@ -58,8 +68,12 @@ export default {
|
||||
this.$data.images = await ipcRenderer.invoke('images-mounted', true);
|
||||
})();
|
||||
(async() => {
|
||||
this.$data.kimState = await ipcRenderer.invoke('images-check-state');
|
||||
this.$data.imageManagerState = await ipcRenderer.invoke('images-check-state');
|
||||
})();
|
||||
ipcRenderer.on('images-namespaces', (event, namespaces) => {
|
||||
this.$data.imageNamespaces = namespaces;
|
||||
});
|
||||
ipcRenderer.send('images-namespaces-read');
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
@@ -73,6 +87,12 @@ export default {
|
||||
{ images: { showAll: value } } );
|
||||
}
|
||||
},
|
||||
onChangeNamespace(value) {
|
||||
if (value !== this.settings.images.namespace) {
|
||||
ipcRenderer.invoke('settings-write',
|
||||
{ images: { namespace: value } } );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
12
src/typings/electron-ipc.d.ts
vendored
12
src/typings/electron-ipc.d.ts
vendored
@@ -38,13 +38,14 @@ interface IpcMainEvents {
|
||||
'update-state': () => void;
|
||||
// #endregion
|
||||
|
||||
// #region main/kim
|
||||
// #region main/imageEvents
|
||||
'confirm-do-image-deletion': (imageName: string, imageID: string) => void;
|
||||
'do-image-build': (taggedImageName: string) => void;
|
||||
'do-image-pull': (imageName: string) => void;
|
||||
'do-image-scan': (imageName: string) => void;
|
||||
'do-image-push': (imageName: string, imageID: string, tag: string) => void;
|
||||
'do-image-deletion': (imageName: string, imageID: string) => void;
|
||||
'images-namespaces-read': () => void;
|
||||
// #endregion
|
||||
|
||||
// #region firstrun
|
||||
@@ -67,7 +68,7 @@ interface IpcMainInvokeEvents {
|
||||
'get-app-version': () => string;
|
||||
'show-message-box': (options: Electron.MessageBoxOptions) => Promise<Electron.MessageBoxReturnValue>;
|
||||
|
||||
// #region main/kim
|
||||
// #region main/imageEvents
|
||||
'images-mounted': (mounted: boolean) => {imageName: string, tag: string, imageID: string, size: string}[];
|
||||
'images-check-state': () => boolean;
|
||||
// #endregion
|
||||
@@ -90,11 +91,12 @@ interface IpcRendererEvents {
|
||||
'service-changed': (services: import('@/k8s-engine/k8s').ServiceEntry[]) => void;
|
||||
|
||||
// #region Images
|
||||
'kim-process-cancelled': () => void;
|
||||
'kim-process-ended': (exitCode: number) => void;
|
||||
'kim-process-output': (data: string, isStdErr: boolean) => void;
|
||||
'images-process-cancelled': () => void;
|
||||
'images-process-ended': (exitCode: number) => void;
|
||||
'images-process-output': (data: string, isStdErr: boolean) => void;
|
||||
'images-changed': (images: {imageName: string, tag: string, imageID: string, size: string}[]) => void;
|
||||
'images-check-state': (state: boolean) => void;
|
||||
'images-namespaces': (namespaces: string[]) => void;
|
||||
// #endregion
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import KimNonBuildOutputCuller from '~/utils/processOutputInterpreters/kim-non-build-output';
|
||||
import KimBuildOutputCuller from '~/utils/processOutputInterpreters/kim-build-output';
|
||||
import TrivyScanImageOutputCuller from '~/utils/processOutputInterpreters/trivy-image-output';
|
||||
import ImageNonBuildOutputCuller from '@/utils/processOutputInterpreters/image-non-build-output';
|
||||
import ImageBuildOutputCuller from '@/utils/processOutputInterpreters/image-build-output';
|
||||
import TrivyScanImageOutputCuller from '@/utils/processOutputInterpreters/trivy-image-output';
|
||||
|
||||
const cullersByName = {
|
||||
build: KimBuildOutputCuller,
|
||||
build: ImageBuildOutputCuller,
|
||||
'trivy-image': TrivyScanImageOutputCuller
|
||||
};
|
||||
|
||||
export default function getImageOutputCuller(command) {
|
||||
const klass = cullersByName[command] || KimNonBuildOutputCuller;
|
||||
const klass = cullersByName[command] || ImageNonBuildOutputCuller;
|
||||
|
||||
return new klass();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import semver from 'semver';
|
||||
import * as childProcess from '@/utils/childProcess';
|
||||
import resources from '@/resources';
|
||||
|
||||
// TODO: Remove all references to kim once we stop shipping it
|
||||
const flags: Record<string, string> = {
|
||||
helm: 'version',
|
||||
kim: '-v',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import KimBuildOutputCuller from '@/utils/processOutputInterpreters/kim-build-output';
|
||||
import ImageBuildOutputCuller from '@/utils/processOutputInterpreters/image-build-output';
|
||||
|
||||
describe('kim build output', () => {
|
||||
describe('image build output', () => {
|
||||
it('returns the raw text back', () => {
|
||||
const buildOutputPath = path.join('./src/utils/processOutputInterpreters/__tests__/assets', 'build.txt');
|
||||
const data = fs.readFileSync(buildOutputPath).toString();
|
||||
const culler = new KimBuildOutputCuller();
|
||||
const culler = new ImageBuildOutputCuller();
|
||||
|
||||
culler.addData(data);
|
||||
expect(culler.getProcessedData()).toBe(data.replace(/\r/g, ''));
|
||||
@@ -1,15 +1,15 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import KimNonBuildOutputCuller from '@/utils/processOutputInterpreters/kim-non-build-output';
|
||||
import ImageNonBuildOutputCuller from '@/utils/processOutputInterpreters/image-non-build-output';
|
||||
|
||||
describe('simple kim output', () => {
|
||||
describe('simple image output', () => {
|
||||
describe('push', () => {
|
||||
it('culls by SHA', () => {
|
||||
const fname = path.join('./src/utils/processOutputInterpreters/__tests__/assets', 'push.txt');
|
||||
const data = fs.readFileSync(fname).toString();
|
||||
const lines = data.split(/(\r?\n)/);
|
||||
const culler = new KimNonBuildOutputCuller();
|
||||
const culler = new ImageNonBuildOutputCuller();
|
||||
|
||||
expect(lines.length).toBeGreaterThan(6);
|
||||
culler.addData(lines.slice(0, 24).join(''));
|
||||
@@ -131,7 +131,7 @@ describe('simple kim output', () => {
|
||||
const fname = path.join('./src/utils/processOutputInterpreters/__tests__/assets', 'pull.txt');
|
||||
const data = fs.readFileSync(fname).toString();
|
||||
const lines = data.split(/(\r?\n)/);
|
||||
const culler = new KimNonBuildOutputCuller();
|
||||
const culler = new ImageNonBuildOutputCuller();
|
||||
|
||||
expect(lines.length).toBeGreaterThan(6);
|
||||
culler.addData(lines.slice(0, 16).join(''));
|
||||
@@ -1,6 +1,6 @@
|
||||
const LineSplitter = /\r?\n/;
|
||||
|
||||
export default class KimBuildOutputCuller {
|
||||
export default class ImageBuildOutputCuller {
|
||||
constructor() {
|
||||
this.lines = [];
|
||||
}
|
||||
@@ -2,7 +2,7 @@ const LineSplitter = /\r?\n/;
|
||||
const ShaLineMatcher = /^[-\w]+-sha256:(\w+):\s*\w+\s*\|.*?\|/;
|
||||
const SummaryLineMatcher = /^elapsed:.*total:/;
|
||||
|
||||
export default class KimNonBuildOutputCuller {
|
||||
export default class ImageNonBuildOutputCuller {
|
||||
constructor() {
|
||||
this.buffering = true;
|
||||
this.lines = [];
|
||||
Reference in New Issue
Block a user