Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adding kube yaml tab to details page for kube quadlets #245

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/backend/src/apis/quadlet-api-impl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**********************************************************************
* Copyright (C) 2025 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import { expect, test, vi, beforeEach } from 'vitest';
import { QuadletApiImpl } from './quadlet-api-impl';
import type { QuadletService } from '../services/quadlet-service';
import type { SystemdService } from '../services/systemd-service';
import type { PodmanService } from '../services/podman-service';
import type { ProviderService } from '../services/provider-service';
import type { LoggerService } from '../services/logger-service';
import type { ProviderContainerConnection } from '@podman-desktop/api';
import type { ProviderContainerConnectionIdentifierInfo } from '/@shared/src/models/provider-container-connection-identifier-info';

const QUADLET_SERVICE: QuadletService = {
getKubeYAML: vi.fn(),
} as unknown as QuadletService;
const SYSTEMD_SERVICE: SystemdService = {} as unknown as SystemdService;
const PODMAN_SERVICE: PodmanService = {} as unknown as PodmanService;
const PROVIDER_SERVICE: ProviderService = {
getProviderContainerConnection: vi.fn(),
} as unknown as ProviderService;
const LOGGER_SERVICE: LoggerService = {} as unknown as LoggerService;

const WSL_PROVIDER_CONNECTION_MOCK: ProviderContainerConnection = {
connection: {
type: 'podman',
vmType: 'WSL',
name: 'podman-machine-default',
},
providerId: 'podman',
} as ProviderContainerConnection;

const WSL_PROVIDER_IDENTIFIER: ProviderContainerConnectionIdentifierInfo = {
name: WSL_PROVIDER_CONNECTION_MOCK.connection.name,
providerId: WSL_PROVIDER_CONNECTION_MOCK.providerId,
};

beforeEach(() => {
vi.resetAllMocks();

vi.mocked(PROVIDER_SERVICE.getProviderContainerConnection).mockReturnValue(WSL_PROVIDER_CONNECTION_MOCK);
});

function getQuadletApiImpl(): QuadletApiImpl {
return new QuadletApiImpl({
quadlet: QUADLET_SERVICE,
systemd: SYSTEMD_SERVICE,
podman: PODMAN_SERVICE,
providers: PROVIDER_SERVICE,
loggerService: LOGGER_SERVICE,
});
}

test('QuadletApiImpl#getKubeYAML should propagate result from QuadletService#getKubeYAML', async () => {
vi.mocked(QUADLET_SERVICE.getKubeYAML).mockResolvedValue('dummy-yaml-content');

const api = getQuadletApiImpl();

const result = await api.getKubeYAML(WSL_PROVIDER_IDENTIFIER, 'dummy-quadlet-id');
expect(result).toStrictEqual('dummy-yaml-content');
expect(PROVIDER_SERVICE.getProviderContainerConnection).toHaveBeenCalledWith(WSL_PROVIDER_IDENTIFIER);
});
9 changes: 9 additions & 0 deletions packages/backend/src/apis/quadlet-api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ export class QuadletApiImpl extends QuadletApi {
return this.dependencies.quadlet.getSynchronisationInfo();
}

override async getKubeYAML(connection: ProviderContainerConnectionIdentifierInfo, id: string): Promise<string> {
const providerConnection = this.dependencies.providers.getProviderContainerConnection(connection);

return await this.dependencies.quadlet.getKubeYAML({
provider: providerConnection,
id: id,
});
}

override async validate(content: string): Promise<QuadletCheck[]> {
return new QuadletValidator().validate(content);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/models/quadlet.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/**
* @author axel7083
*/
import type { QuadletType } from '/@shared/src/utils/quadlet-type';

export interface Quadlet {
id: string;
path: string;
// raw content of the service file
// raw content (generate) of the service file
content: string;
state: 'active' | 'inactive' | 'deleting' | 'unknown';
// type of quadlet
type: QuadletType;
}
4 changes: 3 additions & 1 deletion packages/backend/src/services/quadlet-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const QUADLET_MOCK: Quadlet = {
path: 'foo/bar',
state: 'unknown',
content: 'dummy-content',
type: QuadletType.CONTAINER,
};

const PROGRESS_REPORT: Progress<{ message?: string; increment?: number }> = {
Expand Down Expand Up @@ -253,7 +254,7 @@ describe('QuadletService#saveIntoMachine', () => {
// expect yaml file to be created
expect(PODMAN_SERVICE_MOCK.writeTextFile).toHaveBeenCalledWith(
WSL_RUNNING_PROVIDER_CONNECTION_MOCK,
`~/.config/containers/systemd/foo.yaml`, // always the same (using node:path/posix)
`~/.config/containers/systemd/foo-kube.yaml`, // always the same (using node:path/posix)
'foo: bar',
);
});
Expand Down Expand Up @@ -282,6 +283,7 @@ describe('QuadletService#remove', () => {
state: 'unknown',
path: `config/quadlet-${index}.container`,
content: 'dummy-content',
type: QuadletType.CONTAINER,
}));

beforeEach(() => {
Expand Down
49 changes: 46 additions & 3 deletions packages/backend/src/services/quadlet-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { QuadletDryRunParser } from '../utils/parsers/quadlet-dryrun-parser';
import type { Quadlet } from '../models/quadlet';
import type { QuadletInfo } from '/@shared/src/models/quadlet-info';
import type { AsyncInit } from '../utils/async-init';
import { join as joinposix, basename } from 'node:path/posix';
import { join as joinposix, basename, dirname, isAbsolute } from 'node:path/posix';
import { load } from 'js-yaml';
import { QuadletTypeParser } from '../utils/parsers/quadlet-type-parser';
import type { SynchronisationInfo } from '/@shared/src/models/synchronisation';
import { TelemetryEvents } from '../utils/telemetry-events';
import type { QuadletType } from '/@shared/src/utils/quadlet-type';
import { QuadletType } from '/@shared/src/utils/quadlet-type';
import { QuadletKubeParser } from '../utils/parsers/quadlet-kube-parser';

export class QuadletService extends QuadletHelper implements Disposable, AsyncInit {
#extensionsEventDisposable: Disposable | undefined;
Expand Down Expand Up @@ -237,7 +238,7 @@ export class QuadletService extends QuadletHelper implements Disposable, AsyncIn
console.warn(err);
load(resource);
return {
filename: `${basename}.yaml`,
filename: `${basename}-kube.yaml`,
content: resource,
};
}
Expand Down Expand Up @@ -486,6 +487,48 @@ export class QuadletService extends QuadletHelper implements Disposable, AsyncIn
}));
}

async getKubeYAML(options: { id: string; provider: ProviderContainerConnection }): Promise<string> {
const quadlet = this.findQuadlet({
provider: options.provider,
id: options.id,
});
if (!quadlet) throw new Error(`quadlet with id ${options.id} not found`);

// assert quadlet type is kube.
if (quadlet.type !== QuadletType.KUBE)
throw new Error(`cannot get kube yaml of non-kube quadlet: quadlet ${quadlet.id} type is ${quadlet.type}`);

// extract the yaml file from
const { yaml } = new QuadletKubeParser(quadlet.content).parse();

// found the absolute path of the yaml
// the documentation says "The path, absolute or relative to the location of the unit file, to the Kubernetes YAML file to use."
let target: string;
if (isAbsolute(yaml)) {
target = yaml;
} else {
target = joinposix(dirname(quadlet.path), yaml);
}

// some security, only allow to read yaml / yml files.
if (!target.endsWith('.yaml') && !target.endsWith('.yml')) {
throw new Error(`quadlet ${quadlet.id} declared yaml file ${target}: invalid file format.`);
}

try {
// read
const result = await this.dependencies.podman.readTextFile(options.provider, target);
return result;
} catch (err: unknown) {
console.error(`Something went wrong with readTextFile on ${target}`, err);
// check err is an RunError
if (!err || typeof err !== 'object' || !('exitCode' in err) || !('stderr' in err)) {
throw err;
}
throw new Error(`cannot read ${target}: ${err.stderr}`);
}
}

dispose(): void {
this.#value.clear();
this.#extensionsEventDisposable?.dispose();
Expand Down
37 changes: 37 additions & 0 deletions packages/backend/src/utils/parsers/quadlet-kube-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**********************************************************************
* Copyright (C) 2025 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { test, expect } from 'vitest';
import { QuadletKubeParser } from './quadlet-kube-parser';

const KUBE_QUADLET_EXAMPLE = `
# example.kube
[X-Kube]
Yaml=/mnt/foo/bar.yaml

[Unit]
Wants=network-online.target
After=network-online.target
SourcePath=/home/user/.config/containers/systemd/nginx2.container
RequiresMountsFor=%t/containers
`;

test('expect parser to properly extract Yaml path', () => {
const xkube = new QuadletKubeParser(KUBE_QUADLET_EXAMPLE).parse();
expect(xkube.yaml).toStrictEqual('/mnt/foo/bar.yaml');
});
39 changes: 39 additions & 0 deletions packages/backend/src/utils/parsers/quadlet-kube-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @author axel7083
*/

import { Parser } from './iparser';
import { parse } from 'js-ini';

export interface XKube {
yaml: string;
}

/**
* Utility class to extract the Yaml property of the generated systemd unit.
* The quadlet must be of type Kube.
*/
export class QuadletKubeParser extends Parser<string, XKube> {
constructor(content: string) {
super(content);
}

protected toXKube(kube: Record<string, string>): XKube {
if (!('Yaml' in kube)) throw new Error('missing Yaml in systemd unit section');

return {
yaml: kube['Yaml'],
};
}

override parse(): XKube {
const raw = parse(this.content, {
comment: ['#', ';'],
});
const unit = this.toXKube(raw['X-Kube'] as Record<string, string>);

return {
yaml: unit.yaml,
};
}
}
26 changes: 26 additions & 0 deletions packages/backend/src/utils/parsers/quadlet-type-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**********************************************************************
* Copyright (C) 2025 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { test, expect } from 'vitest';
import { QuadletType } from '/@shared/src/utils/quadlet-type';
import { QuadletTypeParser } from './quadlet-type-parser';

test.each(Object.values(QuadletType))('parsing quadlet %s', (type: QuadletType) => {
const result = new QuadletTypeParser(`[${type}]\nfoo=bar`).parse();
expect(result).toStrictEqual(type);
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { test, expect } from 'vitest';
import { QuadletUnitParser } from './quadlet-unit-parser';
import { QuadletType } from '/@shared/src/utils/quadlet-type';

const CONTAINER_QUADLET_EXAMPLE = `
# demo-quadlet.container
Expand Down Expand Up @@ -44,6 +45,7 @@ test('expect path to be properly extracted', async () => {
expect(result).toStrictEqual(
expect.objectContaining({
path: '/home/user/.config/containers/systemd/nginx2.container',
type: QuadletType.CONTAINER,
}),
);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/utils/parsers/quadlet-unit-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { Parser } from './iparser';
import { parse } from 'js-ini';
import type { Quadlet } from '../../models/quadlet';
import { QuadletType } from '/@shared/src/utils/quadlet-type';

interface Unit {
SourcePath: string;
Expand Down Expand Up @@ -32,11 +33,16 @@ export class QuadletUnitParser extends Parser<string, Quadlet> {
});
const unit = this.toUnit(raw['Unit'] as Record<string, string>);

const extension = unit.SourcePath.split('.').pop();
const type: QuadletType | undefined = Object.values(QuadletType).find(type => extension === type.toLowerCase());
if (!type) throw new Error(`cannot found quadlet type for file ${unit.SourcePath}`);

return {
path: unit.SourcePath,
id: this.serviceName,
content: this.content,
state: 'unknown',
type: type,
};
}
}
Loading