feat: support AWS desktop targets
This commit is contained in:
parent
d79cba3fa5
commit
ab7fc6fd71
@ -183,7 +183,7 @@ func (c *AWSClient) createServer(ctx context.Context, cfg Config, publicKey, lea
|
||||
name := leaseProviderName(leaseID, slug)
|
||||
now := time.Now().UTC()
|
||||
labels := directLeaseLabels(cfg, leaseID, slug, "aws", mapMarket(spot), keep, now)
|
||||
userData := base64.StdEncoding.EncodeToString([]byte(cloudInit(cfg, publicKey)))
|
||||
userData := base64.StdEncoding.EncodeToString([]byte(awsUserData(cfg, publicKey)))
|
||||
rootGB := cfg.AWSRootGB
|
||||
if rootGB <= 0 {
|
||||
rootGB = 400
|
||||
@ -240,6 +240,9 @@ func (c *AWSClient) createServer(ctx context.Context, cfg Config, publicKey, lea
|
||||
},
|
||||
}
|
||||
}
|
||||
if cfg.TargetOS == targetMacOS {
|
||||
input.Placement = &types.Placement{HostId: aws.String(cfg.AWSMacHostID), Tenancy: types.TenancyHost}
|
||||
}
|
||||
out, err := c.ec2.RunInstances(ctx, input)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
@ -308,6 +311,15 @@ func (c *AWSClient) resolveAMI(ctx context.Context, cfg Config) (string, error)
|
||||
if cfg.AWSAMI != "" {
|
||||
return cfg.AWSAMI, nil
|
||||
}
|
||||
if cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeNormal {
|
||||
return "resolve:ssm:/aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base", nil
|
||||
}
|
||||
if cfg.TargetOS == targetMacOS {
|
||||
if strings.HasPrefix(cfg.ServerType, "mac1.") {
|
||||
return "resolve:ssm:/aws/service/ec2-macos/sonoma/x86_64_mac/latest/image_id", nil
|
||||
}
|
||||
return "resolve:ssm:/aws/service/ec2-macos/sonoma/arm64_mac/latest/image_id", nil
|
||||
}
|
||||
out, err := c.ec2.DescribeImages(ctx, &ec2.DescribeImagesInput{
|
||||
Owners: []string{awsUbuntuOwner},
|
||||
Filters: []types.Filter{
|
||||
@ -494,7 +506,14 @@ func awsLaunchCandidates(cfg Config) []string {
|
||||
if cfg.ServerTypeExplicit {
|
||||
return []string{cfg.ServerType}
|
||||
}
|
||||
return appendUniqueStrings([]string{cfg.ServerType}, append(awsInstanceTypeCandidatesForClass(cfg.Class), "t3.small")...)
|
||||
if cfg.TargetOS == targetMacOS {
|
||||
return appendUniqueStrings([]string{cfg.ServerType}, awsInstanceTypeCandidatesForTargetClass(cfg.TargetOS, cfg.Class)...)
|
||||
}
|
||||
fallback := "t3.small"
|
||||
if cfg.TargetOS == targetWindows {
|
||||
fallback = "t3.large"
|
||||
}
|
||||
return appendUniqueStrings([]string{cfg.ServerType}, append(awsInstanceTypeCandidatesForTargetClass(cfg.TargetOS, cfg.Class), fallback)...)
|
||||
}
|
||||
|
||||
func parsePort32(port string) (int32, bool) {
|
||||
|
||||
@ -5,6 +5,22 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
tightVNCMSIURL = "https://www.tightvnc.com/download/2.8.85/tightvnc-2.8.85-gpl-setup-64bit.msi"
|
||||
gitForWindowsSetupURL = "https://github.com/git-for-windows/git/releases/download/v2.52.0.windows.1/Git-2.52.0-64-bit.exe"
|
||||
)
|
||||
|
||||
func awsUserData(cfg Config, publicKey string) string {
|
||||
switch cfg.TargetOS {
|
||||
case targetWindows:
|
||||
return windowsUserData(cfg, publicKey)
|
||||
case targetMacOS:
|
||||
return macOSUserData(cfg, publicKey)
|
||||
default:
|
||||
return cloudInit(cfg, publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func cloudInit(cfg Config, publicKey string) string {
|
||||
portLines := ""
|
||||
for _, port := range sshPortCandidates(cfg.SSHPort, cfg.SSHFallbackPorts) {
|
||||
@ -75,6 +91,115 @@ runcmd:
|
||||
`, cfg.SSHUser, publicKey, cfg.WorkRoot, portLines, readyChecks, writeFiles, bootstrap)
|
||||
}
|
||||
|
||||
func windowsUserData(cfg Config, publicKey string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = `C:\crabbox`
|
||||
}
|
||||
return `<powershell>
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
function Retry($ScriptBlock) {
|
||||
for ($i = 1; $i -le 8; $i++) {
|
||||
try { & $ScriptBlock; return }
|
||||
catch {
|
||||
if ($i -eq 8) { throw }
|
||||
Start-Sleep -Seconds ($i * 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
$user = ` + psQuote(cfg.SSHUser) + `
|
||||
$publicKey = ` + psQuote(publicKey) + `
|
||||
$workRoot = ` + psQuote(workRoot) + `
|
||||
$vncPasswordPath = "C:\ProgramData\crabbox\vnc.password"
|
||||
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
|
||||
$tightVNCInstaller = "$env:TEMP\tightvnc-2.8.85-gpl-setup-64bit.msi"
|
||||
New-Item -ItemType Directory -Force -Path "C:\ProgramData\crabbox", $workRoot | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
|
||||
$bytes = New-Object byte[] 12
|
||||
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
||||
[Convert]::ToBase64String($bytes).Substring(0, 12) | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
|
||||
}
|
||||
$userPassword = [Guid]::NewGuid().ToString("N") + "aA1!"
|
||||
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
|
||||
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
|
||||
}
|
||||
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
|
||||
$ssh = Get-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
||||
if ($ssh.State -ne "Installed") {
|
||||
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 | Out-Null
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path "$env:ProgramData\ssh" | Out-Null
|
||||
Set-Content -Encoding ASCII -Path "$env:ProgramData\ssh\administrators_authorized_keys" -Value $publicKey
|
||||
icacls.exe "$env:ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
if (-not (Get-NetFirewallRule -Name "crabbox-sshd" -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -Name "crabbox-sshd" -DisplayName "Crabbox OpenSSH" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null
|
||||
}
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
Start-Service sshd
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\Git\cmd\git.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(gitForWindowsSetupURL) + ` -OutFile $gitInstaller -UseBasicParsing }
|
||||
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
|
||||
}
|
||||
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
foreach ($path in @("C:\Program Files\Git\cmd", "C:\Program Files\Git\usr\bin")) {
|
||||
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
|
||||
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\TightVNC\tvnserver.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(tightVNCMSIURL) + ` -OutFile $tightVNCInstaller -UseBasicParsing }
|
||||
$vncPassword = Get-Content -Raw -Path $vncPasswordPath
|
||||
Start-Process -FilePath msiexec.exe -ArgumentList @(
|
||||
"/i", $tightVNCInstaller, "/quiet", "/norestart",
|
||||
"ADDLOCAL=Server",
|
||||
"SERVER_REGISTER_AS_SERVICE=1",
|
||||
"SERVER_ADD_FIREWALL_EXCEPTION=0",
|
||||
"SET_USEVNCAUTHENTICATION=1", "VALUE_OF_USEVNCAUTHENTICATION=1",
|
||||
"SET_PASSWORD=1", "VALUE_OF_PASSWORD=$vncPassword",
|
||||
"SET_USECONTROLAUTHENTICATION=1", "VALUE_OF_USECONTROLAUTHENTICATION=1",
|
||||
"SET_CONTROLPASSWORD=1", "VALUE_OF_CONTROLPASSWORD=$vncPassword",
|
||||
"SET_ALLOWLOOPBACK=1", "VALUE_OF_ALLOWLOOPBACK=1",
|
||||
"SET_ACCEPTHTTPCONNECTIONS=1", "VALUE_OF_ACCEPTHTTPCONNECTIONS=0"
|
||||
) -Wait
|
||||
}
|
||||
Get-Service -Name tvnserver -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic
|
||||
Start-Service -Name tvnserver -ErrorAction SilentlyContinue
|
||||
Restart-Service sshd
|
||||
</powershell>`
|
||||
}
|
||||
|
||||
func macOSUserData(cfg Config, _ string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = "/work/crabbox"
|
||||
}
|
||||
return `#!/bin/bash
|
||||
set -euxo pipefail
|
||||
install -d -m 0755 ` + shellQuote(workRoot) + ` /var/db/crabbox
|
||||
chown -R ` + shellQuote(cfg.SSHUser) + `:staff ` + shellQuote(workRoot) + `
|
||||
if [ ! -s /var/db/crabbox/vnc.password ]; then
|
||||
pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
|
||||
printf '%s\n' "$pw" >/var/db/crabbox/vnc.password
|
||||
dscl . -passwd /Users/` + shellQuote(cfg.SSHUser) + ` "$pw"
|
||||
fi
|
||||
chmod 0600 /var/db/crabbox/vnc.password
|
||||
launchctl enable system/com.apple.screensharing || true
|
||||
launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist || true
|
||||
cat >/usr/local/bin/crabbox-ready <<'READY'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
rsync --version >/dev/null
|
||||
curl --version >/dev/null
|
||||
test -w ` + shellQuote(workRoot) + `
|
||||
nc -z 127.0.0.1 5900
|
||||
READY
|
||||
chmod 0755 /usr/local/bin/crabbox-ready
|
||||
/usr/local/bin/crabbox-ready
|
||||
`
|
||||
}
|
||||
|
||||
func cloudInitOptionalReadyChecks(cfg Config) string {
|
||||
var b strings.Builder
|
||||
if cfg.Desktop {
|
||||
|
||||
@ -9,10 +9,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
desktopDisplay = ":99"
|
||||
managedVNCPort = "5900"
|
||||
vncPasswordPath = "/var/lib/crabbox/vnc.password"
|
||||
browserEnvPath = "/var/lib/crabbox/browser.env"
|
||||
desktopDisplay = ":99"
|
||||
managedVNCPort = "5900"
|
||||
vncPasswordPath = "/var/lib/crabbox/vnc.password"
|
||||
windowsVNCPasswordPath = `C:\ProgramData\crabbox\vnc.password`
|
||||
macOSVNCPasswordPath = "/var/db/crabbox/vnc.password"
|
||||
browserEnvPath = "/var/lib/crabbox/browser.env"
|
||||
)
|
||||
|
||||
type vncEndpoint struct {
|
||||
@ -237,6 +239,16 @@ if (-not $result.TcpTestSucceeded) { exit 1 }`)
|
||||
return "ss -ltn | grep -q '127.0.0.1:5900'"
|
||||
}
|
||||
|
||||
func vncPasswordCommand(target SSHTarget) string {
|
||||
if isWindowsNativeTarget(target) {
|
||||
return powershellCommand("Get-Content -Raw -LiteralPath " + psQuote(windowsVNCPasswordPath))
|
||||
}
|
||||
if target.TargetOS == targetMacOS {
|
||||
return "cat " + shellQuote(macOSVNCPasswordPath)
|
||||
}
|
||||
return "cat " + shellQuote(vncPasswordPath)
|
||||
}
|
||||
|
||||
func tcpReachable(ctx context.Context, host, port string, timeout time.Duration) bool {
|
||||
if host == "" || port == "" {
|
||||
return false
|
||||
|
||||
@ -35,6 +35,7 @@ type Config struct {
|
||||
AWSProfile string
|
||||
AWSRootGB int32
|
||||
AWSSSHCIDRs []string
|
||||
AWSMacHostID string
|
||||
SSHUser string
|
||||
SSHKey string
|
||||
SSHPort string
|
||||
@ -145,7 +146,7 @@ func loadConfig() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.ServerType == "" {
|
||||
cfg.ServerType = serverTypeForProviderClass(cfg.Provider, cfg.Class)
|
||||
cfg.ServerType = serverTypeForConfig(cfg)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@ -271,6 +272,7 @@ type fileAWSConfig struct {
|
||||
InstanceProfile string `yaml:"instanceProfile,omitempty"`
|
||||
RootGB int32 `yaml:"rootGB,omitempty"`
|
||||
SSHCIDRs []string `yaml:"sshCIDRs,omitempty"`
|
||||
MacHostID string `yaml:"macHostId,omitempty"`
|
||||
}
|
||||
|
||||
type fileSSHConfig struct {
|
||||
@ -543,6 +545,9 @@ func applyFileConfig(cfg *Config, file fileConfig) {
|
||||
if len(file.AWS.SSHCIDRs) > 0 {
|
||||
cfg.AWSSSHCIDRs = file.AWS.SSHCIDRs
|
||||
}
|
||||
if file.AWS.MacHostID != "" {
|
||||
cfg.AWSMacHostID = file.AWS.MacHostID
|
||||
}
|
||||
}
|
||||
if file.SSH != nil {
|
||||
if file.SSH.User != "" {
|
||||
@ -754,6 +759,7 @@ func applyEnv(cfg *Config) {
|
||||
cfg.AWSSubnetID = getenv("CRABBOX_AWS_SUBNET_ID", cfg.AWSSubnetID)
|
||||
cfg.AWSProfile = getenv("CRABBOX_AWS_INSTANCE_PROFILE", cfg.AWSProfile)
|
||||
cfg.AWSRootGB = int32(getenvInt("CRABBOX_AWS_ROOT_GB", int(cfg.AWSRootGB)))
|
||||
cfg.AWSMacHostID = getenv("CRABBOX_AWS_MAC_HOST_ID", cfg.AWSMacHostID)
|
||||
if cidrs := os.Getenv("CRABBOX_AWS_SSH_CIDRS"); cidrs != "" {
|
||||
cfg.AWSSSHCIDRs = splitCommaList(cidrs)
|
||||
}
|
||||
@ -876,6 +882,16 @@ func serverTypeForClass(class string) string {
|
||||
return serverTypeCandidatesForClass(class)[0]
|
||||
}
|
||||
|
||||
func serverTypeForConfig(cfg Config) string {
|
||||
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
|
||||
return ""
|
||||
}
|
||||
if cfg.Provider == "aws" {
|
||||
return awsInstanceTypeCandidatesForTargetClass(cfg.TargetOS, cfg.Class)[0]
|
||||
}
|
||||
return serverTypeForClass(cfg.Class)
|
||||
}
|
||||
|
||||
func serverTypeForProviderClass(provider, class string) string {
|
||||
if isBlacksmithProvider(provider) || isStaticProvider(provider) {
|
||||
return ""
|
||||
@ -901,6 +917,28 @@ func serverTypeCandidatesForClass(class string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func awsInstanceTypeCandidatesForTargetClass(target, class string) []string {
|
||||
switch target {
|
||||
case targetMacOS:
|
||||
return []string{"mac2.metal"}
|
||||
case targetWindows:
|
||||
switch class {
|
||||
case "standard":
|
||||
return []string{"m7i.large", "m7a.large", "t3.large"}
|
||||
case "fast":
|
||||
return []string{"m7i.xlarge", "m7a.xlarge", "t3.xlarge"}
|
||||
case "large":
|
||||
return []string{"m7i.2xlarge", "m7a.2xlarge", "t3.2xlarge"}
|
||||
case "beast":
|
||||
return []string{"m7i.4xlarge", "m7a.4xlarge", "m7i.2xlarge"}
|
||||
default:
|
||||
return []string{class}
|
||||
}
|
||||
default:
|
||||
return awsInstanceTypeCandidatesForClass(class)
|
||||
}
|
||||
}
|
||||
|
||||
func awsInstanceTypeCandidatesForClass(class string) []string {
|
||||
switch class {
|
||||
case "standard":
|
||||
|
||||
@ -138,8 +138,8 @@ ssh:
|
||||
if cfg.TargetOS != targetWindows || cfg.WindowsMode != windowsModeWSL2 {
|
||||
t.Fatalf("target config not loaded: target=%s windowsMode=%s", cfg.TargetOS, cfg.WindowsMode)
|
||||
}
|
||||
if cfg.ServerType != "c7a.8xlarge" {
|
||||
t.Fatalf("ServerType=%q want c7a.8xlarge", cfg.ServerType)
|
||||
if cfg.ServerType != "m7i.large" {
|
||||
t.Fatalf("ServerType=%q want m7i.large", cfg.ServerType)
|
||||
}
|
||||
if cfg.Coordinator != "https://crabbox.example.test" || cfg.CoordToken != "secret" || cfg.CoordAdminToken != "admin-secret" {
|
||||
t.Fatalf("broker config not loaded: %#v", cfg)
|
||||
|
||||
@ -320,6 +320,7 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
|
||||
"awsProfile": cfg.AWSProfile,
|
||||
"awsRootGB": cfg.AWSRootGB,
|
||||
"awsSSHCIDRs": cfg.AWSSSHCIDRs,
|
||||
"awsMacHostID": cfg.AWSMacHostID,
|
||||
"capacity": map[string]any{
|
||||
"market": cfg.Capacity.Market,
|
||||
"strategy": cfg.Capacity.Strategy,
|
||||
|
||||
@ -44,8 +44,8 @@ func (a App) screenshot(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.TargetOS != targetLinux {
|
||||
return exit(2, "desktop screenshots are currently supported for Linux desktop leases only; target=%s is not a Crabbox-created desktop", target.TargetOS)
|
||||
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
|
||||
return exit(2, "desktop screenshots are not captured from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
|
||||
}
|
||||
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
|
||||
return err
|
||||
@ -98,7 +98,7 @@ func captureDesktopScreenshot(ctx context.Context, target SSHTarget, outputPath
|
||||
_ = os.Remove(outputPath)
|
||||
}
|
||||
}()
|
||||
if err := runSSHToWriter(ctx, target, screenshotRemoteCommand(), file); err != nil {
|
||||
if err := runSSHToWriter(ctx, target, screenshotRemoteCommand(target), file); err != nil {
|
||||
return exit(5, "capture screenshot: %v", err)
|
||||
}
|
||||
ok = true
|
||||
@ -121,7 +121,32 @@ func runSSHToWriter(ctx context.Context, target SSHTarget, remote string, stdout
|
||||
return nil
|
||||
}
|
||||
|
||||
func screenshotRemoteCommand() string {
|
||||
func screenshotRemoteCommand(target SSHTarget) string {
|
||||
if isWindowsNativeTarget(target) {
|
||||
return `$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
||||
$stream = New-Object System.IO.MemoryStream
|
||||
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bytes = $stream.ToArray()
|
||||
[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
$stream.Dispose()`
|
||||
}
|
||||
if target.TargetOS == targetMacOS {
|
||||
return `set -eu
|
||||
if command -v screencapture >/dev/null 2>&1; then
|
||||
screencapture -x -t png -
|
||||
else
|
||||
echo "no screenshot tool found; EC2 macOS should provide screencapture" >&2
|
||||
exit 127
|
||||
fi`
|
||||
}
|
||||
return `set -eu
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
if command -v scrot >/dev/null 2>&1; then
|
||||
|
||||
@ -15,7 +15,7 @@ func TestDefaultScreenshotPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScreenshotRemoteCommandUsesDesktopDisplayAndPNG(t *testing.T) {
|
||||
got := screenshotRemoteCommand()
|
||||
got := screenshotRemoteCommand(SSHTarget{TargetOS: targetLinux})
|
||||
for _, want := range []string{
|
||||
`DISPLAY="${DISPLAY:-:99}"`,
|
||||
"command -v scrot",
|
||||
|
||||
@ -24,6 +24,9 @@ func normalizeTargetConfig(cfg *Config) {
|
||||
cfg.WorkRoot = `C:\crabbox`
|
||||
}
|
||||
}
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS && cfg.SSHUser == baseConfig().SSHUser {
|
||||
cfg.SSHUser = "ec2-user"
|
||||
}
|
||||
if cfg.Static.User != "" && cfg.SSHUser == baseConfig().SSHUser {
|
||||
cfg.SSHUser = cfg.Static.User
|
||||
}
|
||||
@ -82,6 +85,18 @@ func validateProviderTarget(cfg Config) error {
|
||||
if isStaticProvider(cfg.Provider) || isBlacksmithProvider(cfg.Provider) {
|
||||
return nil
|
||||
}
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeNormal {
|
||||
return nil
|
||||
}
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS {
|
||||
if cfg.AWSMacHostID == "" {
|
||||
return exit(2, "provider=aws target=macos requires CRABBOX_AWS_MAC_HOST_ID or aws.macHostId for an allocated EC2 Mac Dedicated Host")
|
||||
}
|
||||
if cfg.Capacity.Market != "on-demand" {
|
||||
return exit(2, "provider=aws target=macos requires --market on-demand; EC2 Mac instances are not Spot")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cfg.TargetOS != targetLinux {
|
||||
return exit(2, "provider=%s currently supports target=linux only; use provider=ssh for target=%s", cfg.Provider, cfg.TargetOS)
|
||||
}
|
||||
|
||||
@ -5,15 +5,36 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateProviderTargetRejectsBrokeredNonLinux(t *testing.T) {
|
||||
for _, target := range []string{targetMacOS, targetWindows} {
|
||||
func TestValidateProviderTargetRejectsUnsupportedAWSTargets(t *testing.T) {
|
||||
t.Run("macOS needs dedicated host", func(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "aws"
|
||||
cfg.TargetOS = target
|
||||
cfg.TargetOS = targetMacOS
|
||||
err := validateProviderTarget(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires CRABBOX_AWS_MAC_HOST_ID") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Windows WSL2 is not brokered by AWS", func(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "aws"
|
||||
cfg.TargetOS = targetWindows
|
||||
cfg.WindowsMode = windowsModeWSL2
|
||||
err := validateProviderTarget(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "currently supports target=linux only") {
|
||||
t.Fatalf("target=%s err=%v", target, err)
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateProviderTargetAllowsAWSNativeWindows(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "aws"
|
||||
cfg.TargetOS = targetWindows
|
||||
cfg.WindowsMode = windowsModeNormal
|
||||
if err := validateProviderTarget(cfg); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -67,12 +67,10 @@ func (a App) vnc(ctx context.Context, args []string) error {
|
||||
}
|
||||
password := ""
|
||||
if endpoint.Managed {
|
||||
if target.TargetOS == targetLinux {
|
||||
password, _ = runSSHOutput(ctx, target, "cat "+shellQuote(vncPasswordPath))
|
||||
}
|
||||
password, _ = runSSHOutput(ctx, target, vncPasswordCommand(target))
|
||||
}
|
||||
if target.TargetOS == targetLinux && !isStaticProvider(cfg.Provider) && password == "" {
|
||||
password, _ = runSSHOutput(ctx, target, "cat "+shellQuote(vncPasswordPath))
|
||||
if !isStaticProvider(cfg.Provider) && password == "" {
|
||||
password, _ = runSSHOutput(ctx, target, vncPasswordCommand(target))
|
||||
}
|
||||
tunnel := vncTunnelCommand(target, *localPort)
|
||||
staticHostVNC := isStaticProvider(cfg.Provider) && !endpoint.Managed
|
||||
|
||||
@ -1,5 +1,20 @@
|
||||
import { sshPorts, type LeaseConfig } from "./config";
|
||||
|
||||
const tightVNCMSIURL =
|
||||
"https://www.tightvnc.com/download/2.8.85/tightvnc-2.8.85-gpl-setup-64bit.msi";
|
||||
const gitForWindowsSetupURL =
|
||||
"https://github.com/git-for-windows/git/releases/download/v2.52.0.windows.1/Git-2.52.0-64-bit.exe";
|
||||
|
||||
export function awsUserData(config: LeaseConfig): string {
|
||||
if (config.target === "windows") {
|
||||
return windowsUserData(config);
|
||||
}
|
||||
if (config.target === "macos") {
|
||||
return macOSUserData(config);
|
||||
}
|
||||
return cloudInit(config);
|
||||
}
|
||||
|
||||
export function cloudInit(config: LeaseConfig): string {
|
||||
const portLines = sshPorts(config)
|
||||
.map((port) => ` Port ${port}`)
|
||||
@ -69,6 +84,107 @@ ${bootstrap}
|
||||
`;
|
||||
}
|
||||
|
||||
export function windowsUserData(config: LeaseConfig): string {
|
||||
return `<powershell>
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
function Retry($ScriptBlock) {
|
||||
for ($i = 1; $i -le 8; $i++) {
|
||||
try { & $ScriptBlock; return }
|
||||
catch {
|
||||
if ($i -eq 8) { throw }
|
||||
Start-Sleep -Seconds ($i * 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
$user = ${psQuote(config.sshUser)}
|
||||
$publicKey = ${psQuote(config.sshPublicKey)}
|
||||
$workRoot = ${psQuote(config.workRoot)}
|
||||
$vncPasswordPath = "C:\\ProgramData\\crabbox\\vnc.password"
|
||||
$gitInstaller = "$env:TEMP\\Git-2.52.0-64-bit.exe"
|
||||
$tightVNCInstaller = "$env:TEMP\\tightvnc-2.8.85-gpl-setup-64bit.msi"
|
||||
New-Item -ItemType Directory -Force -Path "C:\\ProgramData\\crabbox", $workRoot | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
|
||||
$bytes = New-Object byte[] 12
|
||||
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
||||
[Convert]::ToBase64String($bytes).Substring(0, 12) | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
|
||||
}
|
||||
$userPassword = [Guid]::NewGuid().ToString("N") + "aA1!"
|
||||
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
|
||||
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
|
||||
}
|
||||
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
|
||||
$ssh = Get-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
||||
if ($ssh.State -ne "Installed") {
|
||||
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 | Out-Null
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path "$env:ProgramData\\ssh" | Out-Null
|
||||
Set-Content -Encoding ASCII -Path "$env:ProgramData\\ssh\\administrators_authorized_keys" -Value $publicKey
|
||||
icacls.exe "$env:ProgramData\\ssh\\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
if (-not (Get-NetFirewallRule -Name "crabbox-sshd" -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -Name "crabbox-sshd" -DisplayName "Crabbox OpenSSH" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null
|
||||
}
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
Start-Service sshd
|
||||
if (-not (Test-Path -LiteralPath "C:\\Program Files\\Git\\cmd\\git.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ${psQuote(gitForWindowsSetupURL)} -OutFile $gitInstaller -UseBasicParsing }
|
||||
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
|
||||
}
|
||||
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
foreach ($path in @("C:\\Program Files\\Git\\cmd", "C:\\Program Files\\Git\\usr\\bin")) {
|
||||
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
|
||||
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
|
||||
if (-not (Test-Path -LiteralPath "C:\\Program Files\\TightVNC\\tvnserver.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ${psQuote(tightVNCMSIURL)} -OutFile $tightVNCInstaller -UseBasicParsing }
|
||||
$vncPassword = Get-Content -Raw -Path $vncPasswordPath
|
||||
Start-Process -FilePath msiexec.exe -ArgumentList @(
|
||||
"/i", $tightVNCInstaller, "/quiet", "/norestart",
|
||||
"ADDLOCAL=Server",
|
||||
"SERVER_REGISTER_AS_SERVICE=1",
|
||||
"SERVER_ADD_FIREWALL_EXCEPTION=0",
|
||||
"SET_USEVNCAUTHENTICATION=1", "VALUE_OF_USEVNCAUTHENTICATION=1",
|
||||
"SET_PASSWORD=1", "VALUE_OF_PASSWORD=$vncPassword",
|
||||
"SET_USECONTROLAUTHENTICATION=1", "VALUE_OF_USECONTROLAUTHENTICATION=1",
|
||||
"SET_CONTROLPASSWORD=1", "VALUE_OF_CONTROLPASSWORD=$vncPassword",
|
||||
"SET_ALLOWLOOPBACK=1", "VALUE_OF_ALLOWLOOPBACK=1",
|
||||
"SET_ACCEPTHTTPCONNECTIONS=1", "VALUE_OF_ACCEPTHTTPCONNECTIONS=0"
|
||||
) -Wait
|
||||
}
|
||||
Get-Service -Name tvnserver -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic
|
||||
Start-Service -Name tvnserver -ErrorAction SilentlyContinue
|
||||
Restart-Service sshd
|
||||
</powershell>`;
|
||||
}
|
||||
|
||||
export function macOSUserData(config: LeaseConfig): string {
|
||||
return `#!/bin/bash
|
||||
set -euxo pipefail
|
||||
install -d -m 0755 ${shellQuote(config.workRoot)} /var/db/crabbox
|
||||
chown -R ${shellQuote(config.sshUser)}:staff ${shellQuote(config.workRoot)}
|
||||
if [ ! -s /var/db/crabbox/vnc.password ]; then
|
||||
pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
|
||||
printf '%s\\n' "$pw" >/var/db/crabbox/vnc.password
|
||||
dscl . -passwd /Users/${shellQuote(config.sshUser)} "$pw"
|
||||
fi
|
||||
chmod 0600 /var/db/crabbox/vnc.password
|
||||
launchctl enable system/com.apple.screensharing || true
|
||||
launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist || true
|
||||
cat >/usr/local/bin/crabbox-ready <<'READY'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
rsync --version >/dev/null
|
||||
curl --version >/dev/null
|
||||
test -w ${shellQuote(config.workRoot)}
|
||||
nc -z 127.0.0.1 5900
|
||||
READY
|
||||
chmod 0755 /usr/local/bin/crabbox-ready
|
||||
/usr/local/bin/crabbox-ready
|
||||
`;
|
||||
}
|
||||
|
||||
function optionalReadyChecks(config: LeaseConfig): string {
|
||||
const lines: string[] = [];
|
||||
if (config.desktop) {
|
||||
|
||||
@ -19,6 +19,7 @@ export interface LeaseConfig {
|
||||
awsProfile: string;
|
||||
awsRootGB: number;
|
||||
awsSSHCIDRs: string[];
|
||||
awsMacHostID: string;
|
||||
capacityMarket: "spot" | "on-demand";
|
||||
capacityStrategy:
|
||||
| "most-available"
|
||||
@ -46,11 +47,26 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
|
||||
}
|
||||
const target = normalizeTarget(input.target ?? input.targetOS ?? "linux");
|
||||
const windowsMode = normalizeWindowsMode(input.windowsMode ?? "normal");
|
||||
if (target !== "linux") {
|
||||
if (
|
||||
target !== "linux" &&
|
||||
!(provider === "aws" && target === "windows" && windowsMode === "normal") &&
|
||||
!(provider === "aws" && target === "macos")
|
||||
) {
|
||||
throw new Error(`unsupported target for brokered ${provider}: ${target}`);
|
||||
}
|
||||
if (target === "macos") {
|
||||
if (provider !== "aws") {
|
||||
throw new Error(`unsupported target for brokered ${provider}: ${target}`);
|
||||
}
|
||||
if (!input.awsMacHostID?.trim()) {
|
||||
throw new Error("brokered aws target=macos requires awsMacHostID or CRABBOX_AWS_MAC_HOST_ID");
|
||||
}
|
||||
if ((input.capacity?.market ?? "spot") !== "on-demand") {
|
||||
throw new Error("brokered aws target=macos requires capacity.market=on-demand");
|
||||
}
|
||||
}
|
||||
const machineClass = input.class ?? "beast";
|
||||
const serverType = input.serverType ?? serverTypeForProviderClass(provider, machineClass);
|
||||
const serverType = input.serverType ?? serverTypeForConfig(provider, target, machineClass);
|
||||
const ttlSeconds = clampTTL(input.ttlSeconds ?? 5400);
|
||||
const idleTimeoutSeconds = clampIdleTimeout(input.idleTimeoutSeconds ?? 1800);
|
||||
const sshPublicKey = input.sshPublicKey?.trim() ?? "";
|
||||
@ -76,16 +92,17 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
|
||||
awsProfile: input.awsProfile ?? "",
|
||||
awsRootGB: input.awsRootGB ?? 400,
|
||||
awsSSHCIDRs: validCIDRs(input.awsSSHCIDRs ?? []),
|
||||
awsMacHostID: input.awsMacHostID ?? "",
|
||||
capacityMarket: input.capacity?.market ?? "spot",
|
||||
capacityStrategy: input.capacity?.strategy ?? "most-available",
|
||||
capacityFallback: input.capacity?.fallback ?? "on-demand-after-120s",
|
||||
capacityRegions: input.capacity?.regions ?? [],
|
||||
capacityAvailabilityZones: input.capacity?.availabilityZones ?? [],
|
||||
sshUser: input.sshUser ?? "crabbox",
|
||||
sshUser: input.sshUser ?? (provider === "aws" && target === "macos" ? "ec2-user" : "crabbox"),
|
||||
sshPort: input.sshPort ?? "2222",
|
||||
sshFallbackPorts: validPorts(input.sshFallbackPorts ?? ["22"]),
|
||||
providerKey: input.providerKey ?? "crabbox-steipete",
|
||||
workRoot: input.workRoot ?? "/work/crabbox",
|
||||
workRoot: input.workRoot ?? (target === "windows" && windowsMode === "normal" ? "C:\\crabbox" : "/work/crabbox"),
|
||||
ttlSeconds,
|
||||
idleTimeoutSeconds,
|
||||
keep: input.keep ?? false,
|
||||
@ -164,6 +181,41 @@ export function serverTypeForProviderClass(provider: Provider, machineClass: str
|
||||
return serverTypeForClass(machineClass);
|
||||
}
|
||||
|
||||
export function serverTypeForConfig(
|
||||
provider: Provider,
|
||||
target: TargetOS,
|
||||
machineClass: string,
|
||||
): string {
|
||||
if (provider === "aws") {
|
||||
return awsInstanceTypeCandidatesForTargetClass(target, machineClass)[0] ?? machineClass;
|
||||
}
|
||||
return serverTypeForClass(machineClass);
|
||||
}
|
||||
|
||||
export function awsInstanceTypeCandidatesForTargetClass(
|
||||
target: TargetOS,
|
||||
machineClass: string,
|
||||
): string[] {
|
||||
if (target === "macos") {
|
||||
return ["mac2.metal"];
|
||||
}
|
||||
if (target === "windows") {
|
||||
switch (machineClass) {
|
||||
case "standard":
|
||||
return ["m7i.large", "m7a.large", "t3.large"];
|
||||
case "fast":
|
||||
return ["m7i.xlarge", "m7a.xlarge", "t3.xlarge"];
|
||||
case "large":
|
||||
return ["m7i.2xlarge", "m7a.2xlarge", "t3.2xlarge"];
|
||||
case "beast":
|
||||
return ["m7i.4xlarge", "m7a.4xlarge", "m7i.2xlarge"];
|
||||
default:
|
||||
return [machineClass];
|
||||
}
|
||||
}
|
||||
return awsInstanceTypeCandidatesForClass(machineClass);
|
||||
}
|
||||
|
||||
export function serverTypeCandidatesForClass(machineClass: string): string[] {
|
||||
switch (machineClass) {
|
||||
case "standard":
|
||||
|
||||
@ -11,6 +11,7 @@ export interface Env {
|
||||
CRABBOX_AWS_INSTANCE_PROFILE?: string;
|
||||
CRABBOX_AWS_ROOT_GB?: string;
|
||||
CRABBOX_AWS_SSH_CIDRS?: string;
|
||||
CRABBOX_AWS_MAC_HOST_ID?: string;
|
||||
CRABBOX_SHARED_TOKEN?: string;
|
||||
CRABBOX_ADMIN_TOKEN?: string;
|
||||
CRABBOX_SESSION_SECRET?: string;
|
||||
@ -57,6 +58,7 @@ export interface LeaseRequest {
|
||||
awsProfile?: string;
|
||||
awsRootGB?: number;
|
||||
awsSSHCIDRs?: string[];
|
||||
awsMacHostID?: string;
|
||||
capacity?: {
|
||||
market?: "spot" | "on-demand";
|
||||
strategy?: "most-available" | "price-capacity-optimized" | "capacity-optimized" | "sequential";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user