1import * as oslogin from "@distilled.cloud/gcp/oslogin_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4
5export const DEFAULT_USER = "me";
6
7export type ParsedSshKey = {
8 type: string;
9 blob: string;
10 comment: string | undefined;
11};
12
13export const sameText = (left: string | undefined, right: string | undefined) =>
14 (left ?? "") === (right ?? "");
15
16export const parseSshKey = (key: string | undefined): ParsedSshKey => {
17 const trimmed = (key ?? "").trim();
18 const parts = trimmed.split(/\s+/);
19 if (parts.length < 2) {
20 return { type: "", blob: trimmed, comment: undefined };
21 }
22 const type = parts[0] ?? "";
23 const blob = parts[1] ?? "";
24 const comment = parts.slice(2).join(" ").trim();
25 return {
26 type,
27 blob,
28 comment: comment.length > 0 ? comment : undefined,
29 };
30};
31
32export const formatSshKey = (parsed: ParsedSshKey): string =>
33 [parsed.type, parsed.blob, parsed.comment]
34 .filter((part): part is string => part !== undefined && part.length > 0)
35 .join(" ");
36
37export const keyIdentity = (key: string | undefined) => {
38 const parsed = parseSshKey(key);
39 return `${parsed.type} ${parsed.blob}`.trim();
40};
41
42export const sameKeyMaterial = (
43 left: string | undefined,
44 right: string | undefined,
45) => keyIdentity(left) === keyIdentity(right);
46
47export const normalizeUser = (user: string | undefined) => {
48 const raw = (user ?? DEFAULT_USER).trim();
49 const id = raw.startsWith("users/") ? raw.slice("users/".length) : raw;
50 return id.length > 0 ? id : DEFAULT_USER;
51};
52
53export const toUserId = (
54 requested: string | undefined,
55 existing: string | undefined,
56) => normalizeUser(requested ?? existing ?? DEFAULT_USER);
57
58export const toUserParent = (user: string) => {
59 const id = normalizeUser(user);
60 return id.startsWith("users/") ? id : `users/${id}`;
61};
62
63export const userOf = (name: string | undefined, fallback = DEFAULT_USER) => {
64 if (!name) return fallback;
65 const match = name.match(/^users\/([^/]+)/);
66 return match?.[1] ?? fallback;
67};
68
69export const fingerprintOf = (
70 name: string | undefined,
71 fingerprint?: string,
72) => {
73 if (fingerprint && fingerprint.length > 0) return fingerprint;
74 if (!name) return "";
75 const parts = name.split("/sshPublicKeys/");
76 return parts[1] ?? "";
77};
78
79export const resourceName = (user: string, fingerprint: string) =>
80 `${toUserParent(user)}/sshPublicKeys/${fingerprint}`;
81
82const emptyList = <A>() => Effect.succeed([] as A[]);
83
84export const catchMissing = <A, E extends { readonly _tag: string }, R>(
85 effect: Effect.Effect<A, E, R>,
86) =>
87 effect.pipe(
88 Effect.catchIf(
89 (error): error is E & { readonly _tag: "NotFound" } =>
90 error._tag === "NotFound",
91 () => Effect.succeed(undefined),
92 ),
93 );
94
95export const ignoreMissing = <E extends { readonly _tag: string }, R>(
96 effect: Effect.Effect<unknown, E, R>,
97) =>
98 effect.pipe(
99 Effect.catchIf(
100 (
101 error,
102 ): error is E & {
103 readonly _tag: "NotFound" | "Conflict";
104 } => error._tag === "NotFound" || error._tag === "Conflict",
105 () => Effect.void,
106 ),
107 );
108
109export const retryConflict = <A, E extends { _tag: string }, R>(
110 effect: Effect.Effect<A, E, R>,
111) =>
112 effect.pipe(
113 Effect.retry({
114 while: (e) => e._tag === "Conflict",
115 times: 8,
116 schedule: Schedule.spaced("400 millis"),
117 }),
118 );
119
120export const getSshPublicKey = (name: string) => {
121 if (name.length === 0) return Effect.succeed(undefined);
122 return catchMissing(oslogin.getUsersSshPublicKeys({ name }));
123};
124
125export const listSshPublicKeys = (user: string, project?: string) =>
126 oslogin
127 .getLoginProfileUsers({
128 name: toUserParent(user),
129 projectId: project,
130 })
131 .pipe(
132 Effect.map((profile) =>
133 Object.values(profile.sshPublicKeys ?? {}).filter(
134 (key): key is oslogin.SshPublicKey => key !== undefined,
135 ),
136 ),
137 Effect.catchTag("NotFound", () => emptyList<oslogin.SshPublicKey>()),
138 );
139
140/** Find a key of `user` by its exact public key text. */
141export const findKeyByText = (user: string, key: string, project?: string) =>
142 listSshPublicKeys(user, project).pipe(
143 Effect.map((items) =>
144 items.find((item) => sameText(item.key?.trim(), key.trim())),
145 ),
146 );