diff options
| -rw-r--r-- | Pipfile | 11 | ||||
| -rw-r--r-- | cmd/orodyagzou/.gitignore | 2 | ||||
| -rw-r--r-- | cmd/orodyagzou/main.go | 277 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | web/vastai/vastaicli/.gitignore | 1 | ||||
| -rw-r--r-- | web/vastai/vastaicli/instances.go | 198 | ||||
| -rw-r--r-- | web/vastai/vastaicli/search.go | 164 | ||||
| -rw-r--r-- | web/vastai/vastaicli/search_test.go | 19 |
9 files changed, 675 insertions, 0 deletions
@@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.13" diff --git a/cmd/orodyagzou/.gitignore b/cmd/orodyagzou/.gitignore new file mode 100644 index 0000000..c588cf3 --- /dev/null +++ b/cmd/orodyagzou/.gitignore @@ -0,0 +1,2 @@ +*.env +gpu_names_cache.json
\ No newline at end of file diff --git a/cmd/orodyagzou/main.go b/cmd/orodyagzou/main.go new file mode 100644 index 0000000..5267103 --- /dev/null +++ b/cmd/orodyagzou/main.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "os" + "sync" + "time" + + "github.com/google/uuid" + "github.com/joho/godotenv" + "within.website/x/internal" + "within.website/x/web/vastai/vastaicli" +) + +var ( + bind = flag.String("bind", ":3238", "HTTP port to bind to") + diskSizeGB = flag.Int("vastai-disk-size-gb", 32, "amount of disk we need from vast.ai") + dockerImage = flag.String("docker-image", "reg.xeiaso.net/runner/sdxl-tigris:latest", "docker image to start") + onstartCmd = flag.String("onstart-cmd", "python -m cog.server.http", "onstart command to run in vast.ai") + vastaiPort = flag.Int("vastai-port", 5000, "port that the guest will use in vast.ai") + vastaiFilters = flag.String("vastai-filters", "verified=False cuda_max_good>=12.1 gpu_ram>=12 num_gpus=1 inet_down>=850", "vast.ai search filters") +) + +func main() { + internal.HandleStartup() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if flag.NArg() != 1 { + fmt.Println("usage: orodyagzou [flags] <whatever.env>") + os.Exit(2) + } + + fname := flag.Arg(0) + slog.Debug("using env file", "fname", fname) + + env, err := godotenv.Read(fname) + if err != nil { + slog.Error("can't read env file", "fname", fname, "err", err) + os.Exit(1) + } + + var cfg vastaicli.InstanceConfig + + cfg.DiskSizeGB = *diskSizeGB + cfg.Environment = env + cfg.DockerImage = *dockerImage + cfg.OnStartCMD = *onstartCmd + cfg.Ports = append(cfg.Ports, *vastaiPort) + + images := &ScaleToZeroProxy{ + cfg: cfg, + } + + go images.slayLoop(ctx) + + mux := http.NewServeMux() + mux.Handle("/v1/images", images) + + fmt.Printf("http://localhost%s\n", *bind) + log.Fatal(http.ListenAndServe(*bind, mux)) +} + +type ScaleToZeroProxy struct { + cfg vastaicli.InstanceConfig + + // locked fields + lock sync.RWMutex + endpointURL string + instanceID int + ready bool + lastUsed time.Time +} + +func (s *ScaleToZeroProxy) slayLoop(ctx context.Context) { + t := time.NewTicker(time.Minute) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + slog.Error("context canceled", "err", ctx.Err()) + return + case <-t.C: + s.lock.RLock() + ready := s.ready + lastUsed := s.lastUsed + s.lock.RUnlock() + + if !ready { + continue + } + + if lastUsed.Add(5 * time.Minute).Before(time.Now()) { + if err := s.slay(ctx); err != nil { + slog.Error("can't slay instance", "err", err) + } + } + } + } +} + +func (s *ScaleToZeroProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.lock.RLock() + ready := s.ready + s.lock.RUnlock() + + if !ready { + if err := s.mint(r.Context()); err != nil { + slog.Error("can't mint", "err", err) + http.Error(w, "can't mint", http.StatusInternalServerError) + return + } + } + + s.lock.RLock() + endpointURL := s.endpointURL + s.lock.RUnlock() + + u, err := url.Parse(endpointURL) + if err != nil { + slog.Error("can't url parse", "err", err, "url", s.endpointURL) + http.Error(w, "can't url parse", http.StatusInternalServerError) + return + } + + next := httputil.NewSingleHostReverseProxy(u) + od := next.Director + next.Director = func(r *http.Request) { + od(r) + r.URL.Path = "/predictions/" + uuid.NewString() + } + next.ServeHTTP(w, r) + + s.lock.Lock() + s.lastUsed = time.Now() + s.lock.Unlock() +} + +func (s *ScaleToZeroProxy) mint(ctx context.Context) error { + s.lock.Lock() + defer s.lock.Unlock() + + candidates, err := vastaicli.Search(ctx, *vastaiFilters, "dph+") + if err != nil { + return err + } + + candidate := candidates[0] + slog.Info("found instance", "costDPH", candidate.DphTotal, "gpuName", candidate.GpuName) + + instanceData, err := vastaicli.Mint(ctx, candidate.AskContractID, s.cfg) + if err != nil { + return err + } + + slog.Info("created instance, waiting for things to settle", "id", instanceData.NewContract) + + instance, err := s.delayUntilRunning(ctx, instanceData.NewContract) + if err != nil { + return err + } + + addr, ok := instance.AddrFor(s.cfg.Ports[0]) + if !ok { + return fmt.Errorf("somehow can't get port %d for instance %d, is god dead?", s.cfg.Ports[0], instance.ID) + } + + s.endpointURL = "http://" + addr + "/" + s.ready = true + s.instanceID = instance.ID + s.lastUsed = time.Now().Add(5 * time.Minute) + + if err := s.delayUntilReady(ctx, s.endpointURL); err != nil { + return fmt.Errorf("can't do healthcheck: %w", err) + } + + slog.Info("ready", "endpointURL", s.endpointURL, "instanceID", s.instanceID) + + return nil +} + +func (s *ScaleToZeroProxy) slay(ctx context.Context) error { + s.lock.Lock() + defer s.lock.Unlock() + + if err := vastaicli.Slay(ctx, s.instanceID); err != nil { + return err + } + + s.endpointURL = "" + s.ready = false + s.lastUsed = time.Now() + s.instanceID = 0 + + slog.Info("instance slayed", "docker_image", s.cfg.DockerImage) + + return nil +} + +func (s *ScaleToZeroProxy) delayUntilReady(ctx context.Context, endpointURL string) error { + type cogHealthCheck struct { + Status string `json:"status"` + } + + u, err := url.Parse(endpointURL) + if err != nil { + return fmt.Errorf("[unexpected] can't parse endpoint url %q: %w", endpointURL, err) + } + + u.Path = "/health-check" + + t := time.NewTicker(time.Second) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + resp, err := http.Get(u.String()) + if err != nil { + return fmt.Errorf("can't fetch health check: %w", err) + } + + var status cogHealthCheck + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return fmt.Errorf("can't parse health check response: %w", err) + } + + if status.Status == "READY" { + slog.Info("health check passed") + return nil + } + } + } +} + +func (s *ScaleToZeroProxy) delayUntilRunning(ctx context.Context, instanceID int) (*vastaicli.Instance, error) { + t := time.NewTicker(10 * time.Second) + defer t.Stop() + + var instance *vastaicli.Instance + var err error + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-t.C: + instance, err = vastaicli.GetInstance(ctx, instanceID) + if err != nil { + return nil, err + } + + slog.Debug("instance is cooking", "curr", instance.ActualStatus, "next", instance.NextState, "status", instance.StatusMsg) + + if instance.ActualStatus == "running" { + _, ok := instance.AddrFor(s.cfg.Ports[0]) + if !ok { + slog.Info("no addr", "ports", s.cfg.Ports) + continue + } + + return instance, nil + } + } + } +} @@ -76,6 +76,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect dario.cat/mergo v1.0.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cirello.io/goherokuname v0.0.0-20190914093443-b436bae8c2c5 h1:W1xHfkFJ/G3/KGsRFV1S9DPFM6yB+ndu4Tbnvp7Ec1E= cirello.io/goherokuname v0.0.0-20190914093443-b436bae8c2c5/go.mod h1:lfp+7qXdkiHbLqAvsA4v2Cyll5djTKZTiNi+iDfPJcw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= diff --git a/web/vastai/vastaicli/.gitignore b/web/vastai/vastaicli/.gitignore new file mode 100644 index 0000000..1957ae2 --- /dev/null +++ b/web/vastai/vastaicli/.gitignore @@ -0,0 +1 @@ +gpu_names_cache.json
\ No newline at end of file diff --git a/web/vastai/vastaicli/instances.go b/web/vastai/vastaicli/instances.go new file mode 100644 index 0000000..26c9d14 --- /dev/null +++ b/web/vastai/vastaicli/instances.go @@ -0,0 +1,198 @@ +package vastaicli + +import ( + "context" + "fmt" + "net" + "strings" + + "al.essio.dev/pkg/shellescape" +) + +type InstanceConfig struct { + DiskSizeGB int `json:"diskSizeGB"` + DockerImage string `json:"dockerImage"` + Environment map[string]string `json:"env"` + OnStartCMD string `json:"onStartCmd"` + Ports []int `json:"ports"` +} + +func (ic InstanceConfig) EnvString() string { + var sb strings.Builder + + for _, port := range ic.Ports { + fmt.Fprintf(&sb, "-p %d:%d ", port, port) + } + + // -e FOO=bar + for k, v := range ic.Environment { + fmt.Fprintf(&sb, "-e %s=%s ", shellescape.Quote(k), shellescape.Quote(v)) + } + + return sb.String() +} + +type NewInstance struct { + Success bool `json:"success"` + NewContract int `json:"new_contract"` +} + +func Mint(ctx context.Context, askContractID int, ic InstanceConfig) (*NewInstance, error) { + result, err := runJSON[NewInstance]( + ctx, + "vastai", "create", "instance", + askContractID, + "--image", ic.DockerImage, + "--env", ic.EnvString(), + "--disk", ic.DiskSizeGB, + "--onstart-cmd", ic.OnStartCMD, + "--raw", + ) + + if err != nil { + return nil, fmt.Errorf("can't create instance for contract ID %d: %v", askContractID, err) + } + + return &result, nil +} + +func Slay(ctx context.Context, instanceID int) error { + type slayResp struct { + Success bool `json:"success"` + } + + if _, err := runJSON[slayResp](ctx, "vastai", "destroy", "instance", instanceID, "--raw"); err != nil { + return fmt.Errorf("can't slay instance %d: %w", instanceID, err) + } + + return nil +} + +func GetInstance(ctx context.Context, instanceID int) (*Instance, error) { + result, err := runJSON[Instance](ctx, "vastai", "show", "instance", instanceID, "--raw") + if err != nil { + return nil, fmt.Errorf("can't get instance %d: %w", instanceID, err) + } + + return &result, nil +} + +type Instance struct { + ActualStatus string `json:"actual_status"` + BundleID int `json:"bundle_id"` + BwNvlink float64 `json:"bw_nvlink"` + ClientRunTime float64 `json:"client_run_time"` + ComputeCap int `json:"compute_cap"` + CPUArch string `json:"cpu_arch"` + CPUCores int `json:"cpu_cores"` + CPUCoresEffective float64 `json:"cpu_cores_effective"` + CPUName string `json:"cpu_name"` + CPURAM int `json:"cpu_ram"` + CPUUtil float64 `json:"cpu_util"` + CreditBalance any `json:"credit_balance"` + CreditDiscount any `json:"credit_discount"` + CreditDiscountMax float64 `json:"credit_discount_max"` + CudaMaxGood float64 `json:"cuda_max_good"` + CurState string `json:"cur_state"` + DirectPortCount int `json:"direct_port_count"` + DirectPortEnd int `json:"direct_port_end"` + DirectPortStart int `json:"direct_port_start"` + DiskBw float64 `json:"disk_bw"` + DiskName string `json:"disk_name"` + DiskSpace float64 `json:"disk_space"` + DiskUsage float64 `json:"disk_usage"` + DiskUtil float64 `json:"disk_util"` + Dlperf float64 `json:"dlperf"` + DlperfPerDphtotal float64 `json:"dlperf_per_dphtotal"` + DphBase float64 `json:"dph_base"` + DphTotal float64 `json:"dph_total"` + DriverVersion string `json:"driver_version"` + Duration float64 `json:"duration"` + EndDate float64 `json:"end_date"` + External bool `json:"external"` + ExtraEnv any `json:"extra_env"` + FlopsPerDphtotal float64 `json:"flops_per_dphtotal"` + Geolocation string `json:"geolocation"` + GpuDisplayActive bool `json:"gpu_display_active"` + GpuFrac float64 `json:"gpu_frac"` + GpuLanes int `json:"gpu_lanes"` + GpuMemBw float64 `json:"gpu_mem_bw"` + GpuName string `json:"gpu_name"` + GpuRAM int `json:"gpu_ram"` + GpuTemp any `json:"gpu_temp"` + GpuTotalram int `json:"gpu_totalram"` + GpuUtil any `json:"gpu_util"` + HasAvx int `json:"has_avx"` + HostID int `json:"host_id"` + HostRunTime float64 `json:"host_run_time"` + HostingType any `json:"hosting_type"` + ID int `json:"id"` + ImageArgs []any `json:"image_args"` + ImageRuntype string `json:"image_runtype"` + ImageUUID string `json:"image_uuid"` + InetDown float64 `json:"inet_down"` + InetDownBilled any `json:"inet_down_billed"` + InetDownCost float64 `json:"inet_down_cost"` + InetUp float64 `json:"inet_up"` + InetUpBilled any `json:"inet_up_billed"` + InetUpCost float64 `json:"inet_up_cost"` + Instance InstancePricingDetails `json:"instance"` + IntendedStatus string `json:"intended_status"` + InternetDownCostPerTb float64 `json:"internet_down_cost_per_tb"` + InternetUpCostPerTb float64 `json:"internet_up_cost_per_tb"` + IsBid bool `json:"is_bid"` + JupyterToken string `json:"jupyter_token"` + Label any `json:"label"` + LocalIpaddrs string `json:"local_ipaddrs"` + Logo string `json:"logo"` + MachineDirSSHPort int `json:"machine_dir_ssh_port"` + MachineID int `json:"machine_id"` + MemLimit any `json:"mem_limit"` + MemUsage any `json:"mem_usage"` + MinBid float64 `json:"min_bid"` + MoboName string `json:"mobo_name"` + NextState string `json:"next_state"` + NumGpus int `json:"num_gpus"` + Onstart any `json:"onstart"` + OsVersion string `json:"os_version"` + PciGen float64 `json:"pci_gen"` + PcieBw float64 `json:"pcie_bw"` + PublicIpaddr string `json:"public_ipaddr"` + Reliability2 float64 `json:"reliability2"` + Rentable bool `json:"rentable"` + Score float64 `json:"score"` + Search SearchPricing `json:"search"` + SSHHost string `json:"ssh_host"` + SSHIdx string `json:"ssh_idx"` + SSHPort int `json:"ssh_port"` + StartDate float64 `json:"start_date"` + StaticIP bool `json:"static_ip"` + StatusMsg string `json:"status_msg"` + StorageCost float64 `json:"storage_cost"` + StorageTotalCost float64 `json:"storage_total_cost"` + TemplateHashID any `json:"template_hash_id"` + TimeRemaining string `json:"time_remaining"` + TimeRemainingIsbid string `json:"time_remaining_isbid"` + TotalFlops float64 `json:"total_flops"` + UptimeMins any `json:"uptime_mins"` + Verification string `json:"verification"` + VmemUsage any `json:"vmem_usage"` + VramCostperhour float64 `json:"vram_costperhour"` + Webpage any `json:"webpage"` + + Ports map[string][]PortData +} + +func (i Instance) AddrFor(port int) (string, bool) { + data, ok := i.Ports[fmt.Sprintf("%d/tcp", port)] + if !ok { + return "", false + } + + return net.JoinHostPort(i.PublicIpaddr, data[0].HostPort), true +} + +type PortData struct { + HostIp string `json:"HostIp"` + HostPort string `json:"HostPort"` +} diff --git a/web/vastai/vastaicli/search.go b/web/vastai/vastaicli/search.go new file mode 100644 index 0000000..2a143c0 --- /dev/null +++ b/web/vastai/vastaicli/search.go @@ -0,0 +1,164 @@ +package vastaicli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" +) + +// zilch returns the zero value of a given type. +func zilch[T any]() T { return *new(T) } + +func runJSON[T any](ctx context.Context, program string, args ...any) (T, error) { + exePath, err := exec.LookPath(program) + if err != nil { + return zilch[T](), fmt.Errorf("can't find %s: %w", program, err) + } + + var argStr []string + + for _, arg := range args { + argStr = append(argStr, fmt.Sprint(arg)) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd := exec.CommandContext(ctx, exePath, argStr...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + os.Stderr.Write(stderr.Bytes()) + return zilch[T](), fmt.Errorf("can't run %s: %w", program, err) + } + + var result T + if err := json.NewDecoder(&stdout).Decode(&result); err != nil { + return zilch[T](), fmt.Errorf("can't decode json: %w", err) + } + + return result, nil +} + +func Search(ctx context.Context, filters, orderBy string) ([]SearchItem, error) { + result, err := runJSON[[]SearchItem](ctx, "vastai", "search", "offers", filters, "-o", orderBy, "--raw") + if err != nil { + return nil, fmt.Errorf("while searching for %s: %w", filters, err) + } + + return result, nil +} + +type SearchItem struct { + AskContractID int `json:"ask_contract_id"` + BundleID int `json:"bundle_id"` + BundledResults any `json:"bundled_results"` + BwNvlink float64 `json:"bw_nvlink"` + ComputeCap int `json:"compute_cap"` + CPUArch string `json:"cpu_arch"` + CPUCores int `json:"cpu_cores"` + CPUCoresEffective float64 `json:"cpu_cores_effective"` + CPUGhz float64 `json:"cpu_ghz"` + CPUName string `json:"cpu_name"` + CPURAM int `json:"cpu_ram"` + CreditDiscountMax float64 `json:"credit_discount_max"` + CudaMaxGood float64 `json:"cuda_max_good"` + DirectPortCount int `json:"direct_port_count"` + DiscountRate any `json:"discount_rate"` + DiscountedDphTotal float64 `json:"discounted_dph_total"` + DiscountedHourly float64 `json:"discounted_hourly"` + DiskBw float64 `json:"disk_bw"` + DiskName string `json:"disk_name"` + DiskSpace float64 `json:"disk_space"` + Dlperf float64 `json:"dlperf"` + DlperfPerDphtotal float64 `json:"dlperf_per_dphtotal"` + DphBase float64 `json:"dph_base"` + DphTotal float64 `json:"dph_total"` + DphTotalAdj float64 `json:"dph_total_adj"` + DriverVers int `json:"driver_vers"` + DriverVersion string `json:"driver_version"` + Duration float64 `json:"duration"` + EndDate float64 `json:"end_date"` + External any `json:"external"` + FlopsPerDphtotal float64 `json:"flops_per_dphtotal"` + Geolocation string `json:"geolocation"` + Geolocode float64 `json:"geolocode"` + GpuArch string `json:"gpu_arch"` + GpuDisplayActive bool `json:"gpu_display_active"` + GpuFrac float64 `json:"gpu_frac"` + GpuIds []int `json:"gpu_ids"` + GpuLanes int `json:"gpu_lanes"` + GpuMaxPower float64 `json:"gpu_max_power"` + GpuMaxTemp float64 `json:"gpu_max_temp"` + GpuMemBw float64 `json:"gpu_mem_bw"` + GpuName string `json:"gpu_name"` + GpuRAM float64 `json:"gpu_ram"` + GpuTotalRAM float64 `json:"gpu_total_ram"` + HasAvx int `json:"has_avx"` + HostID int `json:"host_id"` + HostingType int `json:"hosting_type"` + Hostname any `json:"hostname"` + ID int `json:"id"` + InetDown float64 `json:"inet_down"` + InetDownCost float64 `json:"inet_down_cost"` + InetUp float64 `json:"inet_up"` + InetUpCost float64 `json:"inet_up_cost"` + Instance InstancePricingDetails `json:"instance"` + InternetDownCostPerTb float64 `json:"internet_down_cost_per_tb"` + InternetUpCostPerTb float64 `json:"internet_up_cost_per_tb"` + IsBid bool `json:"is_bid"` + Logo string `json:"logo"` + MachineID int `json:"machine_id"` + MinBid float64 `json:"min_bid"` + MoboName string `json:"mobo_name"` + NumGpus int `json:"num_gpus"` + OsVersion string `json:"os_version"` + PciGen float64 `json:"pci_gen"` + PcieBw float64 `json:"pcie_bw"` + PublicIpaddr string `json:"public_ipaddr"` + Reliability float64 `json:"reliability"` + Reliability2 float64 `json:"reliability2"` + ReliabilityMult float64 `json:"reliability_mult"` + Rentable bool `json:"rentable"` + Rented bool `json:"rented"` + Rn int `json:"rn"` + Score float64 `json:"score"` + Search SearchPricing `json:"search"` + StartDate float64 `json:"start_date"` + StaticIP bool `json:"static_ip"` + StorageCost float64 `json:"storage_cost"` + StorageTotalCost float64 `json:"storage_total_cost"` + TimeRemaining string `json:"time_remaining"` + TimeRemainingIsbid string `json:"time_remaining_isbid"` + TotalFlops float64 `json:"total_flops"` + Vericode int `json:"vericode"` + Verification string `json:"verification"` + VmsEnabled bool `json:"vms_enabled"` + VramCostperhour float64 `json:"vram_costperhour"` + Webpage any `json:"webpage"` +} + +type InstancePricingDetails struct { + DiscountTotalHour float64 `json:"discountTotalHour"` + DiscountedTotalPerHour float64 `json:"discountedTotalPerHour"` + DiskHour float64 `json:"diskHour"` + GpuCostPerHour float64 `json:"gpuCostPerHour"` + TotalHour float64 `json:"totalHour"` +} + +type SearchPricing struct { + DiscountTotalHour float64 `json:"discountTotalHour"` + DiscountedTotalPerHour float64 `json:"discountedTotalPerHour"` + DiskHour float64 `json:"diskHour"` + GpuCostPerHour float64 `json:"gpuCostPerHour"` + TotalHour float64 `json:"totalHour"` +} + +type Foo struct { + SomeValue string `json:"someValue"` + AnotherValue string "json:\"anotherValue\"" +} diff --git a/web/vastai/vastaicli/search_test.go b/web/vastai/vastaicli/search_test.go new file mode 100644 index 0000000..10960fa --- /dev/null +++ b/web/vastai/vastaicli/search_test.go @@ -0,0 +1,19 @@ +package vastaicli + +import ( + "context" + "testing" + "time" +) + +func TestSearch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + instances, err := Search(ctx, "verified=False cuda_max_good>=12.1 gpu_ram>=12 num_gpus=1 inet_down>=850", "dph+") + if err != nil { + t.Fatal(err) + } + + t.Logf("found %d candidates", len(instances)) +} |
