check / check (push) Canceled after 0s
make asupdate piped the generator into gzip, so the target saw gzip's exit status. A failed download replaced pkg/asinfo/asdata.json.gz with an empty file and the target still exited 0. The target now runs the new script/asupdate. It writes the generator's output to a temporary file and checks that the generator succeeded and wrote at least one AS. Only then does it compress the list and move it into place. Otherwise it leaves the file alone and exits non-zero. The generator reads as.csv from ipverse/as-metadata, the source's new name. The list it builds there is unchanged, so no new list is committed. Model: opus-5-5
131 lines
3.0 KiB
Go
131 lines
3.0 KiB
Go
// Package main generates the embedded AS info data for the asinfo package
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const asCsvURL = "https://github.com/ipverse/as-metadata/raw/refs/heads/master/as.csv"
|
|
|
|
// The first columns of as.csv, the ones the generator reads. It reads past
|
|
// the columns after them, such as the country code.
|
|
const (
|
|
asnColumn = "asn"
|
|
handleColumn = "handle"
|
|
descriptionColumn = "description"
|
|
columnsRead = 3
|
|
)
|
|
|
|
var errUnexpectedHeader = errors.New("unexpected CSV header")
|
|
|
|
// ASInfo represents information about an Autonomous System
|
|
type ASInfo struct {
|
|
ASN int `json:"asn"`
|
|
Handle string `json:"handle"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
func main() {
|
|
// Configure logger to write to stderr
|
|
log.SetOutput(os.Stderr)
|
|
|
|
// Fetch the CSV data
|
|
log.Println("Fetching AS CSV data...")
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, asCsvURL, nil)
|
|
if err != nil {
|
|
log.Fatalf("Failed to fetch CSV: %v", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
log.Fatalf("Failed to fetch CSV: %v", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Fatalf("Failed to fetch CSV: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
asInfos, err := parseCSV(resp.Body)
|
|
|
|
// Closed here rather than deferred: log.Fatalf exits without running
|
|
// deferred calls.
|
|
_ = resp.Body.Close()
|
|
|
|
if err != nil {
|
|
log.Fatalf("Failed to parse CSV: %v", err)
|
|
}
|
|
|
|
log.Printf("Parsed %d AS records", len(asInfos))
|
|
|
|
// Convert to JSON and output to stdout
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", "\t")
|
|
|
|
err = encoder.Encode(asInfos)
|
|
if err != nil {
|
|
log.Fatalf("Failed to encode JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
// parseCSV reads as.csv from r and returns its ASes, with the handle and the
|
|
// description as the file spells them. A header whose first columns are not
|
|
// the AS number, the handle and the description is an error. A record that
|
|
// does not parse is logged and skipped.
|
|
func parseCSV(r io.Reader) ([]ASInfo, error) {
|
|
reader := csv.NewReader(r)
|
|
|
|
header, err := reader.Read()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read CSV header: %w", err)
|
|
}
|
|
|
|
if len(header) < columnsRead || header[0] != asnColumn ||
|
|
header[1] != handleColumn || header[2] != descriptionColumn {
|
|
return nil, fmt.Errorf("%w: %v", errUnexpectedHeader, header)
|
|
}
|
|
|
|
var asInfos []ASInfo
|
|
|
|
for {
|
|
// The reader returns an error for a record with a different number
|
|
// of fields than the header.
|
|
record, err := reader.Read()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
|
|
if err != nil {
|
|
log.Printf("Error reading CSV record: %v", err)
|
|
|
|
continue
|
|
}
|
|
|
|
asn, err := strconv.Atoi(record[0])
|
|
if err != nil {
|
|
log.Printf("Invalid ASN %q: %v", record[0], err)
|
|
|
|
continue
|
|
}
|
|
|
|
asInfos = append(asInfos, ASInfo{
|
|
ASN: asn,
|
|
Handle: strings.TrimSpace(record[1]),
|
|
Description: strings.TrimSpace(record[2]),
|
|
})
|
|
}
|
|
|
|
return asInfos, nil
|
|
}
|