Repository navigation
Expand file tree
/
Copy pathshared-store.test.ts
More file actions
196 lines (183 loc) · 8.02 KB
/
Copy pathshared-store.test.ts
File metadata and controls
196 lines (183 loc) · 8.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
KEY_ID,
personalScenario,
scan,
target,
} from "../helpers/lifecycle.js";
import { trapProxy } from "../helpers/trap-proxy.js";
// The shared file store (docs/security.md, "Shared file store") on the
// installed branded command, with real release archives: an install, an
// update, and a rollback that place their runtime and dependency files from a
// store, and the guarantees the store makes about itself.
//
// - A machine with an empty store installs and launches exactly as one with a
// full store, and with no store at all. The release archive carries no
// store reference, and neither the installed tree nor the install state
// names the store, so removing it changes nothing about what runs.
// - Install, update, and launch contact nothing: the scenario's update host
// sees no request until `update` is asked for, and a proxy that would
// refuse every outbound request sees none. This is what PiShip does, not a
// network namespace; "offline" here means that no step needs the network.
// - `doctor` is the only collector. With every store file older than the grace
// period, it still keeps what the active release and the rollback target
// pinned, and the installed releases keep launching.
//
// The stand-in for a clean machine is an empty temporary home, state, install
// home, and store, on the runner; it is not a fresh operating system image.
/** Every file under `directory`. */
function files(directory: string): string[] {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? files(join(directory, entry.name))
: [join(directory, entry.name)],
);
}
/** The store's file objects. */
const objects = (store: string) => files(join(store, "objects"));
/** The references that name the releases that placed objects. */
const references = (store: string) =>
files(join(store, "refs")).map((path) =>
path
.slice(join(store, "refs").length + 1)
.split("\\")
.join("/"),
);
/** Make every file of the store older than the 24-hour grace period. */
function age(store: string): void {
const old = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
for (const path of files(store)) utimesSync(path, old, old);
}
describe("shared file store on the installed command (local fixtures)", () => {
it("installs, updates, rolls back, and diagnoses with a store, and keeps working with none", async () => {
const s = await personalScenario("shared-store");
const proxy = await trapProxy();
const store = join(s.temp, "store");
// An empty store directory, as on a machine that never had one.
mkdirSync(store);
Object.assign(s.env, {
PISHIP_STORE: "copy",
PISHIP_STORE_HOME: store,
HTTP_PROXY: proxy.url,
HTTPS_PROXY: proxy.url,
http_proxy: proxy.url,
https_proxy: proxy.url,
NO_PROXY: "127.0.0.1,localhost,::1",
no_proxy: "127.0.0.1,localhost,::1",
});
// Install from the empty store: the script shipped in the release fills it.
await s.installFirst();
expect(objects(store).length).toBeGreaterThan(0);
expect(references(store)).toEqual(["mypi/1.0.0.json"]);
// Nothing is left in flight, and no temporary remains.
expect(files(join(store, "inflight"))).toEqual([]);
expect(files(join(store, "tmp"))).toEqual([]);
// The installation is whole and names no store: the receipts, the
// launcher, the command, and every installed file are free of its path.
expect(scan(s.install, [store])).toEqual([]);
expect(scan(join(s.temp, "bin"), [store])).toEqual([]);
const smoke = await s.run(["--smoke"]);
expect(smoke.status, smoke.stderr).toBe(0);
const doctor = await s.run(["doctor"]);
expect(doctor.status, doctor.stdout + doctor.stderr).toBe(0);
expect(doctor.stdout).toMatch(/Release\n {2}✓ release\s+verified/);
expect(doctor.stderr).not.toMatch(/damaged/);
// Install and launch contacted no host: the update host is asked only by `update`.
expect(s.hostRequests).toEqual([]);
expect(proxy.hits).toEqual([]);
// Update and rollback place from the same store and pin both releases.
s.publish(1);
const updated = await s.run(["update"]);
expect(updated.status, updated.stderr).toBe(0);
expect(updated.stdout).toContain(
`Updated MyPi 1.0.0 -> 1.1.0 (stable, signed by ${KEY_ID})`,
);
expect(references(store).sort()).toEqual([
"mypi/1.0.0.json",
"mypi/1.1.0.json",
]);
const objectsAfterUpdate = objects(store).length;
expect(files(join(store, "inflight"))).toEqual([]);
// Everything is past the grace period, and only the pinning keeps it. A
// control proves the collection runs and removes what no release pinned:
// an object nothing names goes, and every object a release placed stays.
const planted = createHash("sha256")
.update("pinned by nothing")
.digest("hex");
mkdirSync(join(store, "objects", planted.slice(0, 2)), { recursive: true });
writeFileSync(
join(store, "objects", planted.slice(0, 2), planted),
"pinned by nothing",
);
age(store);
const aged = await s.run(["doctor"]);
expect(aged.status, aged.stdout + aged.stderr).toBe(0);
expect(aged.stderr).toMatch(/Removed 1 file store object\b/);
expect(
existsSync(join(store, "objects", planted.slice(0, 2), planted)),
).toBe(false);
expect(objects(store)).toHaveLength(objectsAfterUpdate);
expect(references(store).sort()).toEqual([
"mypi/1.0.0.json",
"mypi/1.1.0.json",
]);
const rolledBack = await s.run(["rollback"]);
expect(rolledBack.status, rolledBack.stderr).toBe(0);
expect(rolledBack.stdout).toContain("Rolled back MyPi 1.1.0 -> 1.0.0");
age(store);
const afterRollback = await s.run(["doctor"]);
expect(afterRollback.status).toBe(0);
expect(afterRollback.stderr).not.toMatch(/Removed \d+ file store object/);
expect(objects(store)).toHaveLength(objectsAfterUpdate);
expect((await s.run(["--smoke"])).status).toBe(0);
expect((await s.run(["version"])).stdout).toContain("MyPi 1.0.0");
// Only the update host's own files were requested, and only by `update`.
const channelFiles = new Set([
"root/2.json",
"stable.json",
"stable.json.sig",
`mypi-1.1.0-${target}.tar.gz`,
]);
expect(s.hostRequests.length).toBeGreaterThan(0);
expect(s.hostRequests.filter((path) => !channelFiles.has(path))).toEqual(
[],
);
expect(proxy.hits).toEqual([]);
// The store goes away entirely: what is installed neither needs it nor
// makes a new one, and the diagnostics have nothing to say about it.
rmSync(store, { recursive: true, force: true });
const without = await s.run(["--smoke"]);
expect(without.status, without.stderr).toBe(0);
const diagnosed = await s.run(["doctor"]);
expect(diagnosed.status, diagnosed.stdout + diagnosed.stderr).toBe(0);
expect(diagnosed.stdout).toMatch(/Release\n {2}✓ release\s+verified/);
expect(existsSync(store)).toBe(false);
const uninstall = s.cli("uninstall", "mypi");
expect(uninstall.status, uninstall.stderr).toBe(0);
}, 900000);
it("installs and launches when the store cannot be used at all", async () => {
const s = await personalScenario("shared-store-unusable");
// A file where the store's directory would be: it cannot be created.
const store = join(s.temp, "store");
writeFileSync(store, "not a directory");
Object.assign(s.env, { PISHIP_STORE: "copy", PISHIP_STORE_HOME: store });
await s.installFirst();
const smoke = await s.run(["--smoke"]);
expect(smoke.status, smoke.stderr).toBe(0);
const doctor = await s.run(["doctor"]);
expect(doctor.status, doctor.stdout + doctor.stderr).toBe(0);
expect(statSync(store).isFile()).toBe(true);
}, 900000);
});