Skip to content

Commit cb41203

Browse files
committed
Add dnf resource
1 parent a9b6765 commit cb41203

File tree

5 files changed

+376
-0
lines changed

5 files changed

+376
-0
lines changed

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { VscodeResource } from './resources/vscode/vscode.js';
3838
import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js';
3939
import { AptResource } from './resources/apt/apt.js';
4040
import { YumResource } from './resources/yum/yum.js';
41+
import { DnfResource } from './resources/dnf/dnf.js';
4142

4243
runPlugin(Plugin.create(
4344
'default',
@@ -80,5 +81,6 @@ runPlugin(Plugin.create(
8081
new DockerResource(),
8182
new AptResource(),
8283
new YumResource(),
84+
new DnfResource(),
8385
])
8486
)

src/resources/dnf/dnf-schema.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "https://www.codifycli.com/dnf.json",
4+
"$comment": "https://docs.codifycli.com/core-resources/dnf/",
5+
"title": "Dnf resource",
6+
"description": "Manage dnf packages on modern Red Hat-based systems.",
7+
"type": "object",
8+
"properties": {
9+
"install": {
10+
"type": "array",
11+
"description": "Installs packages using dnf.",
12+
"items": {
13+
"oneOf": [
14+
{ "type": "string" },
15+
{
16+
"type": "object",
17+
"properties": {
18+
"name": { "type": "string" },
19+
"version": { "type": "string" }
20+
},
21+
"required": ["name"]
22+
}
23+
]
24+
}
25+
},
26+
"update": {
27+
"type": "boolean",
28+
"description": "Whether to run dnf check-update before installing packages. Defaults to true."
29+
}
30+
},
31+
"additionalProperties": false
32+
}

src/resources/dnf/dnf.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { CreatePlan, Resource, ResourceSettings, SpawnStatus, getPty } from 'codify-plugin-lib';
2+
import { OS, ResourceConfig } from 'codify-schemas';
3+
4+
import schema from './dnf-schema.json';
5+
import { DnfInstallParameter, DnfPackage } from './install-parameter.js';
6+
7+
export interface DnfConfig extends ResourceConfig {
8+
install: Array<DnfPackage | string>;
9+
update?: boolean;
10+
}
11+
12+
export class DnfResource extends Resource<DnfConfig> {
13+
14+
override getSettings(): ResourceSettings<DnfConfig> {
15+
return {
16+
id: 'dnf',
17+
operatingSystems: [OS.Linux],
18+
schema,
19+
parameterSettings: {
20+
install: { type: 'stateful', definition: new DnfInstallParameter() },
21+
update: { type: 'boolean', default: true, setting: true }
22+
}
23+
};
24+
}
25+
26+
override async refresh(parameters: Partial<DnfConfig>): Promise<Partial<DnfConfig> | null> {
27+
const $ = getPty();
28+
29+
const dnfCheck = await $.spawnSafe('which dnf');
30+
if (dnfCheck.status === SpawnStatus.ERROR) {
31+
return null;
32+
}
33+
34+
return parameters;
35+
}
36+
37+
override async create(_plan: CreatePlan<DnfConfig>): Promise<void> {
38+
const $ = getPty();
39+
40+
// Update package lists
41+
await $.spawnSafe('dnf check-update', { requiresRoot: true, interactive: true });
42+
43+
console.log('dnf is already installed on this Red Hat-based system');
44+
}
45+
46+
override async destroy(): Promise<void> {
47+
// dnf is a core system component and should not be removed
48+
throw new Error('dnf cannot be destroyed as it is a core system package manager');
49+
}
50+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { ParameterSetting, Plan, StatefulParameter, getPty } from 'codify-plugin-lib';
2+
3+
import { DnfConfig } from './dnf.js';
4+
5+
export interface DnfPackage {
6+
name: string;
7+
version?: string;
8+
}
9+
10+
export class DnfInstallParameter extends StatefulParameter<DnfConfig, Array<DnfPackage | string>> {
11+
12+
getSettings(): ParameterSetting {
13+
return {
14+
type: 'array',
15+
filterInStatelessMode: (desired, current) =>
16+
current.filter((c) => desired.some((d) => this.isSamePackage(d, c))),
17+
isElementEqual: this.isEqual,
18+
}
19+
}
20+
21+
async refresh(desired: Array<DnfPackage | string> | null, _config: Partial<DnfConfig>): Promise<Array<DnfPackage | string> | null> {
22+
const $ = getPty()
23+
const { data: installed } = await $.spawnSafe('rpm -qa --queryformat \'%{NAME} %{VERSION}-%{RELEASE}\\n\'');
24+
25+
if (!installed || installed === '') {
26+
return null;
27+
}
28+
29+
const r = installed.split(/\n/)
30+
.filter(Boolean)
31+
.map((l) => {
32+
const [name, version] = l.split(/\s+/)
33+
.filter(Boolean)
34+
35+
return { name, version }
36+
})
37+
.filter((pkg) =>
38+
// Only return packages that are in the desired list
39+
desired?.some((d) => {
40+
if (typeof d === 'string') {
41+
return d === pkg.name;
42+
}
43+
44+
return d.name === pkg.name;
45+
})
46+
)
47+
.map((installed) => {
48+
if (desired?.find((d) => typeof d === 'string' && d === installed.name)) {
49+
return installed.name;
50+
}
51+
52+
if (desired?.find((d) => typeof d === 'object' && d.name === installed.name && !d.version)) {
53+
return { name: installed.name }
54+
}
55+
56+
return installed;
57+
})
58+
59+
return r.length > 0 ? r : null;
60+
}
61+
62+
async add(valueToAdd: Array<DnfPackage | string>, plan: Plan<DnfConfig>): Promise<void> {
63+
await this.updateIfNeeded(plan);
64+
await this.install(valueToAdd);
65+
}
66+
67+
async modify(newValue: (DnfPackage | string)[], previousValue: (DnfPackage | string)[], plan: Plan<DnfConfig>): Promise<void> {
68+
const valuesToAdd = newValue.filter((n) => !previousValue.some((p) => this.isSamePackage(n, p)));
69+
const valuesToRemove = previousValue.filter((p) => !newValue.some((n) => this.isSamePackage(n, p)));
70+
71+
await this.uninstall(valuesToRemove);
72+
await this.updateIfNeeded(plan);
73+
await this.install(valuesToAdd);
74+
}
75+
76+
async remove(valueToRemove: (DnfPackage | string)[], _plan: Plan<DnfConfig>): Promise<void> {
77+
await this.uninstall(valueToRemove);
78+
}
79+
80+
private async updateIfNeeded(plan: Plan<DnfConfig>): Promise<void> {
81+
if (plan.desiredConfig?.update === false) {
82+
return;
83+
}
84+
85+
const $ = getPty();
86+
await $.spawnSafe('dnf check-update', { requiresRoot: true, interactive: true });
87+
}
88+
89+
private async install(packages: Array<DnfPackage | string>): Promise<void> {
90+
if (!packages || packages.length === 0) {
91+
return;
92+
}
93+
94+
const $ = getPty();
95+
const toInstall = packages.map((p) => {
96+
if (typeof p === 'string') {
97+
return p;
98+
}
99+
100+
if (p.version) {
101+
return `${p.name}-${p.version}`;
102+
}
103+
104+
return p.name;
105+
}).join(' ');
106+
107+
await $.spawn(`dnf install -y ${toInstall} --allowerasing`, { requiresRoot: true, interactive: true });
108+
}
109+
110+
private async uninstall(packages: Array<DnfPackage | string>): Promise<void> {
111+
if (!packages || packages.length === 0) {
112+
return;
113+
}
114+
115+
const $ = getPty();
116+
const toUninstall = packages.map((p) => {
117+
if (typeof p === 'string') {
118+
return p;
119+
}
120+
121+
return p.name;
122+
}).join(' ');
123+
124+
await $.spawn(`dnf remove -y ${toUninstall}`, { requiresRoot: true, interactive: true });
125+
}
126+
127+
isSamePackage(a: DnfPackage | string, b: DnfPackage | string): boolean {
128+
if (typeof a === 'string' || typeof b === 'string') {
129+
if (typeof a === 'string' && typeof b === 'string') {
130+
return a === b;
131+
}
132+
133+
if (typeof a === 'string' && typeof b === 'object') {
134+
return a === b.name;
135+
}
136+
137+
if (typeof a === 'object' && typeof b === 'string') {
138+
return a.name === b;
139+
}
140+
}
141+
142+
if (typeof a === 'object' && typeof b === 'object') {
143+
return a.name === b.name;
144+
}
145+
146+
return false;
147+
}
148+
149+
isEqual(desired: DnfPackage | string, current: DnfPackage | string): boolean {
150+
if (typeof desired === 'string' || typeof current === 'string') {
151+
if (typeof desired === 'string' && typeof current === 'string') {
152+
return desired === current;
153+
}
154+
155+
if (typeof desired === 'string' && typeof current === 'object') {
156+
return desired === current.name;
157+
}
158+
159+
if (typeof desired === 'object' && typeof current === 'string') {
160+
return desired.name === current;
161+
}
162+
}
163+
164+
if (typeof desired === 'object' && typeof current === 'object') {
165+
return desired.version
166+
? desired.version === current.version && desired.name === current.name
167+
: desired.name === current.name;
168+
}
169+
170+
return false;
171+
}
172+
}

test/dnf/dnf.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2+
import { PluginTester } from 'codify-plugin-test';
3+
import * as path from 'node:path';
4+
import { execSync } from 'child_process';
5+
import { TestUtils } from '../test-utils.js';
6+
7+
describe('Dnf resource integration tests', () => {
8+
const pluginPath = path.resolve('./src/index.ts');
9+
10+
it('Can install and uninstall dnf packages', { timeout: 300000 }, async () => {
11+
if (!TestUtils.isLinux()) {
12+
console.log('Skipping dnf test - not running on Linux');
13+
return;
14+
}
15+
16+
// Check if dnf is available
17+
try {
18+
execSync('which dnf');
19+
} catch {
20+
console.log('Skipping dnf test - dnf not available on this system');
21+
return;
22+
}
23+
24+
// Plans correctly and detects that dnf is available
25+
await PluginTester.fullTest(pluginPath, [{
26+
type: 'dnf',
27+
install: [
28+
'curl',
29+
'wget',
30+
'vim-enhanced',
31+
]
32+
}], {
33+
skipUninstall: true,
34+
validateApply: () => {
35+
expect(() => execSync(TestUtils.getShellCommand('which curl'))).to.not.throw;
36+
expect(() => execSync(TestUtils.getShellCommand('which wget'))).to.not.throw;
37+
expect(() => execSync(TestUtils.getShellCommand('which vim'))).to.not.throw;
38+
expect(() => execSync(TestUtils.getShellCommand('which dnf'))).to.not.throw;
39+
},
40+
testModify: {
41+
modifiedConfigs: [{
42+
type: 'dnf',
43+
install: [
44+
'curl',
45+
'git'
46+
],
47+
}],
48+
validateModify: () => {
49+
expect(() => execSync(TestUtils.getShellCommand('which curl'))).to.not.throw;
50+
expect(() => execSync(TestUtils.getShellCommand('which git'))).to.not.throw;
51+
// wget and vim should be removed
52+
expect(() => execSync(TestUtils.getShellCommand('which wget'))).to.throw;
53+
expect(() => execSync(TestUtils.getShellCommand('which vim'))).to.throw;
54+
}
55+
},
56+
validateDestroy: () => {
57+
// dnf should still exist as it's a core system component
58+
expect(() => execSync(TestUtils.getShellCommand('which dnf'))).to.not.throw;
59+
}
60+
});
61+
});
62+
63+
it('Can install packages with specific versions', { timeout: 300000, skip: true }, async () => {
64+
if (!TestUtils.isLinux()) {
65+
console.log('Skipping dnf test - not running on Linux');
66+
return;
67+
}
68+
69+
// Check if dnf is available
70+
try {
71+
execSync('which dnf');
72+
} catch {
73+
console.log('Skipping dnf test - dnf not available on this system');
74+
return;
75+
}
76+
77+
// Get available version of a package
78+
const availableVersions = execSync('dnf list available curl | tail -1 | awk \'{print $2}\'').toString().trim();
79+
80+
await PluginTester.fullTest(pluginPath, [{
81+
type: 'dnf',
82+
install: [
83+
{ name: 'curl', version: availableVersions }
84+
]
85+
}], {
86+
skipUninstall: true,
87+
validateApply: () => {
88+
expect(() => execSync(TestUtils.getShellCommand('which curl'))).to.not.throw;
89+
const installedVersion = execSync('rpm -q --queryformat \'%{VERSION}-%{RELEASE}\' curl').toString().trim();
90+
expect(installedVersion).toBe(availableVersions);
91+
},
92+
});
93+
});
94+
95+
it('Can skip dnf check-update when update is false', { timeout: 300000 }, async () => {
96+
if (!TestUtils.isLinux()) {
97+
console.log('Skipping dnf test - not running on Linux');
98+
return;
99+
}
100+
101+
// Check if dnf is available
102+
try {
103+
execSync('which dnf');
104+
} catch {
105+
console.log('Skipping dnf test - dnf not available on this system');
106+
return;
107+
}
108+
109+
await PluginTester.fullTest(pluginPath, [{
110+
type: 'dnf',
111+
install: ['curl'],
112+
update: false
113+
}], {
114+
skipUninstall: true,
115+
validateApply: () => {
116+
expect(() => execSync(TestUtils.getShellCommand('which curl'))).to.not.throw;
117+
},
118+
});
119+
});
120+
});

0 commit comments

Comments
 (0)