1import * as oslogin from "@distilled.cloud/gcp/oslogin_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_USER,
12 findKeyByText,
13 fingerprintOf,
14 getSshPublicKey,
15 ignoreMissing,
16 resourceName,
17 retryConflict,
18 sameText,
19 toUserId,
20 toUserParent,
21} from "./internal.ts";
22
23export type UsersSshPublicKeyProps = {
24 /**
25 * Google account that owns the key. Email, `"me"`, or
26 * `users/{user}`. `"me"` only works for user credentials; with a
27 * service account, pass its email (`users/me` fails with
28 * `UserCredentialMismatch`). Immutable — changing it replaces the key.
29 * @default "me"
30 */
31 user?: string;
32 /**
33 * Public key text in SSH format (RFC4253). The fingerprint covers the
34 * whole text (comment included), so any change replaces the key.
35 */
36 key: string;
37 /**
38 * Expiration time in microseconds since epoch. Omit for no expiry.
39 */
40 expirationTimeUsec?: string;
41};
42
43export type UsersSshPublicKey = Resource<
44 "GCP.OSLogin.UsersSshPublicKey",
45 UsersSshPublicKeyProps,
46 {
47 /** Canonical name `users/{user}/sshPublicKeys/{fingerprint}`. */
48 name: string;
49 /** User id used when the key was reconciled (`me` or email). */
50 user: string;
51 /** SHA-256 fingerprint of the SSH public key. */
52 fingerprint: string;
53 /** SSH public key text. */
54 key: string;
55 /** Expiration time in microseconds since epoch, if set. */
56 expirationTimeUsec: string | undefined;
57 /** Project id used when the key was reconciled. */
58 project: string;
59 },
60 never,
61 Providers
62>;
63
64/**
65 * An OS Login SSH public key on a Google account.
66 *
67 * SSH public keys have no labels field, so Alchemy tracks a key by the
68 * name in state; without state an identical key on the account is
69 * reported as unowned. `user` and the key text are identity — changing
70 * either replaces the key. Expiration updates in place. `"me"` works for
71 * user credentials; service accounts must pass their email as `user`.
72 *
73 * ### Creating a Key
74 * **Example:** Current user
75 * ```typescript
76 * const sshKey = yield* GCP.OSLogin.UsersSshPublicKey("Laptop", {
77 * key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... laptop",
78 * });
79 * ```
80 *
81 * **Example:** With expiration
82 * ```typescript
83 * const sshKey = yield* GCP.OSLogin.UsersSshPublicKey("Laptop", {
84 * user: "me",
85 * key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... laptop",
86 * expirationTimeUsec: "4102444800000000",
87 * });
88 * ```
89 *
90 * **Example:** Key for a service account
91 * ```typescript
92 * const sshKey = yield* GCP.OSLogin.UsersSshPublicKey("Deploy", {
93 * user: "deployer@my-project.iam.gserviceaccount.com",
94 * key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... deploy",
95 * });
96 * ```
97 *
98 * ### Updating a Key
99 * Change props on the same logical id; the engine keeps the physical id.
100 *
101 * **Example:** Extend expiration
102 * ```typescript
103 * const sshKey = yield* GCP.OSLogin.UsersSshPublicKey("Laptop", {
104 * key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... laptop",
105 * expirationTimeUsec: "4133980800000000",
106 * });
107 * ```
108 *
109 * @resource
110 * @category OSLogin
111 */
112export const UsersSshPublicKey = Resource<UsersSshPublicKey>(
113 "GCP.OSLogin.UsersSshPublicKey",
114);
115
116export class UsersSshPublicKeyNotResolved extends Data.TaggedError(
117 "GCP.OSLogin.UsersSshPublicKeyNotResolved",
118)<{
119 name: string;
120}> {}
121
122const toAttrs = (key: oslogin.SshPublicKey, user: string, project: string) => {
123 const name = key.name ?? "";
124 return {
125 name,
126 user,
127 fingerprint: key.fingerprint ?? fingerprintOf(name),
128 key: key.key ?? "",
129 expirationTimeUsec: key.expirationTimeUsec,
130 project,
131 };
132};
133
134export const UsersSshPublicKeyProvider = () =>
135 Provider.succeed(UsersSshPublicKey, {
136 stables: ["name", "user", "fingerprint", "project"],
137
138 diff: Effect.fn(function* ({ news, olds, output }) {
139 if (!isResolved(news)) return undefined;
140 const previousUser = olds?.user ?? output?.user ?? DEFAULT_USER;
141 const nextUser = toUserId(news.user, news.user);
142 const oldUser = toUserId(previousUser, previousUser);
143 if (
144 news.user !== undefined &&
145 nextUser !== oldUser &&
146 nextUser !== DEFAULT_USER &&
147 oldUser !== DEFAULT_USER
148 ) {
149 return { action: "replace" as const, deleteFirst: false };
150 }
151 const previousKey = olds?.key ?? output?.key;
152 if (
153 previousKey !== undefined &&
154 news.key !== undefined &&
155 !sameText(news.key.trim(), previousKey.trim())
156 ) {
157 return { action: "replace" as const, deleteFirst: false };
158 }
159 return undefined;
160 }),
161
162 read: Effect.fn(function* ({ olds, output }) {
163 const env = yield* GcpEnvironment.current;
164 const user = toUserId(olds?.user, output?.user);
165 const byName = yield* getSshPublicKey(
166 output?.name ??
167 (output?.fingerprint ? resourceName(user, output.fingerprint) : ""),
168 );
169 if (byName !== undefined) return toAttrs(byName, user, env.project);
170 if (olds === undefined) return undefined;
171 // No labels: an identical key without state may belong to anyone.
172 const found = yield* findKeyByText(user, olds.key, env.project);
173 return found === undefined
174 ? undefined
175 : Unowned(toAttrs(found, user, env.project));
176 }),
177
178 reconcile: Effect.fn(function* ({ news, output }) {
179 const env = yield* GcpEnvironment.current;
180 const user = toUserId(news.user, output?.user);
181 const desiredKey = news.key.trim();
182
183 let current = yield* getSshPublicKey(output?.name ?? "");
184 if (current === undefined) {
185 current = yield* findKeyByText(user, desiredKey, env.project);
186 }
187
188 if (current === undefined) {
189 const created = yield* retryConflict(
190 oslogin.createUsersSshPublicKeys({
191 parent: toUserParent(user),
192 body: {
193 key: desiredKey,
194 expirationTimeUsec: news.expirationTimeUsec,
195 },
196 }),
197 ).pipe(
198 Effect.catchTag("Conflict", () =>
199 findKeyByText(user, desiredKey, env.project),
200 ),
201 );
202 current = created ?? undefined;
203 }
204
205 if (current === undefined) {
206 return yield* new UsersSshPublicKeyNotResolved({
207 name: output?.name ?? resourceName(user, output?.fingerprint ?? ""),
208 });
209 }
210
211 const name =
212 current.name ??
213 resourceName(user, current.fingerprint ?? output?.fingerprint ?? "");
214 // The fingerprint covers the full key text, so `key` is never patched.
215 const expirationChanged =
216 (current.expirationTimeUsec ?? "") !== (news.expirationTimeUsec ?? "");
217
218 if (expirationChanged) {
219 current = yield* retryConflict(
220 oslogin.patchUsersSshPublicKeys({
221 name,
222 updateMask: "expirationTimeUsec",
223 body: {
224 expirationTimeUsec: news.expirationTimeUsec,
225 },
226 }),
227 );
228 }
229
230 return toAttrs(current, user, env.project);
231 }),
232
233 delete: Effect.fn(function* ({ output }) {
234 const name =
235 output.name ||
236 (output.fingerprint
237 ? resourceName(output.user || DEFAULT_USER, output.fingerprint)
238 : "");
239 if (name.length === 0) return;
240 yield* ignoreMissing(
241 retryConflict(oslogin.deleteUsersSshPublicKeys({ name })),
242 );
243 }),
244 });