aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Pipfile11
-rw-r--r--cmd/orodyagzou/.gitignore2
-rw-r--r--cmd/orodyagzou/main.go277
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--web/vastai/vastaicli/.gitignore1
-rw-r--r--web/vastai/vastaicli/instances.go198
-rw-r--r--web/vastai/vastaicli/search.go164
-rw-r--r--web/vastai/vastaicli/search_test.go19
9 files changed, 675 insertions, 0 deletions
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..d61ea53
--- /dev/null
+++ b/Pipfile
@@ -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
+ }
+ }
+ }
+}
diff --git a/go.mod b/go.mod
index 7cf2400..66e680f 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 06b08b9..69bfea6 100644
--- a/go.sum
+++ b/go.sum
@@ -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))
+}