Monorepo for Tangled
at local-dev 525 lines 21 kB view raw
1{ 2 nixpkgs, 3 system, 4 hostSystem, 5 self, 6}: let 7 envVar = name: let 8 var = builtins.getEnv name; 9 in 10 if var == "" 11 then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details" 12 else var; 13 envVarOr = name: default: let 14 var = builtins.getEnv name; 15 in 16 if var != "" 17 then var 18 else default; 19 20 # When true, run a local PLC directory, PDS, and Jetstream inside the VM 21 # so that the entire stack works offline (after the initial nix build). 22 localDev = (envVarOr "TANGLED_VM_LOCAL_DEV" "0") != "0"; 23 24 plcUrl = 25 if localDev 26 then "http://localhost:2582" 27 else envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 28 jetstream = 29 if localDev 30 then "ws://localhost:6008/subscribe" 31 else envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 32 33 pdsPort = 2583; 34 plcPort = 2582; 35 jetstreamPort = 6008; 36 37 # Static secrets for local development only — NOT for production use 38 pdsAdminPassword = "tangled-local-dev"; 39 pdsJwtSecret = "tangled-local-dev-jwt-secret-DO-NOT-USE-IN-PRODUCTION"; 40 # A pre-generated secp256k1 private key for local dev PLC rotation key 41 pdsPlcRotationKey = "b2e9683fd0e78a52f82c30e9c5f5e44fd55c6fb22e0f4e1ec92c54e1fe4a0509"; 42in 43 nixpkgs.lib.nixosSystem { 44 inherit system; 45 modules = [ 46 self.nixosModules.knot 47 self.nixosModules.spindle 48 ({ 49 lib, 50 config, 51 pkgs, 52 ... 53 }: let 54 did-plc-server = pkgs.callPackage ./pkgs/did-plc-server.nix {}; 55 pds-local-dev = pkgs.callPackage ./pkgs/pds-local-dev.nix {}; 56 jetstream-pkg = pkgs.callPackage ./pkgs/jetstream.nix {}; 57 58 # Bootstrap script: creates a dev account on the PDS and writes 59 # the owner DID to an environment file that knot/spindle load. 60 bootstrapScript = pkgs.writeShellScript "tangled-bootstrap" '' 61 set -euo pipefail 62 ENV_FILE="/var/lib/tangled-dev/env" 63 DID_FILE="/var/lib/tangled-dev/owner-did" 64 ATPROTO_DID_FILE="/mnt/atproto/owner-did" 65 curl="${pkgs.curl}/bin/curl" 66 jq="${pkgs.jq}/bin/jq" 67 PDS="http://localhost:${toString pdsPort}" 68 69 # Check for DID from a previous session either on VM disk or 70 # in the 9p-persisted atproto directory (survives qcow2 deletion). 71 existing_did="" 72 if [[ -f "$DID_FILE" ]]; then 73 existing_did="$(cat "$DID_FILE")" 74 elif [[ -f "$ATPROTO_DID_FILE" ]]; then 75 existing_did="$(cat "$ATPROTO_DID_FILE")" 76 fi 77 78 echo "tangled-bootstrap: waiting for PDS to be ready..." 79 for i in $(seq 1 30); do 80 if $curl -sf "$PDS/xrpc/_health" > /dev/null 2>&1; then 81 break 82 fi 83 sleep 1 84 done 85 86 if [[ -n "$existing_did" ]]; then 87 echo "tangled-bootstrap: owner DID already exists: $existing_did" 88 did="$existing_did" 89 90 # Re-login to get a fresh accessJwt for label creation 91 echo "tangled-bootstrap: logging in to get access token..." 92 login_resp=$($curl -s -X POST \ 93 -H "Content-Type: application/json" \ 94 -d '{"identifier":"tangled-dev.test","password":"password"}' \ 95 "$PDS/xrpc/com.atproto.server.createSession") 96 access_jwt=$(echo "$login_resp" | $jq -r '.accessJwt') 97 else 98 echo "tangled-bootstrap: creating dev account..." 99 resp=$($curl -s -X POST \ 100 -H "Content-Type: application/json" \ 101 -d '{"email":"dev@example.com","handle":"tangled-dev.test","password":"password"}' \ 102 "$PDS/xrpc/com.atproto.server.createAccount") 103 104 did=$(echo "$resp" | $jq -r '.did') 105 access_jwt=$(echo "$resp" | $jq -r '.accessJwt') 106 if [[ -z "$did" || "$did" == "null" ]]; then 107 # Account might already exist (9p-persisted PDS with no DID file). 108 # Try logging in instead. 109 echo "tangled-bootstrap: createAccount failed, trying login..." 110 login_resp=$($curl -s -X POST \ 111 -H "Content-Type: application/json" \ 112 -d '{"identifier":"tangled-dev.test","password":"password"}' \ 113 "$PDS/xrpc/com.atproto.server.createSession") 114 did=$(echo "$login_resp" | $jq -r '.did') 115 access_jwt=$(echo "$login_resp" | $jq -r '.accessJwt') 116 117 if [[ -z "$did" || "$did" == "null" ]]; then 118 echo "tangled-bootstrap: ERROR: failed to create account or login" 119 echo "createAccount response: $resp" 120 echo "createSession response: $login_resp" 121 exit 1 122 fi 123 fi 124 125 echo "$did" > "$DID_FILE" 126 fi 127 128 # Write DID to 9p mount so the host can read it 129 echo "$did" > "$ATPROTO_DID_FILE" 130 131 # Write environment file for knot and spindle 132 printf 'KNOT_SERVER_OWNER=%s\nSPINDLE_SERVER_OWNER=%s\n' "$did" "$did" > "$ENV_FILE" 133 134 # Create default label definitions 135 if [[ -n "$access_jwt" && "$access_jwt" != "null" ]]; then 136 echo "tangled-bootstrap: creating default label definitions..." 137 now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") 138 139 create_label() { 140 local rkey="$1" name="$2" color="$3" vtype="$4" vformat="$5" multiple="$6" 141 local scope='["sh.tangled.repo.issue","sh.tangled.repo.pull"]' 142 local multiple_field="" 143 if [[ "$multiple" == "true" ]]; then 144 multiple_field=',"multiple":true' 145 fi 146 147 local record="{\"\$type\":\"sh.tangled.label.definition\",\"name\":\"$name\",\"color\":\"$color\",\"createdAt\":\"$now\",\"scope\":$scope,\"valueType\":{\"type\":\"$vtype\",\"format\":\"$vformat\"}$multiple_field}" 148 149 local resp 150 resp=$($curl -sf -X POST \ 151 -H "Content-Type: application/json" \ 152 -H "Authorization: Bearer $access_jwt" \ 153 -d "{\"repo\":\"$did\",\"collection\":\"sh.tangled.label.definition\",\"rkey\":\"$rkey\",\"record\":$record}" \ 154 "$PDS/xrpc/com.atproto.repo.putRecord" 2>&1) || true 155 156 if echo "$resp" | $jq -e '.uri' > /dev/null 2>&1; then 157 echo " created: $rkey" 158 else 159 echo " $rkey: already exists or skipped" 160 fi 161 } 162 163 create_label "wontfix" "wontfix" "#737373" "null" "any" "false" 164 create_label "good-first-issue" "good first issue" "#22c55e" "null" "any" "false" 165 create_label "duplicate" "duplicate" "#a855f7" "null" "any" "false" 166 create_label "documentation" "documentation" "#3b82f6" "null" "any" "false" 167 create_label "assignee" "assignee" "#f59e0b" "string" "did" "true" 168 else 169 echo "tangled-bootstrap: WARNING: no access token, skipping label creation" 170 fi 171 172 echo "" 173 echo "========================================" 174 echo " Tangled Local Dev Bootstrap Complete" 175 echo " Owner DID: $did" 176 echo " Handle: tangled-dev.test" 177 echo " Password: password" 178 echo " PDS: $PDS" 179 echo "========================================" 180 echo "" 181 ''; 182 in { 183 virtualisation.vmVariant.virtualisation = { 184 host.pkgs = import nixpkgs {system = hostSystem;}; 185 186 graphics = false; 187 memorySize = 188 if localDev 189 then 4096 190 else 2048; 191 diskSize = 10 * 1024; 192 cores = 2; 193 forwardPorts = 194 [ 195 # ssh 196 { 197 from = "host"; 198 host.port = 2222; 199 guest.port = 22; 200 } 201 # knot 202 { 203 from = "host"; 204 host.port = 6444; 205 guest.port = 6444; 206 } 207 # spindle 208 { 209 from = "host"; 210 host.port = 6555; 211 guest.port = 6555; 212 } 213 ] 214 ++ lib.optionals localDev [ 215 # PLC directory 216 { 217 from = "host"; 218 host.port = plcPort; 219 guest.port = plcPort; 220 } 221 # PDS 222 { 223 from = "host"; 224 host.port = pdsPort; 225 guest.port = pdsPort; 226 } 227 # Jetstream 228 { 229 from = "host"; 230 host.port = jetstreamPort; 231 guest.port = jetstreamPort; 232 } 233 ]; 234 sharedDirectories = 235 { 236 # We can't use the 9p mounts directly for most of these 237 # as SQLite is incompatible with them. So instead we 238 # mount the shared directories to a different location 239 # and copy the contents around on service start/stop. 240 knotData = { 241 source = "$TANGLED_VM_DATA_DIR/knot"; 242 target = "/mnt/knot-data"; 243 }; 244 spindleData = { 245 source = "$TANGLED_VM_DATA_DIR/spindle"; 246 target = "/mnt/spindle-data"; 247 }; 248 spindleLogs = { 249 source = "$TANGLED_VM_DATA_DIR/spindle-logs"; 250 target = "/var/log/spindle"; 251 }; 252 } 253 // lib.optionalAttrs localDev { 254 # AT Protocol state persistence: PLC (postgres), PDS (sqlite+blobs), 255 # Jetstream (pebbledb), and the owner DID file. 256 atprotoData = { 257 source = "$TANGLED_VM_DATA_DIR/atproto"; 258 target = "/mnt/atproto"; 259 }; 260 }; 261 }; 262 # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 263 networking.firewall.enable = false; 264 time.timeZone = "Europe/London"; 265 services.getty.autologinUser = "root"; 266 environment.systemPackages = with pkgs; [curl vim git sqlite litecli jq]; 267 services.tangled.knot = { 268 enable = true; 269 motd = "Welcome to the development knot!\n"; 270 server = { 271 owner = 272 if localDev 273 then "bootstrap-pending" 274 else envVar "TANGLED_VM_KNOT_OWNER"; 275 hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6444"; 276 plcUrl = plcUrl; 277 jetstreamEndpoint = jetstream; 278 listenAddr = "0.0.0.0:6444"; 279 dev = localDev; 280 }; 281 }; 282 services.tangled.spindle = { 283 enable = true; 284 server = { 285 owner = 286 if localDev 287 then "bootstrap-pending" 288 else envVar "TANGLED_VM_SPINDLE_OWNER"; 289 hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 290 plcUrl = plcUrl; 291 jetstreamEndpoint = jetstream; 292 listenAddr = "0.0.0.0:6555"; 293 dev = true; 294 queueSize = 100; 295 maxJobCount = 2; 296 secrets = { 297 provider = "sqlite"; 298 }; 299 }; 300 }; 301 302 # ── Local PLC + PDS + Jetstream (offline dev mode) ────────────── 303 # 304 # When TANGLED_VM_LOCAL_DEV=1, the VM runs a fully self-contained 305 # AT Protocol stack: 306 # PostgreSQL → PLC directory → PDS → Jetstream → Knot/Spindle 307 # 308 # A bootstrap service creates a dev account and writes the owner 309 # DID to an environment file that knot/spindle load at startup. 310 311 # PostgreSQL for PLC directory (persistent DID storage) 312 services.postgresql = lib.mkIf localDev { 313 enable = true; 314 package = pkgs.postgresql_16; 315 ensureDatabases = ["plc"]; 316 ensureUsers = [ 317 { 318 name = "plc"; 319 ensureDBOwnership = true; 320 } 321 ]; 322 # Trust local connections (dev-only, no passwords needed) 323 authentication = lib.mkForce '' 324 # TYPE DATABASE USER ADDRESS METHOD 325 local all all trust 326 host all all 127.0.0.1/32 trust 327 host all all ::1/128 trust 328 ''; 329 }; 330 331 users = { 332 # So we don't have to deal with permission clashing between 333 # blank disk VMs and existing state 334 users.${config.services.tangled.knot.gitUser}.uid = 666; 335 groups.${config.services.tangled.knot.gitUser}.gid = 666; 336 337 # TODO: separate spindle user 338 }; 339 340 systemd.services = let 341 mkDataSyncScripts = source: target: { 342 enableStrictShellChecks = true; 343 344 preStart = lib.mkBefore '' 345 mkdir -p ${target} 346 ${lib.getExe pkgs.rsync} -a ${source}/ ${target} 347 ''; 348 349 postStop = lib.mkAfter '' 350 ${lib.getExe pkgs.rsync} -a ${target}/ ${source} 351 ''; 352 353 serviceConfig.PermissionsStartOnly = true; 354 }; 355 # In localDev mode, knot/spindle depend on the bootstrap service 356 # which creates a dev account and writes the owner DID to an env 357 # file. Requires= ensures knot/spindle won't start if bootstrap 358 # failed (e.g. PDS never came up). After= ensures ordering. 359 localDevDeps = lib.optionalAttrs localDev { 360 after = ["tangled-bootstrap.service"]; 361 requires = ["tangled-bootstrap.service"]; 362 serviceConfig.EnvironmentFile = ["/var/lib/tangled-dev/env"]; 363 }; 364 in 365 { 366 knot = 367 (mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir) 368 // localDevDeps; 369 spindle = 370 (mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath)) 371 // localDevDeps; 372 } 373 // lib.optionalAttrs localDev { 374 # ── AT Protocol state persistence ───────────────────────── 375 # PDS, Jetstream, and PostgreSQL (PLC) data is synced to/from 376 # the 9p mount at /mnt/atproto so it survives VM reboots. 377 # DID stability across reboots is critical — knot/spindle 378 # data references the owner DID. 379 # 380 # PostgreSQL (PLC) persistence via pg_dump/psql. 381 # The qcow2 disk is the primary storage; the 9p dump is a 382 # safety net so DID data survives qcow2 deletion. 383 # 384 # Boot: pg starts → setup creates DB → restore loads dump (if DB empty) 385 # Shutdown: backup dumps → pg stops 386 atproto-pg-restore = { 387 description = "Restore PLC database from 9p dump"; 388 after = ["postgresql.service" "postgresql-setup.service"]; 389 requires = ["postgresql.service"]; 390 wantedBy = ["multi-user.target"]; 391 serviceConfig = { 392 Type = "oneshot"; 393 RemainAfterExit = true; 394 ExecStart = pkgs.writeShellScript "atproto-pg-restore" '' 395 dump="/mnt/atproto/plc.sql" 396 if [ ! -f "$dump" ]; then 397 echo "atproto-pg-restore: no dump file, skipping" 398 exit 0 399 fi 400 table_count=$(${pkgs.postgresql_16}/bin/psql -U plc -d plc -tAc \ 401 "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'") 402 if [ "$table_count" != "0" ]; then 403 echo "atproto-pg-restore: database already has $table_count tables, skipping" 404 exit 0 405 fi 406 echo "atproto-pg-restore: loading dump into empty database..." 407 ${pkgs.postgresql_16}/bin/psql -U plc -d plc < "$dump" 408 echo "atproto-pg-restore: done" 409 ''; 410 }; 411 }; 412 atproto-pg-backup = { 413 description = "Backup PLC database to 9p dump"; 414 after = ["postgresql.service"]; 415 wants = ["postgresql.service"]; 416 wantedBy = ["multi-user.target"]; 417 serviceConfig = { 418 Type = "oneshot"; 419 RemainAfterExit = true; 420 # ExecStart is a no-op; the real work is in ExecStop 421 # which fires on shutdown while postgresql is still running. 422 ExecStart = "${pkgs.coreutils}/bin/true"; 423 ExecStop = pkgs.writeShellScript "atproto-pg-backup" '' 424 echo "atproto-pg-backup: dumping PLC database..." 425 ${pkgs.postgresql_16}/bin/pg_dump -U plc plc > /mnt/atproto/plc.sql 426 echo "atproto-pg-backup: done ($(wc -c < /mnt/atproto/plc.sql) bytes)" 427 ''; 428 }; 429 }; 430 431 # PLC directory server 432 plc-server = { 433 description = "DID PLC Directory Server (local development)"; 434 after = ["network.target" "atproto-pg-restore.service"]; 435 wants = ["postgresql.service"]; 436 wantedBy = ["multi-user.target"]; 437 438 serviceConfig = { 439 Environment = [ 440 "DATABASE_URL=postgres://plc@127.0.0.1/plc" 441 "PORT=${toString plcPort}" 442 "LOG_ENABLED=true" 443 "LOG_LEVEL=info" 444 "DEBUG_MODE=1" 445 ]; 446 ExecStart = "${did-plc-server}/bin/plc-server"; 447 Restart = "on-failure"; 448 RestartSec = "5s"; 449 }; 450 }; 451 452 # Bluesky PDS (patched for HTTP local dev) 453 bluesky-pds = 454 (mkDataSyncScripts "/mnt/atproto/pds" "/var/lib/bluesky-pds") 455 // { 456 description = "Bluesky PDS (local development)"; 457 after = ["network.target" "plc-server.service"]; 458 wants = ["plc-server.service"]; 459 wantedBy = ["multi-user.target"]; 460 461 serviceConfig = { 462 StateDirectory = "bluesky-pds"; 463 WorkingDirectory = "/var/lib/bluesky-pds"; 464 Environment = [ 465 "PDS_HOSTNAME=localhost" 466 "PDS_PORT=${toString pdsPort}" 467 "PDS_DATA_DIRECTORY=/var/lib/bluesky-pds" 468 "PDS_DID_PLC_URL=http://localhost:${toString plcPort}" 469 "PDS_BLOBSTORE_DISK_LOCATION=/var/lib/bluesky-pds/blobs" 470 "PDS_JWT_SECRET=${pdsJwtSecret}" 471 "PDS_ADMIN_PASSWORD=${pdsAdminPassword}" 472 "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${pdsPlcRotationKey}" 473 "PDS_CRAWLERS=" 474 "PDS_INVITE_REQUIRED=false" 475 "PDS_DEV_MODE=true" 476 "PDS_LOG_ENABLED=true" 477 "NODE_ENV=development" 478 ]; 479 ExecStart = "${pds-local-dev}/bin/pds"; 480 Restart = "on-failure"; 481 RestartSec = "5s"; 482 }; 483 }; 484 485 # Jetstream (native Nix package — subscribes to local PDS firehose) 486 jetstream = 487 (mkDataSyncScripts "/mnt/atproto/jetstream" "/var/lib/jetstream") 488 // { 489 description = "AT Protocol Jetstream (local development)"; 490 after = ["network.target" "bluesky-pds.service"]; 491 wants = ["bluesky-pds.service"]; 492 wantedBy = ["multi-user.target"]; 493 494 serviceConfig = { 495 StateDirectory = "jetstream"; 496 Environment = [ 497 "JETSTREAM_WS_URL=ws://127.0.0.1:${toString pdsPort}/xrpc/com.atproto.sync.subscribeRepos" 498 "JETSTREAM_LISTEN_ADDR=:${toString jetstreamPort}" 499 "JETSTREAM_METRICS_LISTEN_ADDR=:6009" 500 "JETSTREAM_DATA_DIR=/var/lib/jetstream" 501 ]; 502 ExecStart = "${jetstream-pkg}/bin/jetstream"; 503 Restart = "on-failure"; 504 RestartSec = "5s"; 505 }; 506 }; 507 508 # Bootstrap: create dev account and write owner DID env file 509 tangled-bootstrap = { 510 description = "Tangled local dev bootstrap (account creation)"; 511 after = ["bluesky-pds.service"]; 512 wants = ["bluesky-pds.service"]; 513 wantedBy = ["multi-user.target"]; 514 515 serviceConfig = { 516 Type = "oneshot"; 517 RemainAfterExit = true; 518 StateDirectory = "tangled-dev"; 519 ExecStart = bootstrapScript; 520 }; 521 }; 522 }; 523 }) 524 ]; 525 }