heartwood every commit a ring
15.4 KB raw
<#
.SYNOPSIS
    Idempotent host-side setup for the bythewood-webdev Docker container.

.DESCRIPTION
    Brings a Windows 11 machine from "Docker Desktop installed + SSH key set up"
    to a fully running webdev container with restic credentials in place.

    The host stays minimal: this script never clones taproot to the host
    filesystem. Instead it uses one-shot ubuntu:24.04 helper containers (the
    same base image webdev itself is built from) to do the work directly
    against the bythewood-code volume:

        - clone taproot into the volume via SSH (using the host's home_key,
          mounted read-only)
        - build the webdev image using docker.sock to reach the host's daemon

    Each step inspects current state and skips if already done, so this is
    safe to re-run on a partially-configured machine.

    First-time machine setup (assumes Docker Desktop is running and you have
    an SSH key at $HOME/.ssh/home_key added to GitHub):

        irm https://raw.githubusercontent.com/overshard/taproot/master/containers/webdev/bootstrap.ps1 -OutFile bootstrap.ps1
        powershell -ExecutionPolicy Bypass -File .\bootstrap.ps1 laptop

    The Bypass flag is needed because PowerShell's default execution policy
    blocks scripts downloaded from the internet. It only applies to this one
    invocation; nothing on your system changes.

    Host-side dotfiles (Zed settings, ~/.ssh/config) are NOT managed by this
    script. After taproot is in the bythewood-code volume, copy them by hand:
        dotfiles/host/zed-settings.json -> $env:APPDATA\Zed\settings.json
        dotfiles/host/ssh-config        -> $HOME\.ssh\config

    Subsequent runs (from inside the cloned-in-volume taproot is fine; the
    script doesn't depend on its own location):

        .\bootstrap.ps1 desktop                # idempotent, all skips
        .\bootstrap.ps1 laptop -Force          # pull taproot, rebuild image, recreate container
        .\bootstrap.ps1 laptop -Restore        # restore data from B2 snapshot

.PARAMETER HostTag
    The restic --host tag for this machine. Use "desktop" or "laptop".

.PARAMETER Only
    Run only the named step. One of: prereqs, volumes, taproot, image,
    container, ssh, restic-password, b2-env, alpine-password, restore.

.PARAMETER Restore
    After all setup steps, run ~/scripts/restic-restore inside the container
    to pull data from the latest B2 snapshot. Opt-in.

.PARAMETER Force
    Pull latest taproot, rebuild the image, remove the existing container,
    create a new container from the new image. Volumes (and your data) are
    untouched.

.PARAMETER TaprootRepo
    SSH URL of the taproot repository. Default: git@github.com:overshard/taproot.git

.PARAMETER TaprootBranch
    Branch to clone/pull. Default: master
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory=$true, Position=0)]
    [ValidateSet("desktop", "laptop")]
    [string]$HostTag,

    [ValidateSet(
        "prereqs", "volumes", "taproot", "image", "container",
        "ssh", "restic-password", "b2-env", "alpine-password", "restore"
    )]
    [string]$Only,

    [switch]$Restore,
    [switch]$Force,

    [string]$TaprootRepo = "git@github.com:overshard/taproot.git",
    [string]$TaprootBranch = "master"
)

$ErrorActionPreference = "Stop"

$ImageName = "overshard/webdev:latest"
$ContainerName = "bythewood-webdev"
$Volumes = @("bythewood-code", "bythewood-claude", "bythewood-ssh", "bythewood-restic")
$HelperImage = "ubuntu:24.04"
$HostKeyPath = Join-Path $HOME ".ssh\home_key"
$HostKeyPubPath = Join-Path $HOME ".ssh\home_key.pub"

function Step-Banner { param([string]$Name) Write-Host ""; Write-Host "=== $Name ===" -ForegroundColor Cyan }
function Done   { param([string]$M) Write-Host "  [done] $M" -ForegroundColor Green }
function Skip   { param([string]$M) Write-Host "  [skip] $M" -ForegroundColor DarkGray }
function Warn   { param([string]$M) Write-Host "  [warn] $M" -ForegroundColor Yellow }
function Fail   { param([string]$M) Write-Host "  [fail] $M" -ForegroundColor Red; exit 1 }

function Should-Run { param([string]$Name) return (-not $Only) -or ($Only -eq $Name) }

# ---------------------------------------------------------------------------
function Step-Prereqs {
    Step-Banner "prereqs"
    try {
        $null = docker version --format '{{.Server.Version}}' 2>$null
        Done "Docker Desktop is running"
    } catch {
        Fail "Docker Desktop is not running. Start it and re-run."
    }

    if (-not (Test-Path $HostKeyPath) -or -not (Test-Path $HostKeyPubPath)) {
        Fail @"
SSH key not found at $HostKeyPath (.pub).
Set up your keys first:
    ssh-keygen -t ed25519 -f `"$HostKeyPath`" -C bythewood-webdev
Then add the public key to GitHub:
    Get-Content `"$HostKeyPubPath`" | clip
Paste at https://github.com/settings/ssh/new
"@
    }
    Done "SSH key found at $HostKeyPath"
}

# ---------------------------------------------------------------------------
function Step-Volumes {
    Step-Banner "volumes"
    foreach ($v in $Volumes) {
        docker volume inspect $v 2>$null 1>$null
        if ($LASTEXITCODE -eq 0) {
            Skip "$v exists"
        } else {
            docker volume create --name $v | Out-Null
            Done "created $v"
        }
    }
}

# ---------------------------------------------------------------------------
function Invoke-Helper-Clone {
    param([string]$Action) # "clone" or "pull"

    # Run git as UID 1001 to match webdev's `dev` user. Why 1001: ubuntu:24.04
    # ships with a default `ubuntu` user at UID 1000, so when webdev's
    # Dockerfile does `useradd dev` it gets UID 1001 (next available). Files
    # created here as 1001 match dev inside webdev with no chown gymnastics.
    #
    # The host SSH key is bind-mounted read-only at /keys/home_key. Windows
    # NTFS has no unix mode to copy, so it lands at 0777 which sshd refuses.
    # We copy it into the helper user's $HOME and chmod 600 before invoking git.
    $gitOp = if ($Action -eq "clone") {
        "git clone --branch $TaprootBranch $TaprootRepo /code/taproot"
    } else {
        "cd /code/taproot && git fetch --all --prune && git pull --ff-only"
    }

    $script = @"
set -e
apt-get update >/dev/null
apt-get install -y --no-install-recommends git openssh-client sudo >/dev/null
id -u devhelper >/dev/null 2>&1 || useradd -u 1001 -m -d /home/devhelper -s /bin/sh devhelper
mkdir -p /home/devhelper/.ssh
cp /keys/home_key /home/devhelper/.ssh/home_key
chmod 700 /home/devhelper/.ssh
chmod 600 /home/devhelper/.ssh/home_key
chown -R 1001:1001 /home/devhelper /code
sudo -u devhelper -E env GIT_SSH_COMMAND='ssh -i /home/devhelper/.ssh/home_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/known_hosts' sh -c '$gitOp'
"@

    docker run --rm `
        --volume "${HostKeyPath}:/keys/home_key:ro" `
        --volume "bythewood-code:/code" `
        $HelperImage `
        sh -c $script

    if ($LASTEXITCODE -ne 0) { Fail "helper container failed during '$Action'" }
}

function Step-Taproot {
    Step-Banner "taproot"
    docker run --rm --volume "bythewood-code:/code" $HelperImage `
        test -d /code/taproot/.git 2>$null
    $exists = ($LASTEXITCODE -eq 0)

    if (-not $exists) {
        Done "cloning $TaprootRepo into bythewood-code volume"
        Invoke-Helper-Clone -Action "clone"
        return
    }

    if ($Force) {
        Done "pulling latest $TaprootBranch into existing /code/taproot"
        Invoke-Helper-Clone -Action "pull"
        return
    }

    Skip "taproot already in bythewood-code volume (use -Force to git pull)"
}

# ---------------------------------------------------------------------------
function Step-Image {
    Step-Banner "image"
    docker image inspect $ImageName 2>$null 1>$null
    $exists = ($LASTEXITCODE -eq 0)

    if ($exists -and -not $Force) {
        Skip "$ImageName already built (use -Force to rebuild)"
        return
    }

    Done "building $ImageName from /code/taproot in bythewood-code"
    docker run --rm `
        --volume "bythewood-code:/code" `
        --volume "/var/run/docker.sock:/var/run/docker.sock" `
        $HelperImage `
        sh -c "set -e; apt-get update >/dev/null && apt-get install -y --no-install-recommends docker.io >/dev/null && cd /code/taproot && docker build --tag $ImageName -f containers/webdev/Dockerfile ."

    if ($LASTEXITCODE -ne 0) { Fail "image build failed" }
}

# ---------------------------------------------------------------------------
function Step-Container {
    Step-Banner "container"
    docker container inspect $ContainerName 2>$null 1>$null
    $exists = ($LASTEXITCODE -eq 0)

    if ($exists -and $Force) {
        Done "removing existing container (-Force)"
        docker rm -f $ContainerName | Out-Null
        $exists = $false
    }

    if ($exists) {
        $running = (docker container inspect -f '{{.State.Running}}' $ContainerName)
        if ($running -eq "true") {
            Skip "$ContainerName running"
        } else {
            docker start $ContainerName | Out-Null
            Done "started existing $ContainerName"
        }
        return
    }

    Done "creating $ContainerName"
    $dockerArgs = @(
        "run", "--detach", "--init", "--restart", "unless-stopped",
        "--name", $ContainerName,
        "--volume", "bythewood-code:/home/dev/code",
        "--volume", "bythewood-claude:/home/dev/.claude",
        "--volume", "bythewood-ssh:/home/dev/.ssh",
        "--volume", "bythewood-restic:/home/dev/.restic",
        "--volume", "/var/run/docker.sock:/var/run/docker.sock",
        "-p", "8000:8000",
        "-p", "2222:22",
        $ImageName
    )
    docker @dockerArgs | Out-Null
    if ($LASTEXITCODE -ne 0) { Fail "docker run failed" }
}

# ---------------------------------------------------------------------------
function Step-Ssh {
    Step-Banner "ssh"
    docker exec $ContainerName test -f /home/dev/.ssh/home_key 2>$null
    $keyOk = ($LASTEXITCODE -eq 0)
    docker exec $ContainerName test -f /home/dev/.ssh/home_key.pub 2>$null
    $pubOk = ($LASTEXITCODE -eq 0)

    if ($keyOk -and $pubOk) {
        Skip "home_key already in volume"
    } else {
        docker cp $HostKeyPath "${ContainerName}:/home/dev/.ssh/home_key" | Out-Null
        docker cp $HostKeyPubPath "${ContainerName}:/home/dev/.ssh/home_key.pub" | Out-Null
        Done "copied home_key + home_key.pub into bythewood-ssh volume"
    }

    # Always (re)apply ownership and perms. docker cp from Windows hosts loses
    # mode info, and pre-existing keys from earlier manual setups may have been
    # left at 0777, which sshd refuses ("unprotected private key file").
    docker exec $ContainerName sudo chown dev:dev /home/dev/.ssh/home_key /home/dev/.ssh/home_key.pub | Out-Null
    docker exec $ContainerName sudo chmod 600 /home/dev/.ssh/home_key | Out-Null
    docker exec $ContainerName sudo chmod 644 /home/dev/.ssh/home_key.pub | Out-Null
    Done "verified perms (key 600, pub 644)"
}

# ---------------------------------------------------------------------------
function Read-Secret { param([string]$Prompt)
    $sec = Read-Host -AsSecureString -Prompt $Prompt
    return [System.Net.NetworkCredential]::new("", $sec).Password
}

function Write-File-Into-Container { param([string]$Path, [string]$Content)
    $tmp = New-TemporaryFile
    try {
        $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Content)
        [System.IO.File]::WriteAllBytes($tmp.FullName, $bytes)
        docker cp $tmp.FullName "${ContainerName}:${Path}" | Out-Null
        docker exec $ContainerName sudo chown dev:dev $Path | Out-Null
        docker exec $ContainerName chmod 600 $Path | Out-Null
    } finally {
        Remove-Item $tmp.FullName -Force
    }
}

# ---------------------------------------------------------------------------
function Step-ResticPassword {
    Step-Banner "restic-password"
    docker exec $ContainerName test -s /home/dev/.restic/password 2>$null
    if ($LASTEXITCODE -eq 0) {
        Skip "/home/dev/.restic/password already set"
        return
    }
    $pw = Read-Secret "Restic webdev repo password (paste from 1Password)"
    if (-not $pw) { Fail "empty password" }
    Write-File-Into-Container "/home/dev/.restic/password" "$pw`n"
    Done "wrote /home/dev/.restic/password"
}

# ---------------------------------------------------------------------------
function Step-B2Env {
    Step-Banner "b2-env"
    docker exec $ContainerName test -s /home/dev/.restic/b2-env 2>$null
    $exists = ($LASTEXITCODE -eq 0)

    if ($exists) {
        $currentText = (docker exec $ContainerName cat /home/dev/.restic/b2-env) -join "`n"
        if ($currentText -match 'RESTIC_HOST=["]?([^"\r\n]+)["]?') {
            $existingHost = $matches[1].Trim('"')
            if ($existingHost -eq $HostTag) {
                Skip "/home/dev/.restic/b2-env complete (RESTIC_HOST=$existingHost)"
            } else {
                Warn "/home/dev/.restic/b2-env has RESTIC_HOST=$existingHost (expected $HostTag)."
                Warn "Not modifying. Edit manually if wrong: docker exec -it $ContainerName nvim /home/dev/.restic/b2-env"
            }
            return
        }
        Done "appending RESTIC_HOST=$HostTag to existing b2-env"
        $newContent = $currentText.TrimEnd("`r","`n") + "`nexport RESTIC_HOST=`"$HostTag`"`n"
        Write-File-Into-Container "/home/dev/.restic/b2-env" $newContent
        return
    }

    $accountId = Read-Secret "B2 account ID"
    $accountKey = Read-Secret "B2 account key"
    if (-not $accountId -or -not $accountKey) { Fail "empty B2 credential" }
    $content = @"
export B2_ACCOUNT_ID="$accountId"
export B2_ACCOUNT_KEY="$accountKey"
export RESTIC_HOST="$HostTag"
"@
    Write-File-Into-Container "/home/dev/.restic/b2-env" "$content`n"
    Done "wrote /home/dev/.restic/b2-env with RESTIC_HOST=$HostTag"
}

# ---------------------------------------------------------------------------
function Step-AlpinePassword {
    Step-Banner "alpine-password"
    docker exec $ContainerName test -s /home/dev/.restic/alpine-password 2>$null
    if ($LASTEXITCODE -eq 0) {
        Skip "/home/dev/.restic/alpine-password already set"
        return
    }
    Write-Host "  Optional: lets ~/scripts/restic-status query the alpine repo too." -ForegroundColor DarkGray
    $pw = Read-Secret "Alpine repo password (or empty to skip)"
    if (-not $pw) {
        Skip "no value provided"
        return
    }
    Write-File-Into-Container "/home/dev/.restic/alpine-password" "$pw`n"
    Done "wrote /home/dev/.restic/alpine-password"
}

# ---------------------------------------------------------------------------
function Step-Restore {
    Step-Banner "restore"
    if (-not $Restore -and $Only -ne "restore") {
        Skip "use -Restore to pull data from B2"
        return
    }
    Done "running ~/scripts/restic-restore inside container"
    docker exec -it $ContainerName /home/dev/scripts/restic-restore
}

# ---------------------------------------------------------------------------
if (Should-Run "prereqs")          { Step-Prereqs }
if (Should-Run "volumes")          { Step-Volumes }
if (Should-Run "taproot")          { Step-Taproot }
if (Should-Run "image")            { Step-Image }
if (Should-Run "container")        { Step-Container }
if (Should-Run "ssh")              { Step-Ssh }
if (Should-Run "restic-password")  { Step-ResticPassword }
if (Should-Run "b2-env")           { Step-B2Env }
if (Should-Run "alpine-password")  { Step-AlpinePassword }
if (Should-Run "restore")          { Step-Restore }

Write-Host ""
Write-Host "Done." -ForegroundColor Green
Write-Host "Connect with:  docker exec -it $ContainerName tmux"
Write-Host "Or via SSH:    ssh -p 2222 dev@localhost"