Skip to content

Commit fe13c96

Browse files
committed
feat: working devstat reporting for bin size via cli
Signed-off-by: Sam Gammon <[email protected]>
1 parent 50f8e91 commit fe13c96

File tree

19 files changed

+469
-90
lines changed

19 files changed

+469
-90
lines changed

pnpm-lock.yaml

Lines changed: 58 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/client/actions/binstats.mjs

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 214 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,216 @@
1-
import { Command } from "commander";
1+
import {
2+
type Architecture,
3+
type BinstatInfo,
4+
type OperatingSystem,
5+
type LibCTarget,
6+
binstatService as api,
7+
modelVersion,
8+
BinstatInfoRecord,
9+
} from "../../commons/api.mjs";
210

3-
export default async function binstats(path: string, options: any) {
4-
console.log("Generating binstats...");
11+
import { existsSync } from "node:fs";
12+
import syncFs from "node:fs";
13+
import fs from "node:fs/promises";
14+
import crypto from "node:crypto";
15+
import { simpleGit, type SimpleGit } from "simple-git";
16+
import byteSize from "byte-size";
17+
import pkg from "../package.json" with { type: "json" };
18+
19+
export type BinstatOptions = {
20+
debug?: boolean;
21+
dry?: boolean;
22+
revision?: string;
23+
branch?: string;
24+
tag?: string;
25+
os?: string;
26+
arch?: string;
27+
libc?: string;
28+
};
29+
30+
async function generateSourceControlState(
31+
git: SimpleGit,
32+
options: BinstatOptions,
33+
): Promise<{
34+
revision: string;
35+
branch?: string;
36+
tag?: string;
37+
}> {
38+
const isGitRepo = await git.checkIsRepo();
39+
const revision =
40+
options.revision || (isGitRepo ? await git.revparse("HEAD") : undefined);
41+
if (!revision) {
42+
throw new Error(
43+
"Git revision is required but could not be determined, and was not provided via `--revision`",
44+
);
45+
}
46+
const branch =
47+
options.branch ||
48+
(isGitRepo ? await git.revparse(["--abbrev-ref", "HEAD"]) : undefined);
49+
const tag = options.tag || undefined;
50+
return { revision, branch, tag };
51+
}
52+
53+
async function calculateSha256Digest(path: string): Promise<string> {
54+
return new Promise((resolve, reject) => {
55+
const hash = crypto.createHash("sha256");
56+
const stream = syncFs.createReadStream(path);
57+
stream.on("data", (data) => hash.update(data));
58+
stream.on("end", () => resolve(hash.digest("hex")));
59+
stream.on("error", (err) => reject(err));
60+
});
61+
}
62+
63+
async function resolveBinaryOrHostInfo(
64+
path: string,
65+
options: BinstatOptions,
66+
): Promise<{
67+
os: OperatingSystem;
68+
arch: Architecture;
69+
libc?: LibCTarget;
70+
}> {
71+
let os: OperatingSystem;
72+
let arch: Architecture;
73+
let libc: LibCTarget | undefined;
74+
75+
if (options.os) {
76+
os = options.os as OperatingSystem;
77+
if (!["linux", "macos", "windows"].includes(os)) {
78+
throw new Error(`Invalid OS specified: ${os}`);
79+
}
80+
} else {
81+
// resolve from host
82+
switch (process.platform) {
83+
case "linux":
84+
os = "linux";
85+
break;
86+
case "darwin":
87+
os = "macos";
88+
break;
89+
case "win32":
90+
os = "windows";
91+
break;
92+
default:
93+
throw new Error(`Unsupported host platform: ${process.platform}`);
94+
}
95+
}
96+
if (options.arch) {
97+
arch = options.arch as Architecture;
98+
if (!["amd64", "arm64"].includes(arch)) {
99+
throw new Error(`Invalid architecture specified: ${arch}`);
100+
}
101+
} else {
102+
// resolve from host
103+
switch (process.arch) {
104+
case "x64":
105+
arch = "amd64";
106+
break;
107+
case "arm64":
108+
arch = "arm64";
109+
break;
110+
default:
111+
throw new Error(`Unsupported host architecture: ${process.arch}`);
112+
}
113+
}
114+
if (os === "linux") {
115+
if (options.libc) {
116+
libc = options.libc as LibCTarget;
117+
if (!["glibc", "musl"].includes(libc)) {
118+
throw new Error(`Invalid libc specified: ${libc}`);
119+
}
120+
} else {
121+
// default to glibc
122+
libc = "glibc";
123+
}
124+
}
125+
return { os, arch, libc };
126+
}
127+
128+
export default async function binstats(
129+
name: string,
130+
path: string,
131+
options: BinstatOptions,
132+
) {
133+
const token = process.env["ELIDE_DEVOPS_API_TOKEN"];
134+
if (!token) {
135+
throw new Error(
136+
"ELIDE_DEVOPS_API_TOKEN environment variable is not set; cannot report binstats",
137+
);
138+
}
139+
const git: SimpleGit = simpleGit({});
140+
141+
console.info("Generating binstats...");
142+
143+
if (!name) {
144+
throw new Error("Binary name is required");
145+
}
146+
if (!existsSync(path)) {
147+
throw new Error(`Binary not found at path: ${path}`);
148+
}
149+
// must be a file, must be readable
150+
const stat = await fs.stat(path);
151+
152+
if (!stat.isFile()) {
153+
throw new Error(`Path is not a file: ${path}`);
154+
}
155+
156+
const infos = Promise.all([
157+
generateSourceControlState(git, options),
158+
resolveBinaryOrHostInfo(path, options),
159+
calculateSha256Digest(path),
160+
]);
161+
162+
const [gitState, targetingInfo, digest] = await infos;
163+
164+
const binstats: BinstatInfo = {
165+
name,
166+
size: stat.size,
167+
...gitState,
168+
...targetingInfo,
169+
sha256: digest,
170+
timestamp: +new Date(),
171+
};
172+
if (options.debug) {
173+
console.debug("Generated binstats:", JSON.stringify(binstats, null, 2));
174+
}
175+
try {
176+
BinstatInfoRecord.parse(binstats);
177+
} catch (err) {
178+
throw new Error(`Generated binstat info is invalid: ${err}`);
179+
}
180+
181+
const headers = new Headers();
182+
headers.set("content-type", "application/json");
183+
headers.set("authorization", `Bearer ${token}`);
184+
headers.set("x-api-version", modelVersion);
185+
headers.set("user-agent", `elide-devops-client/${pkg.version}`);
186+
187+
const req: Request = new Request(
188+
new URL(
189+
`https://${api.endpoint}/${api.name}/${api.version}/${api.methods.binstat}`,
190+
),
191+
{
192+
method: "POST",
193+
headers,
194+
body: JSON.stringify(binstats),
195+
},
196+
);
197+
198+
const fmt = byteSize(stat.size);
199+
if (!options.dry) {
200+
console.log("Reporting stats...");
201+
const start = +new Date();
202+
try {
203+
const resp: Response = await fetch(req);
204+
const duration = +new Date() - start;
205+
if (!resp.ok) {
206+
throw new Error("Non-OK HTTP response code: " + resp.status);
207+
}
208+
console.info(`Binstats reported in ${duration}ms (${fmt})`);
209+
return;
210+
} catch (err) {
211+
throw new Error(`Failed to report binstats: ${err}`);
212+
}
213+
} else {
214+
console.info(`Dry run enabled; binstats not reported (${fmt}).`);
215+
}
5216
}

services/client/actions/report-upload.mjs

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Command } from "commander";
22

33
export default async function reportUpload(context: Command) {
4+
const token = process.env["ELIDE_DEVOPS_API_TOKEN"];
5+
if (!token) {
6+
throw new Error(
7+
"ELIDE_DEVOPS_API_TOKEN environment variable is not set; cannot report binstats",
8+
);
9+
}
410
console.log("Report upload...");
511
}

0 commit comments

Comments
 (0)