Skip to content

Commit 5e741ac

Browse files
committed
feat(packages): Add cachix-deploy-metrics package
1 parent de62e23 commit 5e741ac

File tree

6 files changed

+387
-0
lines changed

6 files changed

+387
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
lib,
3+
buildDubPackage,
4+
pkgs,
5+
...
6+
}:
7+
buildDubPackage rec {
8+
pname = "cachix-deploy-metrics";
9+
version = "unstable";
10+
src = lib.fileset.toSource {
11+
root = ./.;
12+
fileset = lib.fileset.fileFilter (
13+
file:
14+
builtins.any file.hasExt [
15+
"d"
16+
"sdl"
17+
"json"
18+
]
19+
) ./.;
20+
};
21+
dubLock = ./dub.lock.json; # Auto generated with `dub-to-nix`.
22+
dubBuildFlags = [ ];
23+
24+
installPhase = ''
25+
runHook preInstall
26+
mkdir -p $out/bin
27+
install -m755 ./build/${pname} $out/bin/${pname}
28+
runHook postInstall
29+
'';
30+
31+
nativeBuildInputs = [
32+
pkgs.pkg-config
33+
pkgs.makeWrapper
34+
];
35+
36+
buildInputs = [
37+
pkgs.curl
38+
];
39+
40+
postFixup = ''
41+
wrapProgram $out/bin/${pname}
42+
'';
43+
44+
meta.mainProgram = pname;
45+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"dependencies": {
3+
"argparse": {
4+
"version": "1.4.1",
5+
"sha256": "1hwpc9y9jqcw849g77pxbz6rvvpslgaxwid1rxpaz4nyhi93fq4g"
6+
},
7+
"diet-ng": {
8+
"version": "1.8.4",
9+
"sha256": "03c3pmc9w0s3yf1xipmiw5jiy3aifk6bby3z015vvn80s59mk18c"
10+
},
11+
"during": {
12+
"version": "0.3.0",
13+
"sha256": "1xkfwhvnr67k0vy664sadj0hjifbs0257cbfjw86jp7rvjkn0myi"
14+
},
15+
"eventcore": {
16+
"version": "0.9.37",
17+
"sha256": "17fs8lsm1l3nf26k9a21jr7chmzs5ryl926nppzh6a1i2k4jmia4"
18+
},
19+
"mir-linux-kernel": {
20+
"version": "1.2.1",
21+
"sha256": "12i0sa7dnh70vd22cyhymwn0837bxdjcrs3hilnn5cpfxamaqavq"
22+
},
23+
"openssl": {
24+
"version": "3.4.0",
25+
"sha256": "0y71p03v05v797bkk0b3sbdkncqb5ch3a48nxaly6dxfdz4w247m"
26+
},
27+
"openssl-static": {
28+
"version": "1.0.5+3.0.8",
29+
"sha256": "0wpqz29yrbbh39g3cwlgd6h6hh1msws7w5baw1kywdkgj761gx2k"
30+
},
31+
"prometheus2": {
32+
"version": "1.1.0",
33+
"sha256": "0i3r4i5dyfxxlayk1gzbf0w28mpysnwg33khxndp7wwh3vadwyis"
34+
},
35+
"silly": {
36+
"version": "1.1.1",
37+
"sha256": "0fz7ib715sfk3w69i6xns5pwd4caahvfqjf32v13daxm1ms8xdzz"
38+
},
39+
"stdx-allocator": {
40+
"version": "2.77.5",
41+
"sha256": "1g8382wr49sjyar0jay8j7y2if7h1i87dhapkgxphnizp24d7kaj"
42+
},
43+
"taggedalgebraic": {
44+
"version": "1.0.1",
45+
"sha256": "1xwczhidhb4d6lw342fwx8da4vdwryv9gq8p41bh7184300aafl9"
46+
},
47+
"vibe-container": {
48+
"version": "1.7.0",
49+
"sha256": "04m3yvrhjq231m71yvk0wc4n2y4bwpd7nvivnbvc65sqjxipqpgf"
50+
},
51+
"vibe-core": {
52+
"version": "2.13.1",
53+
"sha256": "0fcl1n9lsw2gg5flbjv9wimb18jxby40iip2zdzh1zksis2rg9b5"
54+
},
55+
"vibe-d": {
56+
"version": "0.10.2",
57+
"sha256": "1vb2alzvvlmn4gjycsgcjjj57gv2mh5faci57qvkxhs73x818rc8"
58+
},
59+
"vibe-http": {
60+
"version": "1.3.1",
61+
"sha256": "0w01qw3ajnwij3pflv77brik0gf5qhla0wkcq8gcj79hj7gkf49c"
62+
},
63+
"vibe-inet": {
64+
"version": "1.2.0",
65+
"sha256": "11qdm5qyv95flfrdm32xscjsx0khhpin7jvrk9v43hsvyy8lzrvf"
66+
},
67+
"vibe-serialization": {
68+
"version": "1.1.2",
69+
"sha256": "1mlqd7z5b3qdr7crj7ci76whqrnz2bahndp3fxn5k8zsd1yl294x"
70+
},
71+
"vibe-stream": {
72+
"version": "1.3.0",
73+
"sha256": "0q8bxi07z46dp7fk1xdfxmcq9n10jfxhq3zbd5p44w0681qgzf7g"
74+
}
75+
}
76+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
name "cachix-deploy-metrics"
2+
3+
targetType "executable"
4+
targetPath "build"
5+
6+
sourceFiles "main.d"
7+
8+
dependency "prometheus2" version="~>1.1.0"
9+
dependency "vibe-d" version="~>0.10.2"
10+
dependency "argparse" version=">=1.3.0 <2.0.0"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"fileVersion": 1,
3+
"versions": {
4+
"argparse": "1.4.1",
5+
"diet-ng": "1.8.4",
6+
"during": "0.3.0",
7+
"eventcore": "0.9.37",
8+
"mir-linux-kernel": "1.2.1",
9+
"openssl": "3.4.0",
10+
"openssl-static": "1.0.5+3.0.8",
11+
"prometheus2": "1.1.0",
12+
"silly": "1.1.1",
13+
"stdx-allocator": "2.77.5",
14+
"taggedalgebraic": "1.0.1",
15+
"vibe-container": "1.7.0",
16+
"vibe-core": "2.13.1",
17+
"vibe-d": "0.10.2",
18+
"vibe-http": "1.3.1",
19+
"vibe-inet": "1.2.0",
20+
"vibe-serialization": "1.1.2",
21+
"vibe-stream": "1.3.0"
22+
}
23+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import prometheus.registry : Registry;
2+
import prometheus.gauge : Gauge;
3+
4+
import core.thread : Thread;
5+
import core.time : dur;
6+
import std.conv : to;
7+
import std.process : environment;
8+
import std.exception : ErrnoException;
9+
import std.file : readText;
10+
import std.format : format;
11+
import argparse; // Andrey Zherikov's argparse (UDA-based)
12+
import std.json : JSONType, JSONValue, parseJSON;
13+
import std.logger : LogLevel, logf;
14+
import std.string : strip;
15+
import std.datetime : SysTime, Clock;
16+
import vibe.core.args : setCommandLineArgs;
17+
import vibe.d: HTTPServerSettings, URLRouter, listenHTTP, runApplication, HTTPServerRequest, HTTPServerResponse;
18+
import std.net.curl : HTTP, get;
19+
20+
const string[] CACHIX_DEPLOY_STATES = ["Pending", "InProgress", "Cancelled", "Failed", "Succeeded"];
21+
22+
__gshared Gauge statusGauge;
23+
__gshared Gauge indexGauge;
24+
__gshared Gauge startedGauge;
25+
__gshared Gauge finishedGauge;
26+
__gshared Gauge inProgressDurationGauge;
27+
__gshared string gWorkspace;
28+
const string[] FINISHED_KEYS = ["endedOn", "finishedOn", "completedOn"];
29+
30+
void promInit() {
31+
statusGauge = new Gauge("cachix_deploy_status", "Status of the last deploy", ["workspace", "agent", "status"]);
32+
statusGauge.register;
33+
indexGauge = new Gauge("cachix_deploy_counter", "Counter/index of deploys.", ["workspace", "agent"]);
34+
indexGauge.register;
35+
startedGauge = new Gauge("cachix_deploy_last_started_time", "Unix time when the last deploy started.", ["workspace", "agent"]);
36+
startedGauge.register;
37+
finishedGauge = new Gauge("cachix_deploy_last_finished_time", "Unix time when the last deploy finished (if any).", ["workspace", "agent"]);
38+
finishedGauge.register;
39+
inProgressDurationGauge = new Gauge("cachix_deploy_in_progress_duration_seconds", "Seconds elapsed for the current in-progress deploy.", ["workspace", "agent"]);
40+
inProgressDurationGauge.register;
41+
}
42+
43+
void promSetStatus(string agentName, string status, long indexVal) {
44+
auto ws = gWorkspace;
45+
foreach (s; CACHIX_DEPLOY_STATES) {
46+
statusGauge.set(s == status ? 1.0 : 0.0, [ws, agentName, s]);
47+
}
48+
if (indexVal != long.min) {
49+
indexGauge.set(cast(double) indexVal, [ws, agentName]);
50+
}
51+
}
52+
53+
JSONValue httpGetJson(string url, string authToken) {
54+
logf(LogLevel.trace, "GET %s", url);
55+
auto conn = HTTP();
56+
conn.connectTimeout = dur!"seconds"(10);
57+
conn.operationTimeout = dur!"seconds"(20);
58+
conn.addRequestHeader("Authorization", "Bearer " ~ authToken);
59+
auto bodyArr = get!(HTTP, char)(url, conn);
60+
auto body = bodyArr.idup;
61+
return parseJSON(body);
62+
}
63+
64+
private:
65+
bool tryIsoToUnix(string iso, out double outVal) {
66+
try {
67+
auto t = SysTime.fromISOExtString(iso);
68+
outVal = cast(double) t.toUnixTime();
69+
return true;
70+
} catch (Exception) {
71+
return false;
72+
}
73+
}
74+
75+
void promSetTimes(string agentName, string startedOn, string finishedOn) {
76+
auto ws = gWorkspace;
77+
double v;
78+
if (startedOn.length && tryIsoToUnix(startedOn, v)) {
79+
startedGauge.set(v, [ws, agentName]);
80+
}
81+
if (finishedOn.length && tryIsoToUnix(finishedOn, v)) {
82+
finishedGauge.set(v, [ws, agentName]);
83+
}
84+
}
85+
86+
void promSetInProgressDuration(string agentName, string status, string startedOn) {
87+
auto ws = gWorkspace;
88+
if (status == "InProgress") {
89+
double startUnix;
90+
if (startedOn.length && tryIsoToUnix(startedOn, startUnix)) {
91+
auto nowUnix = cast(double) Clock.currTime().toUnixTime();
92+
auto diff = nowUnix - startUnix;
93+
if (diff < 0) diff = 0;
94+
inProgressDurationGauge.set(diff, [ws, agentName]);
95+
return;
96+
}
97+
}
98+
inProgressDurationGauge.set(0, [ws, agentName]);
99+
}
100+
101+
void fetchAgentMetrics(string workspace, string authToken, string agentName) {
102+
auto url = format("%s/api/v1/deploy/agent/%s/%s", "https://app.cachix.org", workspace, agentName);
103+
try {
104+
auto data = httpGetJson(url, authToken);
105+
JSONValue last;
106+
if (data.type == JSONType.object && "lastDeployment" in data.object) {
107+
last = data["lastDeployment"];
108+
}
109+
110+
string status;
111+
long indexVal = long.min;
112+
string startedOn;
113+
string finishedOn;
114+
115+
if (last.type == JSONType.object) {
116+
if ("status" in last.object && last["status"].type == JSONType.string) {
117+
status = last["status"].str;
118+
}
119+
if ("index" in last.object && (last["index"].type == JSONType.integer || last["index"].type == JSONType.uinteger)) {
120+
indexVal = last["index"].integer;
121+
}
122+
if ("startedOn" in last.object && last["startedOn"].type == JSONType.string) {
123+
startedOn = last["startedOn"].str;
124+
}
125+
foreach (k; FINISHED_KEYS) {
126+
if (k in last.object && last[k].type == JSONType.string) {
127+
finishedOn = last[k].str;
128+
break;
129+
}
130+
}
131+
}
132+
133+
promSetStatus(agentName, status, indexVal);
134+
promSetTimes(agentName, startedOn, finishedOn);
135+
promSetInProgressDuration(agentName, status, startedOn);
136+
137+
auto started = startedOn.length ? startedOn : "";
138+
auto finished = finishedOn.length ? finishedOn : "";
139+
auto idx = (indexVal == long.min) ? "" : to!string(indexVal);
140+
logf(LogLevel.trace, "Agent %s startedOn=%s finishedOn=%s index=%s status=%s", agentName, started, finished, idx, (status.length ? status : ""));
141+
} catch (Exception e) {
142+
logf(LogLevel.error, "Error fetching metrics for agent '%s' (%s): %s", agentName, url, e.msg);
143+
}
144+
}
145+
146+
void scrapeLoop(string workspace, string authToken, string[] agents, int scrapeIntervalSec) {
147+
while (true) {
148+
foreach (agentName; agents) {
149+
fetchAgentMetrics(workspace, authToken, agentName);
150+
}
151+
Thread.sleep(dur!"seconds"(scrapeIntervalSec));
152+
}
153+
}
154+
155+
int main(string[] args) {
156+
struct CliArgs {
157+
@(NamedArgument(["port"])
158+
.Description("Port to listen on (default: 9160)"))
159+
int port = 9160;
160+
161+
@(NamedArgument(["listen-address"])
162+
.Description("Address to bind (default: 127.0.0.1)"))
163+
string listenAddress = "127.0.0.1";
164+
165+
@(NamedArgument(["scrape-interval"])
166+
.Description("Scrape interval in seconds (default: 10)"))
167+
int scrapeInterval = 10;
168+
169+
@(NamedArgument(["auth-token-path"])
170+
.Description("Path to Cachix auth token (required if CACHIX_AUTH_TOKEN is unset)"))
171+
string tokenPath;
172+
173+
@(NamedArgument(["workspace"])
174+
.Description("Cachix workspace name (required)")
175+
.Required())
176+
string workspace;
177+
178+
@(NamedArgument(["agent-names", "a"])
179+
.Description("Agent names (repeatable)")
180+
.Required())
181+
string[] agents;
182+
}
183+
184+
CliArgs opts;
185+
auto res = CLI!(Config.init, CliArgs).parseArgs(opts, args.length > 1 ? args[1 .. $] : []);
186+
if (!res) return res.resultCode;
187+
188+
if (args.length > 0) setCommandLineArgs([args[0]]);
189+
190+
string authToken = environment.get("CACHIX_AUTH_TOKEN");
191+
try {
192+
if (!authToken) authToken = readText(opts.tokenPath).strip();
193+
} catch (Exception e) {
194+
logf(LogLevel.error, "Token file '%s' not found or unreadable.", opts.tokenPath);
195+
return 2;
196+
}
197+
198+
gWorkspace = opts.workspace;
199+
promInit();
200+
foreach (agentName; opts.agents) {
201+
foreach (s; CACHIX_DEPLOY_STATES) {
202+
promSetStatus(agentName, s, long.min);
203+
}
204+
}
205+
206+
auto settings = new HTTPServerSettings;
207+
settings.port = cast(ushort)opts.port;
208+
bool listenSpecified = opts.listenAddress.length != 0;
209+
if (listenSpecified) settings.bindAddresses = [opts.listenAddress];
210+
211+
auto router = new URLRouter;
212+
router.get("/metrics", (HTTPServerRequest req, HTTPServerResponse res) {
213+
string buf;
214+
foreach (m; Registry.global.metrics) {
215+
auto snap = m.collect();
216+
buf ~= snap.encode();
217+
}
218+
res.writeBody(cast(ubyte[])buf, "text/plain; version=0.0.4; charset=utf-8");
219+
});
220+
221+
if (opts.agents.length) {
222+
auto t = new Thread({ scrapeLoop(opts.workspace, authToken, opts.agents, opts.scrapeInterval); });
223+
t.isDaemon = true;
224+
t.start();
225+
} else {
226+
logf(LogLevel.warning, "No --agent-names provided; only /metrics with static counters will be served.");
227+
}
228+
229+
listenHTTP(settings, router);
230+
runApplication;
231+
return 0;
232+
}

packages/default.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
};
5555

5656
packages = {
57+
cachix-deploy-metrics = pkgs.callPackage ./cachix-deploy-metrics { };
5758
lido-withdrawals-automation = pkgs.callPackage ./lido-withdrawals-automation { };
5859
pyroscope = pkgs.callPackage ./pyroscope { };
5960
random-alerts = pkgs.callPackage ./random-alerts { };

0 commit comments

Comments
 (0)