github.com/Schaudge/grailbase@v0.0.0-20240223061707-44c758a471c0/cloud/spotfeed/parser.go (about)

     1  package spotfeed
     2  
     3  import (
     4  	"bufio"
     5  	"fmt"
     6  	"io"
     7  	"regexp"
     8  	"strconv"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/Schaudge/grailbase/errors"
    13  )
    14  
    15  const (
    16  	feedFileTimestampFormat = "2006-01-02-15"
    17  )
    18  
    19  var (
    20  	feedFileNamePattern = regexp.MustCompile(`^[0-9]{12}\.[0-9]{4}(\-[0-9]{2}){3}\.[0-9]{3}.[a-z0-9]{8}(\.gz)?$`)
    21  )
    22  
    23  type fileMeta struct {
    24  	filterable
    25  
    26  	Name      string
    27  	AccountId string
    28  	Timestamp time.Time
    29  	Version   int64
    30  	IsGzip    bool
    31  }
    32  
    33  func (f *fileMeta) accountId() string {
    34  	return f.AccountId
    35  }
    36  
    37  func (f *fileMeta) timestamp() time.Time {
    38  	return f.Timestamp
    39  }
    40  
    41  func (f *fileMeta) version() int64 {
    42  	return f.Version
    43  }
    44  
    45  func parseFeedFileName(name string) (*fileMeta, error) {
    46  	if !feedFileNamePattern.MatchString(name) {
    47  		return nil, fmt.Errorf("%s does not match feed fileMeta pattern, skipping", name)
    48  	}
    49  
    50  	fields := strings.Split(name, ".")
    51  	var isGzip bool
    52  	switch len(fields) {
    53  	case 4:
    54  		isGzip = false
    55  	case 5:
    56  		if fields[4] == "gz" {
    57  			isGzip = true
    58  		} else {
    59  			return nil, fmt.Errorf("failed to parse fileMeta name in data feed directory: %s", name)
    60  		}
    61  	default:
    62  		return nil, fmt.Errorf("failed to parse fileMeta name in data feed directory: %s", name)
    63  	}
    64  
    65  	timestamp, err := time.Parse(feedFileTimestampFormat, fields[1])
    66  	if err != nil {
    67  		return nil, errors.E(err, fmt.Sprintf("failed to parse timestamp for name %s", name))
    68  	}
    69  
    70  	version, err := strconv.ParseInt(fields[2], 10, 64)
    71  	if err != nil {
    72  		return nil, errors.E(err, fmt.Sprintf("failed to parse version for name %s", name))
    73  	}
    74  
    75  	return &fileMeta{
    76  		Name:      name,
    77  		AccountId: fields[0],
    78  		Timestamp: timestamp,
    79  		Version:   version,
    80  		IsGzip:    isGzip,
    81  	}, nil
    82  }
    83  
    84  // Entry corresponds to a single line in a Spot Instance data feed file. The
    85  // Spot Instance data feed files are tab-delimited. Each line in the data file
    86  // corresponds to one instance hour and contains the fields listed in the
    87  // following table. The AccountId field is not specified for each individual entry
    88  // but is given as a prefix in the name of the spot data feed file.
    89  type Entry struct {
    90  	filterable
    91  
    92  	// AccountId is a 12-digit account number (ID) that specifies the AWS account
    93  	// billed for this spot instance-hour.
    94  	AccountId string
    95  
    96  	// Timestamp is used to determine the price charged for this instance usage.
    97  	// It is not at the hour boundary but within the hour specified by the title of
    98  	// the data feed file that contains this Entry.
    99  	Timestamp time.Time
   100  
   101  	// UsageType is the type of usage and instance type being charged for. For
   102  	// m1.small Spot Instances, this field is set to SpotUsage. For all other
   103  	// instance types, this field is set to SpotUsage:{instance-type}. For
   104  	// example, SpotUsage:c1.medium.
   105  	UsageType string
   106  
   107  	// Instance is the instance type being charged for and is a member of the
   108  	// set of information provided by UsageType.
   109  	Instance string
   110  
   111  	// Operation is the product being charged for. For Linux Spot Instances,
   112  	// this field is set to RunInstances. For Windows Spot Instances, this
   113  	// field is set to RunInstances:0002. Spot usage is grouped according
   114  	// to Availability Zone.
   115  	Operation string
   116  
   117  	// InstanceID is the ID of the Spot Instance that generated this instance
   118  	// usage.
   119  	InstanceID string
   120  
   121  	// MyBidID is the ID for the Spot Instance request that generated this instance usage.
   122  	MyBidID string
   123  
   124  	// MyMaxPriceUSD is the maximum price specified for this Spot Instance request.
   125  	MyMaxPriceUSD float64
   126  
   127  	// MarketPriceUSD is the Spot price at the time specified in the Timestamp field.
   128  	MarketPriceUSD float64
   129  
   130  	// ChargeUSD is the price charged for this instance usage.
   131  	ChargeUSD float64
   132  
   133  	// Version is the version included in the data feed file name for this record.
   134  	Version int64
   135  }
   136  
   137  func (e *Entry) accountId() string {
   138  	return e.AccountId
   139  }
   140  
   141  func (e *Entry) timestamp() time.Time {
   142  	return e.Timestamp
   143  }
   144  
   145  func (e *Entry) version() int64 {
   146  	return e.Version
   147  }
   148  
   149  // parsePriceUSD parses a price in USD formatted like "6.669 USD".
   150  func parsePriceUSD(priceField string) (float64, error) {
   151  	trimCurrency := strings.TrimSuffix(priceField, " USD")
   152  	if len(trimCurrency) != (len(priceField) - 4) {
   153  		return 0, fmt.Errorf("failed to trim currency from %s", priceField)
   154  	}
   155  	return strconv.ParseFloat(trimCurrency, 64)
   156  }
   157  
   158  // parseUsageType parses the EC2 instance type from the spot data feed column UsageType, as per the AWS documentation.
   159  // For m1.small Spot Instances, this field is set to SpotUsage. For all other instance types, this field is set to
   160  // SpotUsage:{instance-type}. For example, SpotUsage:c1.medium.
   161  func parseUsageType(usageType string) (string, error) {
   162  	fields := strings.Split(usageType, ":")
   163  	if len(fields) == 1 {
   164  		return "m1.small", nil
   165  	}
   166  	if len(fields) == 2 {
   167  		return fields[1], nil
   168  	}
   169  	return "", fmt.Errorf("failed to parse instance from UsageType %s", usageType)
   170  }
   171  
   172  const (
   173  	feedLineTimestampFormat = "2006-01-02 15:04:05 MST"
   174  )
   175  
   176  // parseFeedLine parses an *Entry from a line in a spot data feed file. The content and ordering of the columns
   177  // in this file are documented at https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-data-feeds.html
   178  func parseFeedLine(line string, accountId string) (*Entry, error) {
   179  	fields := strings.Split(line, "\t")
   180  	if len(fields) != 9 {
   181  		return nil, fmt.Errorf("failed to parse line in data feed: %s", line)
   182  	}
   183  
   184  	timestamp, err := time.Parse(feedLineTimestampFormat, fields[0])
   185  	if err != nil {
   186  		return nil, errors.E(err, fmt.Sprintf("failed to parse timestamp for line %s", line))
   187  	}
   188  
   189  	instance, err := parseUsageType(fields[1])
   190  	if err != nil {
   191  		return nil, errors.E(err, fmt.Sprintf("failed to parse usage type for line %s", line))
   192  	}
   193  
   194  	myMaxPriceUSD, err := parsePriceUSD(fields[5])
   195  	if err != nil {
   196  		return nil, errors.E(err, fmt.Sprintf("failed to parse my max price for line %s", line))
   197  	}
   198  
   199  	marketPriceUSD, err := parsePriceUSD(fields[6])
   200  	if err != nil {
   201  		return nil, errors.E(err, fmt.Sprintf("failed to parse market price for line %s", line))
   202  	}
   203  
   204  	chargeUSD, err := parsePriceUSD(fields[7])
   205  	if err != nil {
   206  		return nil, errors.E(err, fmt.Sprintf("failed to parse charge for line %s", line))
   207  	}
   208  
   209  	version, err := strconv.ParseInt(fields[8], 10, 64)
   210  	if err != nil {
   211  		return nil, errors.E(err, fmt.Sprintf("failed to parse version for line %s", line))
   212  	}
   213  
   214  	return &Entry{
   215  		AccountId:      accountId,
   216  		Timestamp:      timestamp,
   217  		UsageType:      fields[1],
   218  		Instance:       instance,
   219  		Operation:      fields[2],
   220  		InstanceID:     fields[3],
   221  		MyBidID:        fields[4],
   222  		MyMaxPriceUSD:  myMaxPriceUSD,
   223  		MarketPriceUSD: marketPriceUSD,
   224  		ChargeUSD:      chargeUSD,
   225  		Version:        version,
   226  	}, nil
   227  }
   228  
   229  func ParseFeedFile(feed io.Reader, accountId string) ([]*Entry, error) {
   230  	scn := bufio.NewScanner(feed)
   231  
   232  	entries := make([]*Entry, 0)
   233  	for scn.Scan() {
   234  		line := scn.Text()
   235  		if strings.HasPrefix(line, "#") {
   236  			continue
   237  		}
   238  
   239  		entry, err := parseFeedLine(scn.Text(), accountId)
   240  		if err != nil {
   241  			return nil, errors.E(err, "")
   242  		}
   243  
   244  		entries = append(entries, entry)
   245  	}
   246  
   247  	return entries, nil
   248  }