Skip to content

Commit 13470ed

Browse files
feat: stable repo identity via IPNS CID
Replace flake URI-based namespace derivation with cryptographic identity. Each repo commits a .seed-identity file containing an IPNS CID derived from an Ed25519 keypair. Namespace is derived from the CID, so changing hosting providers (github: → silo tarball) no longer orphans k8s resources. - Add src/shared/identity.ts: IPNS CID derivation, round-trip, SSH signature verification (no external deps) - Add deriveNamespaceFromIdentity() in kube.ts - Extend SeedFlake CRD with spec.identity field - Controller: plant reads .seed-identity, verifies signature, stores identity; new replant command updates flakeUri while preserving namespace - Shell: 3-part SEED_REPOS format (name=ns=identity), replant command, plant accepts signature arg - Full test coverage: 19 new tests (identity + kube) Co-Authored-By: Joshua Perry <josh@6bit.com>
1 parent ce7606d commit 13470ed

9 files changed

Lines changed: 913 additions & 38 deletions

File tree

controller.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ let
7676
properties = {
7777
inviteCode = { type = "string"; };
7878
flakeUri = { type = "string"; };
79+
identity = { type = "string"; };
7980
};
8081
};
8182
status = {

instances/shell.nix

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ let
4141
| select(.key | split(" ")[0:2] | join(" ") == $key)
4242
| .value[] ]
4343
| unique_by(.namespace)
44-
| map("\(.name)=\(.namespace)") | join(",")')
44+
| map("\(.name)=\(.namespace)=\(.identity // "")") | join(",")')
4545
fi
4646
4747
# Output extra environment= directive for authorized_keys line
@@ -71,14 +71,19 @@ let
7171
fi
7272
}
7373
74-
# Parse SEED_REPOS into parallel arrays: REPO_NAMES and REPO_NS
74+
# Parse SEED_REPOS into parallel arrays: REPO_NAMES, REPO_NS, REPO_IDENTITY
75+
# Format: name=namespace=identity (3-part), or name=namespace (2-part legacy)
7576
declare -a REPO_NAMES=()
7677
declare -a REPO_NS=()
78+
declare -a REPO_IDENTITY=()
7779
if [ -n "$REPOS_RAW" ]; then
7880
IFS=',' read -ra PAIRS <<< "$REPOS_RAW"
7981
for pair in "''${PAIRS[@]}"; do
80-
REPO_NAMES+=("''${pair%%=*}")
81-
REPO_NS+=("''${pair#*=}")
82+
# Split on '=' — name=namespace=identity
83+
IFS='=' read -r _name _ns _id <<< "$pair"
84+
REPO_NAMES+=("$_name")
85+
REPO_NS+=("$_ns")
86+
REPO_IDENTITY+=("''${_id:-}")
8287
done
8388
fi
8489
@@ -151,34 +156,42 @@ let
151156
FOLLOW=false
152157
LINES=""
153158
159+
ARG3=""
154160
while [ $# -gt 0 ]; do
155161
case "$1" in
156162
--json) JSON_OUT=true ;;
157163
--follow) FOLLOW=true ;;
158164
-f) FOLLOW=true ;;
159165
--lines) shift; LINES="''${1:-}" ;;
160-
*) if [ -z "$ARG" ]; then ARG="$1"; elif [ -z "$ARG2" ]; then ARG2="$1"; fi ;;
166+
*) if [ -z "$ARG" ]; then ARG="$1"; elif [ -z "$ARG2" ]; then ARG2="$1"; elif [ -z "$ARG3" ]; then ARG3="$1"; fi ;;
161167
esac
162168
shift
163169
done
164170
165171
case "$ACTION" in
166172
plant)
167173
if [ -z "$ARG" ] || [ -z "$ARG2" ]; then
168-
echo "usage: plant <flake-uri> <invite-code>" >&2
174+
echo "usage: plant <flake-uri> <invite-code> [<signature>]" >&2
169175
echo "" >&2
170176
echo "examples:" >&2
171177
echo " plant github:me/my-app a3f8c2e1" >&2
172178
echo " plant silo:my-app a3f8c2e1" >&2
179+
echo " plant silo:my-app a3f8c2e1 <base64-ssh-signature>" >&2
173180
exit 1
174181
fi
175182
if [ -z "$KEY_BLOB" ]; then
176183
echo "error: key identity not available" >&2
177184
exit 1
178185
fi
186+
# Build JSON payload — include signature if provided
187+
PLANT_JSON="{\"flakeUri\":\"$ARG\",\"inviteCode\":\"$ARG2\",\"keyBlob\":\"$KEY_BLOB\""
188+
if [ -n "$ARG3" ]; then
189+
PLANT_JSON="$PLANT_JSON,\"signature\":\"$ARG3\""
190+
fi
191+
PLANT_JSON="$PLANT_JSON}"
179192
RESULT=$($CURL -sf -X POST "$API/plant" \
180193
-H "Content-Type: application/json" \
181-
-d "{\"flakeUri\":\"$ARG\",\"inviteCode\":\"$ARG2\",\"keyBlob\":\"$KEY_BLOB\"}") || {
194+
-d "$PLANT_JSON") || {
182195
echo "error: plant failed" >&2
183196
exit 1
184197
}
@@ -187,7 +200,38 @@ let
187200
echo "error: $ERROR" >&2
188201
exit 1
189202
fi
190-
echo "$RESULT" | $JQ -r '"planted \(.flakeUri)\n name: \(.name)\n namespace: \(.namespace)"'
203+
echo "$RESULT" | $JQ -r '"planted \(.flakeUri)\n name: \(.name)\n namespace: \(.namespace)" + (if .identity != "" then "\n identity: \(.identity)" else "" end)'
204+
;;
205+
206+
replant)
207+
if [ -z "$ARG" ] || [ -z "$ARG2" ]; then
208+
echo "usage: replant <identity-cid> <new-flake-uri>" >&2
209+
echo "" >&2
210+
echo "change the source URI for an identity-based repo." >&2
211+
echo "your SSH key must be in .authorized_keys of the new repo," >&2
212+
echo "and the new repo must have the same .seed-identity file." >&2
213+
echo "" >&2
214+
echo "examples:" >&2
215+
echo " replant k51qzi5... silo:my-app" >&2
216+
echo " replant k51qzi5... github:me/my-app" >&2
217+
exit 1
218+
fi
219+
if [ -z "$KEY_BLOB" ]; then
220+
echo "error: key identity not available" >&2
221+
exit 1
222+
fi
223+
RESULT=$($CURL -sf -X POST "$API/replant" \
224+
-H "Content-Type: application/json" \
225+
-d "{\"identity\":\"$ARG\",\"newFlakeUri\":\"$ARG2\",\"keyBlob\":\"$KEY_BLOB\"}") || {
226+
echo "error: replant failed" >&2
227+
exit 1
228+
}
229+
ERROR=$(echo "$RESULT" | $JQ -r '.error // empty')
230+
if [ -n "$ERROR" ]; then
231+
echo "error: $ERROR" >&2
232+
exit 1
233+
fi
234+
echo "$RESULT" | $JQ -r '"replanted → \(.flakeUri)\n name: \(.name)\n namespace: \(.namespace)\n identity: \(.identity)"'
191235
;;
192236
193237
status)
@@ -228,6 +272,12 @@ let
228272
if [ "$JSON_OUT" = true ]; then
229273
echo "$ALL_JSON" | $JQ .
230274
else
275+
# Build identity lookup for display
276+
declare -A REPO_ID_MAP
277+
for i in "''${!REPO_NAMES[@]}"; do
278+
REPO_ID_MAP["''${REPO_NAMES[$i]}"]="''${REPO_IDENTITY[$i]:-}"
279+
done
280+
231281
echo "$ALL_JSON" | $JQ -r '.[] |
232282
.data.namespace as $ns |
233283
"\u001b[1;4m\(.repo)\u001b[0m \u001b[2m\($ns)\u001b[0m",
@@ -239,6 +289,14 @@ let
239289
" restarts=\(.value.restarts)" +
240290
" age=\(.value.age)"
241291
), ""'
292+
293+
# Show identity CIDs if present
294+
for i in "''${!REPO_NAMES[@]}"; do
295+
local id="''${REPO_IDENTITY[$i]:-}"
296+
if [ -n "$id" ]; then
297+
printf ' \033[2midentity: %s\033[0m\n' "$id"
298+
fi
299+
done
242300
fi
243301
fi
244302
;;
@@ -340,22 +398,24 @@ let
340398
echo "seed shell — manage your seed instances"
341399
echo ""
342400
echo "commands:"
343-
echo " plant <flake-uri> <code> register a repo with an invite code"
344-
echo " status [repo] show instance status (default: all repos)"
345-
echo " logs <[repo/]instance> show recent logs (default: 100 lines)"
346-
echo " restart <[repo/]instance> restart an instance"
347-
echo " keys <[repo/]instance> show age public key (for sops encryption)"
348-
echo " help show this help"
401+
echo " plant <flake-uri> <code> [sig] register a repo with an invite code"
402+
echo " replant <identity> <new-uri> change source URI (identity preserved)"
403+
echo " status [repo] show instance status (default: all repos)"
404+
echo " logs <[repo/]instance> show recent logs (default: 100 lines)"
405+
echo " restart <[repo/]instance> restart an instance"
406+
echo " keys <[repo/]instance> show age public key (for sops encryption)"
407+
echo " help show this help"
349408
echo ""
350409
echo "examples:"
351-
echo " plant github:me/app a3f8 register with invite code"
352-
echo " plant silo:my-app a3f8 register a silo repo"
353-
echo " status status of all repos"
354-
echo " status seed status of the 'seed' repo"
355-
echo " logs web logs for 'web' (auto-resolves repo)"
356-
echo " logs seed/web -f follow logs for 'web' in 'seed' repo"
410+
echo " plant github:me/app a3f8 register with invite code"
411+
echo " plant silo:my-app a3f8 <sig> register with identity signature"
412+
echo " replant k51qzi5... silo:my-app change source URI"
413+
echo " status status of all repos"
414+
echo " status seed status of the 'seed' repo"
415+
echo " logs web logs for 'web' (auto-resolves repo)"
416+
echo " logs seed/web -f follow logs for 'web' in 'seed' repo"
357417
echo " restart shoot-demo/shoot-demo"
358-
echo " keys web age public key for sops encryption"
418+
echo " keys web age public key for sops encryption"
359419
echo ""
360420
echo "flags:"
361421
echo " --json output raw JSON (for scripting)"

src/controller/api.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { log } from "../shared/kube.js";
2323
export interface NamespaceEntry {
2424
name: string; // repo name (e.g. "seed", "shoot-demo")
2525
namespace: string; // k8s namespace (e.g. "s-gaydazldmnsg")
26+
identity: string; // IPNS CID from .seed-identity (empty for legacy flakes)
2627
}
2728

2829
export interface KeyIndex {
@@ -45,7 +46,8 @@ export type PlantHandler = (
4546
flakeUri: string,
4647
inviteCode: string,
4748
keyBlob: string,
48-
) => Promise<{ name: string; namespace: string; flakeUri: string }>;
49+
signature: string,
50+
) => Promise<{ name: string; namespace: string; flakeUri: string; identity: string }>;
4951

5052
let plantHandler: PlantHandler | null = null;
5153

@@ -54,6 +56,21 @@ export function setPlantHandler(handler: PlantHandler): void {
5456
plantHandler = handler;
5557
}
5658

59+
// --- Replant handler ---
60+
61+
export type ReplantHandler = (
62+
identity: string,
63+
newFlakeUri: string,
64+
keyBlob: string,
65+
) => Promise<{ name: string; namespace: string; flakeUri: string; identity: string }>;
66+
67+
let replantHandler: ReplantHandler | null = null;
68+
69+
/** Register the replant handler. Called by the controller after startup. */
70+
export function setReplantHandler(handler: ReplantHandler): void {
71+
replantHandler = handler;
72+
}
73+
5774
// --- Route handler ---
5875

5976
interface RouteContext {
@@ -111,6 +128,12 @@ export async function handleApiRequest(
111128
return true;
112129
}
113130

131+
// POST /api/replant
132+
if (req.method === "POST" && pathname === "/api/replant") {
133+
await handleReplantRequest(req, res);
134+
return true;
135+
}
136+
114137
// Parse /api/ns/:namespace/...
115138
const nsMatch = pathname.match(/^\/api\/ns\/([a-z0-9-]+)\/(.+)$/);
116139
if (!nsMatch) {
@@ -181,22 +204,22 @@ async function handlePlantRequest(
181204
chunks.push(chunk as Buffer);
182205
}
183206

184-
let body: { flakeUri?: string; inviteCode?: string; keyBlob?: string };
207+
let body: { flakeUri?: string; inviteCode?: string; keyBlob?: string; signature?: string };
185208
try {
186209
body = JSON.parse(Buffer.concat(chunks).toString());
187210
} catch {
188211
jsonResponse(res, 400, { error: "invalid JSON" });
189212
return;
190213
}
191214

192-
const { flakeUri, inviteCode, keyBlob } = body;
215+
const { flakeUri, inviteCode, keyBlob, signature } = body;
193216
if (!flakeUri || !inviteCode || !keyBlob) {
194217
jsonResponse(res, 400, { error: "missing required fields: flakeUri, inviteCode, keyBlob" });
195218
return;
196219
}
197220

198221
try {
199-
const result = await plantHandler(flakeUri, inviteCode, keyBlob);
222+
const result = await plantHandler(flakeUri, inviteCode, keyBlob, signature || "");
200223
log("api", `planted ${result.flakeUri}${result.namespace}`);
201224
jsonResponse(res, 200, result);
202225
} catch (err) {
@@ -206,6 +229,45 @@ async function handlePlantRequest(
206229
}
207230
}
208231

232+
async function handleReplantRequest(
233+
req: IncomingMessage,
234+
res: ServerResponse,
235+
): Promise<void> {
236+
if (!replantHandler) {
237+
jsonResponse(res, 503, { error: "replant handler not initialized" });
238+
return;
239+
}
240+
241+
const chunks: Buffer[] = [];
242+
for await (const chunk of req) {
243+
chunks.push(chunk as Buffer);
244+
}
245+
246+
let body: { identity?: string; newFlakeUri?: string; keyBlob?: string };
247+
try {
248+
body = JSON.parse(Buffer.concat(chunks).toString());
249+
} catch {
250+
jsonResponse(res, 400, { error: "invalid JSON" });
251+
return;
252+
}
253+
254+
const { identity, newFlakeUri, keyBlob } = body;
255+
if (!identity || !newFlakeUri || !keyBlob) {
256+
jsonResponse(res, 400, { error: "missing required fields: identity, newFlakeUri, keyBlob" });
257+
return;
258+
}
259+
260+
try {
261+
const result = await replantHandler(identity, newFlakeUri, keyBlob);
262+
log("api", `replanted ${result.identity}${result.flakeUri}`);
263+
jsonResponse(res, 200, result);
264+
} catch (err) {
265+
const msg = err instanceof Error ? err.message : String(err);
266+
log("api", `replant failed: ${msg}`);
267+
jsonResponse(res, 400, { error: msg });
268+
}
269+
}
270+
209271
async function handleStatus(
210272
res: ServerResponse,
211273
clients: KubeClients,

0 commit comments

Comments
 (0)