Skip to content

Commit 0ac06d9

Browse files
committed
Added snap
1 parent cb41203 commit 0ac06d9

File tree

6 files changed

+372
-1
lines changed

6 files changed

+372
-1
lines changed

.cirrus.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ integration_individual_test_task:
4848

4949
integration_individual_test_linux_task:
5050
arm_container:
51-
image: kevinwang5658/codify-test-linux-centos:latest
51+
image: kevinwang5658/codify-test-linux:latest
5252
# node_modules_cache:
5353
# folder: node_modules
5454
# fingerprint_script: cat package-lock.json

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ 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';
4141
import { DnfResource } from './resources/dnf/dnf.js';
42+
import { SnapResource } from './resources/snap/snap.js';
4243

4344
runPlugin(Plugin.create(
4445
'default',
@@ -82,5 +83,6 @@ runPlugin(Plugin.create(
8283
new AptResource(),
8384
new YumResource(),
8485
new DnfResource(),
86+
new SnapResource(),
8587
])
8688
)
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { ParameterSetting, Plan, StatefulParameter, getPty } from 'codify-plugin-lib';
2+
3+
import { SnapConfig } from './snap.js';
4+
5+
export interface SnapPackage {
6+
name: string;
7+
channel?: string;
8+
classic?: boolean;
9+
}
10+
11+
export class SnapInstallParameter extends StatefulParameter<SnapConfig, Array<SnapPackage | string>> {
12+
13+
getSettings(): ParameterSetting {
14+
return {
15+
type: 'array',
16+
filterInStatelessMode: (desired, current) =>
17+
current.filter((c) => desired.some((d) => this.isSamePackage(d, c))),
18+
isElementEqual: this.isEqual,
19+
}
20+
}
21+
22+
async refresh(desired: Array<SnapPackage | string> | null, _config: Partial<SnapConfig>): Promise<Array<SnapPackage | string> | null> {
23+
const $ = getPty()
24+
const { data: installed } = await $.spawnSafe('snap list --unicode=never');
25+
26+
if (!installed || installed === '') {
27+
return null;
28+
}
29+
30+
const r = installed.split(/\n/)
31+
.filter(Boolean)
32+
.slice(1) // Skip header line
33+
.map((l) => {
34+
const parts = l.split(/\s+/).filter(Boolean);
35+
const name = parts[0];
36+
const channel = parts[3]; // Channel is the 4th column
37+
38+
return { name, channel }
39+
})
40+
.filter((pkg) =>
41+
// Only return packages that are in the desired list
42+
desired?.some((d) => {
43+
if (typeof d === 'string') {
44+
return d === pkg.name;
45+
}
46+
47+
return d.name === pkg.name;
48+
})
49+
)
50+
.map((installed) => {
51+
if (desired?.find((d) => typeof d === 'string' && d === installed.name)) {
52+
return installed.name;
53+
}
54+
55+
const desiredPkg = desired?.find((d) => typeof d === 'object' && d.name === installed.name);
56+
if (desiredPkg && typeof desiredPkg === 'object' && !desiredPkg.channel && !desiredPkg.classic) {
57+
return { name: installed.name }
58+
}
59+
60+
return installed;
61+
})
62+
63+
return r.length > 0 ? r : null;
64+
}
65+
66+
async add(valueToAdd: Array<SnapPackage | string>, _plan: Plan<SnapConfig>): Promise<void> {
67+
await this.install(valueToAdd);
68+
}
69+
70+
async modify(newValue: (SnapPackage | string)[], previousValue: (SnapPackage | string)[], _plan: Plan<SnapConfig>): Promise<void> {
71+
const valuesToAdd = newValue.filter((n) => !previousValue.some((p) => this.isSamePackage(n, p)));
72+
const valuesToRemove = previousValue.filter((p) => !newValue.some((n) => this.isSamePackage(n, p)));
73+
74+
await this.uninstall(valuesToRemove);
75+
await this.install(valuesToAdd);
76+
}
77+
78+
async remove(valueToRemove: (SnapPackage | string)[], _plan: Plan<SnapConfig>): Promise<void> {
79+
await this.uninstall(valueToRemove);
80+
}
81+
82+
private async install(packages: Array<SnapPackage | string>): Promise<void> {
83+
if (!packages || packages.length === 0) {
84+
return;
85+
}
86+
87+
const $ = getPty();
88+
89+
// Install packages one by one since snap doesn't support batch installation
90+
for (const p of packages) {
91+
let command = 'snap install';
92+
93+
if (typeof p === 'string') {
94+
command += ` ${p}`;
95+
} else {
96+
command += ` ${p.name}`;
97+
98+
if (p.channel) {
99+
command += ` --channel=${p.channel}`;
100+
}
101+
102+
if (p.classic) {
103+
command += ' --classic';
104+
}
105+
}
106+
107+
await $.spawn(command, { requiresRoot: true, interactive: true });
108+
}
109+
}
110+
111+
private async uninstall(packages: Array<SnapPackage | string>): Promise<void> {
112+
if (!packages || packages.length === 0) {
113+
return;
114+
}
115+
116+
const $ = getPty();
117+
118+
// Uninstall packages one by one
119+
for (const p of packages) {
120+
const name = typeof p === 'string' ? p : p.name;
121+
await $.spawn(`snap remove ${name}`, { requiresRoot: true, interactive: true });
122+
}
123+
}
124+
125+
isSamePackage(a: SnapPackage | string, b: SnapPackage | string): boolean {
126+
if (typeof a === 'string' || typeof b === 'string') {
127+
if (typeof a === 'string' && typeof b === 'string') {
128+
return a === b;
129+
}
130+
131+
if (typeof a === 'string' && typeof b === 'object') {
132+
return a === b.name;
133+
}
134+
135+
if (typeof a === 'object' && typeof b === 'string') {
136+
return a.name === b;
137+
}
138+
}
139+
140+
if (typeof a === 'object' && typeof b === 'object') {
141+
return a.name === b.name;
142+
}
143+
144+
return false;
145+
}
146+
147+
isEqual(desired: SnapPackage | string, current: SnapPackage | string): boolean {
148+
if (typeof desired === 'string' || typeof current === 'string') {
149+
if (typeof desired === 'string' && typeof current === 'string') {
150+
return desired === current;
151+
}
152+
153+
if (typeof desired === 'string' && typeof current === 'object') {
154+
return desired === current.name;
155+
}
156+
157+
if (typeof desired === 'object' && typeof current === 'string') {
158+
return desired.name === current;
159+
}
160+
}
161+
162+
if (typeof desired === 'object' && typeof current === 'object') {
163+
// For snap, we only check name equality since channel can change
164+
// and classic is an installation flag, not a state
165+
return desired.name === current.name;
166+
}
167+
168+
return false;
169+
}
170+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "https://www.codifycli.com/snap.json",
4+
"$comment": "https://docs.codifycli.com/core-resources/snap/",
5+
"title": "Snap resource",
6+
"description": "Manage snap packages on Linux systems.",
7+
"type": "object",
8+
"properties": {
9+
"install": {
10+
"type": "array",
11+
"description": "Installs packages using snap.",
12+
"items": {
13+
"oneOf": [
14+
{ "type": "string" },
15+
{
16+
"type": "object",
17+
"properties": {
18+
"name": { "type": "string" },
19+
"channel": {
20+
"type": "string",
21+
"description": "The channel to install from (e.g., stable, edge, beta, candidate)"
22+
},
23+
"classic": {
24+
"type": "boolean",
25+
"description": "Install the snap in classic mode (with full system access)"
26+
}
27+
},
28+
"required": ["name"]
29+
}
30+
]
31+
}
32+
}
33+
},
34+
"additionalProperties": false
35+
}

src/resources/snap/snap.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { CreatePlan, Resource, ResourceSettings, SpawnStatus, getPty } from 'codify-plugin-lib';
2+
import { OS, ResourceConfig } from 'codify-schemas';
3+
4+
import { SnapInstallParameter, SnapPackage } from './install-parameter.js';
5+
import schema from './snap-schema.json';
6+
7+
export interface SnapConfig extends ResourceConfig {
8+
install: Array<SnapPackage | string>;
9+
}
10+
11+
export class SnapResource extends Resource<SnapConfig> {
12+
13+
override getSettings(): ResourceSettings<SnapConfig> {
14+
return {
15+
id: 'snap',
16+
operatingSystems: [OS.Linux],
17+
schema,
18+
parameterSettings: {
19+
install: { type: 'stateful', definition: new SnapInstallParameter() }
20+
}
21+
};
22+
}
23+
24+
override async refresh(parameters: Partial<SnapConfig>): Promise<Partial<SnapConfig> | null> {
25+
const $ = getPty();
26+
27+
const snapCheck = await $.spawnSafe('which snap');
28+
if (snapCheck.status === SpawnStatus.ERROR) {
29+
return null;
30+
}
31+
32+
return parameters;
33+
}
34+
35+
override async create(_plan: CreatePlan<SnapConfig>): Promise<void> {
36+
const $ = getPty();
37+
await $.spawn('apt update', { requiresRoot: true })
38+
await $.spawn('apt install -y snapd', { requiresRoot: true })
39+
}
40+
41+
override async destroy(): Promise<void> {
42+
// snap is a core system component and should not be removed
43+
const $ = getPty();
44+
await $.spawn('apt uninstall snapd', { requiresRoot: true })
45+
}
46+
}

test/snap/snap.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
// Currently need to figure out a way to test snap. It requires system ctl
8+
describe('Snap resource integration tests', { skip: true }, () => {
9+
const pluginPath = path.resolve('./src/index.ts');
10+
11+
it('Can install and uninstall snap packages', { timeout: 300000 }, async () => {
12+
if (!TestUtils.isLinux()) {
13+
console.log('Skipping snap test - not running on Linux');
14+
return;
15+
}
16+
17+
// Check if snap is available
18+
// try {
19+
// execSync('which snap');
20+
// } catch {
21+
// console.log('Skipping snap test - snap not available on this system');
22+
// return;
23+
// }
24+
25+
// Plans correctly and detects that snap is available
26+
await PluginTester.fullTest(pluginPath, [{
27+
type: 'snap',
28+
install: [
29+
'hello-world',
30+
'curl',
31+
]
32+
}], {
33+
skipUninstall: true,
34+
validateApply: () => {
35+
const snapList = execSync('snap list').toString();
36+
expect(snapList).toContain('hello-world');
37+
expect(snapList).toContain('curl');
38+
expect(() => execSync(TestUtils.getShellCommand('which snap'))).to.not.throw;
39+
},
40+
testModify: {
41+
modifiedConfigs: [{
42+
type: 'snap',
43+
install: [
44+
'hello-world',
45+
'jq',
46+
],
47+
}],
48+
validateModify: () => {
49+
const snapList = execSync('snap list').toString();
50+
expect(snapList).toContain('hello-world');
51+
expect(snapList).toContain('jq');
52+
// curl should be removed
53+
expect(snapList).not.toContain('curl');
54+
}
55+
},
56+
validateDestroy: () => {
57+
// snap should still exist as it's a core system component
58+
expect(() => execSync(TestUtils.getShellCommand('which snap'))).to.not.throw;
59+
}
60+
});
61+
});
62+
63+
it('Can install packages with specific channels', { timeout: 300000 }, async () => {
64+
if (!TestUtils.isLinux()) {
65+
console.log('Skipping snap test - not running on Linux');
66+
return;
67+
}
68+
69+
// Check if snap is available
70+
// try {
71+
// execSync('which snap');
72+
// } catch {
73+
// console.log('Skipping snap test - snap not available on this system');
74+
// return;
75+
// }
76+
77+
await PluginTester.fullTest(pluginPath, [{
78+
type: 'snap',
79+
install: [
80+
{ name: 'hello-world', channel: 'stable' }
81+
]
82+
}], {
83+
skipUninstall: true,
84+
validateApply: () => {
85+
const snapList = execSync('snap list').toString();
86+
expect(snapList).toContain('hello-world');
87+
},
88+
});
89+
});
90+
91+
it('Can install classic snaps', { timeout: 300000 }, async () => {
92+
if (!TestUtils.isLinux()) {
93+
console.log('Skipping snap test - not running on Linux');
94+
return;
95+
}
96+
97+
// Check if snap is available
98+
// try {
99+
// execSync('which snap');
100+
// } catch {
101+
// console.log('Skipping snap test - snap not available on this system');
102+
// return;
103+
// }
104+
105+
await PluginTester.fullTest(pluginPath, [{
106+
type: 'snap',
107+
install: [
108+
{ name: 'code', classic: true }
109+
]
110+
}], {
111+
skipUninstall: true,
112+
validateApply: () => {
113+
const snapList = execSync('snap list').toString();
114+
expect(snapList).toContain('code');
115+
},
116+
});
117+
});
118+
});

0 commit comments

Comments
 (0)