{ nixpkgs, system, hostSystem, self, }: let envVar = name: let var = builtins.getEnv name; in if var == "" then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details" else var; envVarOr = name: default: let var = builtins.getEnv name; in if var != "" then var else default; # When true, run a local PLC directory, PDS, and Jetstream inside the VM # so that the entire stack works offline (after the initial nix build). localDev = (envVarOr "TANGLED_VM_LOCAL_DEV" "0") != "0"; plcUrl = if localDev then "http://localhost:2582" else envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; jetstream = if localDev then "ws://localhost:6008/subscribe" else envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; pdsPort = 2583; plcPort = 2582; jetstreamPort = 6008; # Static secrets for local development only — NOT for production use pdsAdminPassword = "tangled-local-dev"; pdsJwtSecret = "tangled-local-dev-jwt-secret-DO-NOT-USE-IN-PRODUCTION"; # A pre-generated secp256k1 private key for local dev PLC rotation key pdsPlcRotationKey = "b2e9683fd0e78a52f82c30e9c5f5e44fd55c6fb22e0f4e1ec92c54e1fe4a0509"; in nixpkgs.lib.nixosSystem { inherit system; modules = [ self.nixosModules.knot self.nixosModules.spindle ({ lib, config, pkgs, ... }: let did-plc-server = pkgs.callPackage ./pkgs/did-plc-server.nix {}; pds-local-dev = pkgs.callPackage ./pkgs/pds-local-dev.nix {}; jetstream-pkg = pkgs.callPackage ./pkgs/jetstream.nix {}; # Bootstrap script: creates a dev account on the PDS and writes # the owner DID to an environment file that knot/spindle load. bootstrapScript = pkgs.writeShellScript "tangled-bootstrap" '' set -euo pipefail ENV_FILE="/var/lib/tangled-dev/env" DID_FILE="/var/lib/tangled-dev/owner-did" ATPROTO_DID_FILE="/mnt/atproto/owner-did" curl="${pkgs.curl}/bin/curl" jq="${pkgs.jq}/bin/jq" PDS="http://localhost:${toString pdsPort}" # Check for DID from a previous session — either on VM disk or # in the 9p-persisted atproto directory (survives qcow2 deletion). existing_did="" if [[ -f "$DID_FILE" ]]; then existing_did="$(cat "$DID_FILE")" elif [[ -f "$ATPROTO_DID_FILE" ]]; then existing_did="$(cat "$ATPROTO_DID_FILE")" fi echo "tangled-bootstrap: waiting for PDS to be ready..." for i in $(seq 1 30); do if $curl -sf "$PDS/xrpc/_health" > /dev/null 2>&1; then break fi sleep 1 done if [[ -n "$existing_did" ]]; then echo "tangled-bootstrap: owner DID already exists: $existing_did" did="$existing_did" # Re-login to get a fresh accessJwt for label creation echo "tangled-bootstrap: logging in to get access token..." login_resp=$($curl -s -X POST \ -H "Content-Type: application/json" \ -d '{"identifier":"tangled-dev.test","password":"password"}' \ "$PDS/xrpc/com.atproto.server.createSession") access_jwt=$(echo "$login_resp" | $jq -r '.accessJwt') else echo "tangled-bootstrap: creating dev account..." resp=$($curl -s -X POST \ -H "Content-Type: application/json" \ -d '{"email":"dev@example.com","handle":"tangled-dev.test","password":"password"}' \ "$PDS/xrpc/com.atproto.server.createAccount") did=$(echo "$resp" | $jq -r '.did') access_jwt=$(echo "$resp" | $jq -r '.accessJwt') if [[ -z "$did" || "$did" == "null" ]]; then # Account might already exist (9p-persisted PDS with no DID file). # Try logging in instead. echo "tangled-bootstrap: createAccount failed, trying login..." login_resp=$($curl -s -X POST \ -H "Content-Type: application/json" \ -d '{"identifier":"tangled-dev.test","password":"password"}' \ "$PDS/xrpc/com.atproto.server.createSession") did=$(echo "$login_resp" | $jq -r '.did') access_jwt=$(echo "$login_resp" | $jq -r '.accessJwt') if [[ -z "$did" || "$did" == "null" ]]; then echo "tangled-bootstrap: ERROR: failed to create account or login" echo "createAccount response: $resp" echo "createSession response: $login_resp" exit 1 fi fi echo "$did" > "$DID_FILE" fi # Write DID to 9p mount so the host can read it echo "$did" > "$ATPROTO_DID_FILE" # Write environment file for knot and spindle printf 'KNOT_SERVER_OWNER=%s\nSPINDLE_SERVER_OWNER=%s\n' "$did" "$did" > "$ENV_FILE" # ── Create default label definitions ────────────────────────── if [[ -n "$access_jwt" && "$access_jwt" != "null" ]]; then echo "tangled-bootstrap: creating default label definitions..." now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") create_label() { local rkey="$1" name="$2" color="$3" vtype="$4" vformat="$5" multiple="$6" local scope='["sh.tangled.repo.issue","sh.tangled.repo.pull"]' local multiple_field="" if [[ "$multiple" == "true" ]]; then multiple_field=',"multiple":true' fi local record="{\"\$type\":\"sh.tangled.label.definition\",\"name\":\"$name\",\"color\":\"$color\",\"createdAt\":\"$now\",\"scope\":$scope,\"valueType\":{\"type\":\"$vtype\",\"format\":\"$vformat\"}$multiple_field}" local resp resp=$($curl -sf -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $access_jwt" \ -d "{\"repo\":\"$did\",\"collection\":\"sh.tangled.label.definition\",\"rkey\":\"$rkey\",\"record\":$record}" \ "$PDS/xrpc/com.atproto.repo.putRecord" 2>&1) || true if echo "$resp" | $jq -e '.uri' > /dev/null 2>&1; then echo " created: $rkey" else echo " $rkey: already exists or skipped" fi } create_label "wontfix" "wontfix" "#737373" "null" "any" "false" create_label "good-first-issue" "good first issue" "#22c55e" "null" "any" "false" create_label "duplicate" "duplicate" "#a855f7" "null" "any" "false" create_label "documentation" "documentation" "#3b82f6" "null" "any" "false" create_label "assignee" "assignee" "#f59e0b" "string" "did" "true" else echo "tangled-bootstrap: WARNING: no access token, skipping label creation" fi echo "" echo "========================================" echo " Tangled Local Dev Bootstrap Complete" echo " Owner DID: $did" echo " Handle: tangled-dev.test" echo " Password: password" echo " PDS: $PDS" echo "========================================" echo "" ''; in { virtualisation.vmVariant.virtualisation = { host.pkgs = import nixpkgs {system = hostSystem;}; graphics = false; memorySize = if localDev then 4096 else 2048; diskSize = 10 * 1024; cores = 2; forwardPorts = [ # ssh { from = "host"; host.port = 2222; guest.port = 22; } # knot { from = "host"; host.port = 6444; guest.port = 6444; } # spindle { from = "host"; host.port = 6555; guest.port = 6555; } ] ++ lib.optionals localDev [ # PLC directory { from = "host"; host.port = plcPort; guest.port = plcPort; } # PDS { from = "host"; host.port = pdsPort; guest.port = pdsPort; } # Jetstream { from = "host"; host.port = jetstreamPort; guest.port = jetstreamPort; } ]; sharedDirectories = { # We can't use the 9p mounts directly for most of these # as SQLite is incompatible with them. So instead we # mount the shared directories to a different location # and copy the contents around on service start/stop. knotData = { source = "$TANGLED_VM_DATA_DIR/knot"; target = "/mnt/knot-data"; }; spindleData = { source = "$TANGLED_VM_DATA_DIR/spindle"; target = "/mnt/spindle-data"; }; spindleLogs = { source = "$TANGLED_VM_DATA_DIR/spindle-logs"; target = "/var/log/spindle"; }; } // lib.optionalAttrs localDev { # AT Protocol state persistence: PLC (postgres), PDS (sqlite+blobs), # Jetstream (pebbledb), and the owner DID file. atprotoData = { source = "$TANGLED_VM_DATA_DIR/atproto"; target = "/mnt/atproto"; }; }; }; # 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 networking.firewall.enable = false; time.timeZone = "Europe/London"; services.getty.autologinUser = "root"; environment.systemPackages = with pkgs; [curl vim git sqlite litecli jq]; services.tangled.knot = { enable = true; motd = "Welcome to the development knot!\n"; server = { owner = if localDev then "bootstrap-pending" else envVar "TANGLED_VM_KNOT_OWNER"; hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6444"; plcUrl = plcUrl; jetstreamEndpoint = jetstream; listenAddr = "0.0.0.0:6444"; dev = localDev; }; }; services.tangled.spindle = { enable = true; server = { owner = if localDev then "bootstrap-pending" else envVar "TANGLED_VM_SPINDLE_OWNER"; hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; plcUrl = plcUrl; jetstreamEndpoint = jetstream; listenAddr = "0.0.0.0:6555"; dev = true; queueSize = 100; maxJobCount = 2; secrets = { provider = "sqlite"; }; }; }; # ── Local PLC + PDS + Jetstream (offline dev mode) ────────────── # # When TANGLED_VM_LOCAL_DEV=1, the VM runs a fully self-contained # AT Protocol stack: # PostgreSQL → PLC directory → PDS → Jetstream → Knot/Spindle # # A bootstrap service creates a dev account and writes the owner # DID to an environment file that knot/spindle load at startup. # PostgreSQL for PLC directory (persistent DID storage) services.postgresql = lib.mkIf localDev { enable = true; package = pkgs.postgresql_16; ensureDatabases = ["plc"]; ensureUsers = [ { name = "plc"; ensureDBOwnership = true; } ]; # Trust local connections (dev-only, no passwords needed) authentication = lib.mkForce '' # TYPE DATABASE USER ADDRESS METHOD local all all trust host all all 127.0.0.1/32 trust host all all ::1/128 trust ''; }; users = { # So we don't have to deal with permission clashing between # blank disk VMs and existing state users.${config.services.tangled.knot.gitUser}.uid = 666; groups.${config.services.tangled.knot.gitUser}.gid = 666; # TODO: separate spindle user }; systemd.services = let mkDataSyncScripts = source: target: { enableStrictShellChecks = true; preStart = lib.mkBefore '' mkdir -p ${target} ${lib.getExe pkgs.rsync} -a ${source}/ ${target} ''; postStop = lib.mkAfter '' ${lib.getExe pkgs.rsync} -a ${target}/ ${source} ''; serviceConfig.PermissionsStartOnly = true; }; # In localDev mode, knot/spindle depend on the bootstrap service # which creates a dev account and writes the owner DID to an env # file. Requires= ensures knot/spindle won't start if bootstrap # failed (e.g. PDS never came up). After= ensures ordering. localDevDeps = lib.optionalAttrs localDev { after = ["tangled-bootstrap.service"]; requires = ["tangled-bootstrap.service"]; serviceConfig.EnvironmentFile = ["/var/lib/tangled-dev/env"]; }; in { knot = (mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir) // localDevDeps; spindle = (mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath)) // localDevDeps; } // lib.optionalAttrs localDev { # ── AT Protocol state persistence ───────────────────────── # PDS, Jetstream, and PostgreSQL (PLC) data is synced to/from # the 9p mount at /mnt/atproto so it survives VM reboots. # DID stability across reboots is critical — knot/spindle # data references the owner DID. # # PostgreSQL (PLC) persistence via pg_dump/psql. # The qcow2 disk is the primary storage; the 9p dump is a # safety net so DID data survives qcow2 deletion. # # Boot: pg starts → setup creates DB → restore loads dump (if DB empty) # Shutdown: backup dumps → pg stops atproto-pg-restore = { description = "Restore PLC database from 9p dump"; after = ["postgresql.service" "postgresql-setup.service"]; requires = ["postgresql.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = pkgs.writeShellScript "atproto-pg-restore" '' dump="/mnt/atproto/plc.sql" if [ ! -f "$dump" ]; then echo "atproto-pg-restore: no dump file, skipping" exit 0 fi table_count=$(${pkgs.postgresql_16}/bin/psql -U plc -d plc -tAc \ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'") if [ "$table_count" != "0" ]; then echo "atproto-pg-restore: database already has $table_count tables, skipping" exit 0 fi echo "atproto-pg-restore: loading dump into empty database..." ${pkgs.postgresql_16}/bin/psql -U plc -d plc < "$dump" echo "atproto-pg-restore: done" ''; }; }; atproto-pg-backup = { description = "Backup PLC database to 9p dump"; after = ["postgresql.service"]; wants = ["postgresql.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; # ExecStart is a no-op; the real work is in ExecStop # which fires on shutdown while postgresql is still running. ExecStart = "${pkgs.coreutils}/bin/true"; ExecStop = pkgs.writeShellScript "atproto-pg-backup" '' echo "atproto-pg-backup: dumping PLC database..." ${pkgs.postgresql_16}/bin/pg_dump -U plc plc > /mnt/atproto/plc.sql echo "atproto-pg-backup: done ($(wc -c < /mnt/atproto/plc.sql) bytes)" ''; }; }; # PLC directory server plc-server = { description = "DID PLC Directory Server (local development)"; after = ["network.target" "atproto-pg-restore.service"]; wants = ["postgresql.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { Environment = [ "DATABASE_URL=postgres://plc@127.0.0.1/plc" "PORT=${toString plcPort}" "LOG_ENABLED=true" "LOG_LEVEL=info" "DEBUG_MODE=1" ]; ExecStart = "${did-plc-server}/bin/plc-server"; Restart = "on-failure"; RestartSec = "5s"; }; }; # Bluesky PDS (patched for HTTP local dev) bluesky-pds = (mkDataSyncScripts "/mnt/atproto/pds" "/var/lib/bluesky-pds") // { description = "Bluesky PDS (local development)"; after = ["network.target" "plc-server.service"]; wants = ["plc-server.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { StateDirectory = "bluesky-pds"; WorkingDirectory = "/var/lib/bluesky-pds"; Environment = [ "PDS_HOSTNAME=localhost" "PDS_PORT=${toString pdsPort}" "PDS_DATA_DIRECTORY=/var/lib/bluesky-pds" "PDS_DID_PLC_URL=http://localhost:${toString plcPort}" "PDS_BLOBSTORE_DISK_LOCATION=/var/lib/bluesky-pds/blobs" "PDS_JWT_SECRET=${pdsJwtSecret}" "PDS_ADMIN_PASSWORD=${pdsAdminPassword}" "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${pdsPlcRotationKey}" "PDS_CRAWLERS=" "PDS_INVITE_REQUIRED=false" "PDS_DEV_MODE=true" "PDS_LOG_ENABLED=true" "NODE_ENV=development" ]; ExecStart = "${pds-local-dev}/bin/pds"; Restart = "on-failure"; RestartSec = "5s"; }; }; # Jetstream (native Nix package — subscribes to local PDS firehose) jetstream = (mkDataSyncScripts "/mnt/atproto/jetstream" "/var/lib/jetstream") // { description = "AT Protocol Jetstream (local development)"; after = ["network.target" "bluesky-pds.service"]; wants = ["bluesky-pds.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { StateDirectory = "jetstream"; Environment = [ "JETSTREAM_WS_URL=ws://127.0.0.1:${toString pdsPort}/xrpc/com.atproto.sync.subscribeRepos" "JETSTREAM_LISTEN_ADDR=:${toString jetstreamPort}" "JETSTREAM_METRICS_LISTEN_ADDR=:6009" "JETSTREAM_DATA_DIR=/var/lib/jetstream" ]; ExecStart = "${jetstream-pkg}/bin/jetstream"; Restart = "on-failure"; RestartSec = "5s"; }; }; # Bootstrap: create dev account and write owner DID env file tangled-bootstrap = { description = "Tangled local dev bootstrap (account creation)"; after = ["bluesky-pds.service"]; wants = ["bluesky-pds.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; StateDirectory = "tangled-dev"; ExecStart = bootstrapScript; }; }; }; }) ]; }