feat(vendordep): add vendordep management

- Add vendordep add
- Add vendordep remove
- Add vendordep list
- Refactor downloading
This commit is contained in:
2025-10-17 14:15:37 -04:00
parent e073c0d391
commit 2a22f41fac
18 changed files with 1099 additions and 184 deletions

View File

@@ -0,0 +1,3 @@
package artifactory
const DefaultVendorDepArtifactoryUrl = "https://frcmaven.wpi.edu/artifactory"

View File

@@ -0,0 +1,202 @@
package artifactory
import (
"bytes"
"encoding/json"
"errors"
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
)
type ArtifactoryFS struct {
BaseURL string
Client *http.Client
}
func New(baseURL string) ArtifactoryFS {
return ArtifactoryFS{
BaseURL: strings.TrimRight(baseURL, "/") + "/",
Client: &http.Client{Timeout: 10 * time.Second},
}
}
func (afs ArtifactoryFS) GetUrl(name string) string {
cleanName := path.Clean(name)
return afs.BaseURL + cleanName
}
func (afs ArtifactoryFS) Open(name string) (fs.File, error) {
cleanName := path.Clean(name)
url := afs.BaseURL + cleanName
// Fetch metadata via storage API
metaURL := afs.BaseURL + "api/storage/" + cleanName
if cleanName == "." {
metaURL = afs.BaseURL + "api/storage/"
}
resp, err := afs.Client.Get(metaURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fs.ErrNotExist
} else if resp.StatusCode != 200 {
return nil, errors.New("unexpected status: " + resp.Status)
}
var meta struct {
Repo string `json:"repo"`
Path string `json:"path"`
Children []struct {
URI string `json:"uri"`
Folder bool `json:"folder"`
} `json:"children"`
Size string `json:"size"`
}
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
return nil, err
}
// It's a directory
if len(meta.Children) > 0 {
return &artifactoryDir{
entries: meta.Children,
pos: 0,
name: cleanName,
}, nil
}
// It's a file
contentResp, err := afs.Client.Get(url)
if err != nil {
return nil, err
}
if contentResp.StatusCode == 404 {
return nil, fs.ErrNotExist
} else if contentResp.StatusCode != 200 {
return nil, errors.New("unexpected status: " + contentResp.Status)
}
data, err := io.ReadAll(contentResp.Body)
contentResp.Body.Close()
if err != nil {
return nil, err
}
return &artifactoryFile{
data: bytes.NewReader(data),
name: cleanName,
size: int64(len(data)),
}, nil
}
type artifactoryFile struct {
data *bytes.Reader
name string
size int64
}
func (f *artifactoryFile) Stat() (fs.FileInfo, error) {
return &fileInfo{
name: path.Base(f.name),
size: f.size,
mode: 0444,
}, nil
}
func (f *artifactoryFile) Read(p []byte) (int, error) {
return f.data.Read(p)
}
func (f *artifactoryFile) Close() error {
return nil
}
type artifactoryDir struct {
entries []struct {
URI string `json:"uri"`
Folder bool `json:"folder"`
}
pos int
name string
}
func (d *artifactoryDir) Stat() (fs.FileInfo, error) {
return &fileInfo{
name: path.Base(d.name),
mode: fs.ModeDir | 0555,
}, nil
}
func (d *artifactoryDir) Read([]byte) (int, error) {
return 0, errors.New("cannot read directory")
}
func (d *artifactoryDir) Close() error {
return nil
}
func (d *artifactoryDir) ReadDir(n int) ([]fs.DirEntry, error) {
if d.pos >= len(d.entries) && n > 0 {
return nil, io.EOF
}
var entries []fs.DirEntry
max := len(d.entries)
if n > 0 && d.pos+n < max {
max = d.pos + n
}
for ; d.pos < max; d.pos++ {
e := d.entries[d.pos]
entries = append(entries, &dirEntry{
name: strings.TrimPrefix(e.URI, "/"),
isDir: e.Folder,
})
}
return entries, nil
}
type fileInfo struct {
name string
size int64
mode fs.FileMode
}
func (fi *fileInfo) Name() string { return fi.name }
func (fi *fileInfo) Size() int64 { return fi.size }
func (fi *fileInfo) Mode() fs.FileMode { return fi.mode }
func (fi *fileInfo) ModTime() time.Time { return time.Time{} }
func (fi *fileInfo) IsDir() bool { return fi.mode.IsDir() }
func (fi *fileInfo) Sys() any { return nil }
type dirEntry struct {
name string
isDir bool
}
func (de *dirEntry) Name() string { return de.name }
func (de *dirEntry) IsDir() bool { return de.isDir }
func (de *dirEntry) Type() fs.FileMode {
if de.isDir {
return fs.ModeDir
} else {
return 0
}
}
func (de *dirEntry) Info() (fs.FileInfo, error) {
return &fileInfo{
name: de.name,
mode: de.Type(),
}, nil
}

66
cmd/vendordep/remote.go Normal file
View File

@@ -0,0 +1,66 @@
package vendordep
import (
"errors"
"io/fs"
"log/slog"
"regexp"
"rph/cmd/vendordep/artifactory"
"time"
)
type OnlineVendordep struct {
VendordepName string
Version string
FileName string
LastModTime time.Time
}
func ListAvailableOnlineDeps(year string) (map[string][]OnlineVendordep, error) {
fsys := artifactory.New(artifactory.DefaultVendorDepArtifactoryUrl)
path := "vendordeps/vendordep-marketplace/" + year
entries, err := fs.ReadDir(fsys, path)
if err != nil {
slog.Error("Failed to readdir from artifactory", "dir", path, "error", err)
return nil, err
}
type fileEntry struct {
Name string
ModTime time.Time
}
files := make([]fileEntry, len(entries))
for i, e := range entries {
info, err := e.Info()
if err != nil {
return nil, err
}
files[i] = fileEntry{ Name: e.Name(), ModTime: info.ModTime() }
}
allDeps := make(map[string][]OnlineVendordep, len(entries))
// breaks a vendordep file name into "name-of-library" and "v2025.9.28"
re := regexp.MustCompile(`^(.+)-v?(\d+\.\d+(?:\.\d+)?)`)
for _, file := range files {
matches := re.FindStringSubmatch(file.Name)
if len(matches) > 2 {
baseName := matches[1]
version := matches[2]
allDeps[baseName] = append(allDeps[baseName], OnlineVendordep{
VendordepName: baseName,
Version: version,
FileName: file.Name,
LastModTime: file.ModTime,
})
} else {
return nil, errors.New("Vendordep file name does not match format '" + file.Name + "'")
}
}
return allDeps, nil
}

80
cmd/vendordep/store.go Normal file
View File

@@ -0,0 +1,80 @@
package vendordep
import (
"log/slog"
"os"
"path/filepath"
"rph/state"
)
const vendordepDir = "vendordeps"
func MkCacheDir() {
os.MkdirAll(filepath.Join(state.CachePath, vendordepDir), 0755);
}
func Trash(path string) error {
_, err := os.Stat(path)
if err != nil {
slog.Error("Can't trash file, path must be a valid file", "path", path, "error", err)
return err
}
file, err := os.Open(path)
if err != nil {
slog.Error("Failed to open vendordep file", "error", err)
return err
}
defer file.Close()
dep, err := Parse(file)
if err != nil {
slog.Error("Failed to parse vendordep file", "error", err)
}
err = os.Rename(path, filepath.Join(state.CachePath, vendordepDir, dep.FileName))
if err != nil {
slog.Info("Failed to move vendor dep", "error", err)
return err
}
return nil
}
// hasVendorDepOnDisk check if a vendor dep is already on your disk, this is only
// useful if you've got the uuid of the vendordep you would like to install or
// are in a very percarious situation where you have no internet and any version
// of your vendordep will do.
func hasVendorDepOnDisk(dep Vendordep, strict bool) (bool, error) {
path := filepath.Join(state.CachePath, vendordepDir)
// by default we're not matching
matches := false
err := filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error {
if err != nil { return err }
if d.IsDir() { return nil }
file, err := os.Open(filepath.Join(path, d.Name()))
if err != nil {
slog.Error("Failed to open vendordep file", "error", err)
return err
}
defer file.Close()
new_dep, err := Parse(file)
if err != nil {
slog.Error("Failed to parse vendordep file", "error", err)
}
matches = new_dep.Matches(dep, strict)
return nil
})
if err != nil {
slog.Error("Failed to walk the vendordep directory", "error", err)
return false, err
}
return matches, nil
}

129
cmd/vendordep/vendordep.go Normal file
View File

@@ -0,0 +1,129 @@
package vendordep
import (
"encoding/json"
"errors"
"io"
"io/fs"
"log/slog"
"rph/utils"
)
type JavaDepedency struct {
GroupId string `json:"groupId"`
ArtifactId string `json:"artifactId"`
Version string `json:"version"`
}
type JniDependency struct {
GroupId string `json:"groupId"`
ArtifactId string `json:"artifactId"`
Version string `json:"version"`
IsJar bool `json:"isJar"`
SkipInvalidPlatforms bool `json:"skipInvalidPlatforms"`
ValidPlatforms []string `json:"validPlatforms"`
SimMode string `json:"simMode"`
}
type CppDependency struct {
GroupId string `json:"groupId"`
ArtifactId string `json:"artifactId"`
Version string `json:"version"`
LibName string `json:"libName"`
HeaderClassifier string `json:"headerClassifier"`
SharedLibrary bool `json:"sharedLibrary"`
SkipInvalidPlatforms bool `json:"skipInvalidPlatforms"`
BinaryPlatforms []string `json:"binaryPlatforms"`
SimMode string `json:"simMode"`
}
type Vendordep struct {
FileName string `json:"filename"`
Name string `json:"name"`
Version string `json:"version"`
FrcYear utils.StringOrNumber `json:"frcYear"`
UUID string `json:"uuid"`
MavenUrls []string `json:"mavenUrls"`
JsonUrl string `json:"jsonUrl"`
JavaDependencies []JavaDepedency `json:"javaDependencies"`
JniDependencies []JniDependency `json:"jniDependencies"`
CppDependencies []CppDependency `json:"cppDependencies"`
}
// Matches check if one vendor dep matches another in any real useful way
func (v *Vendordep) Matches(other Vendordep, strict bool) bool {
if other.Name != "" && v.Name == other.Name {
if other.Version != "" && v.Version == other.Version {
return true
}
}
if other.UUID != "" && v.UUID == other.UUID { return true }
if !strict {
if other.JsonUrl != "" && v.JsonUrl == other.JsonUrl { return true }
}
return false
}
func Parse(vendordepFile io.Reader) (*Vendordep, error) {
var vendordep Vendordep
if err := json.NewDecoder(vendordepFile).Decode(&vendordep); err != nil {
slog.Error("Invalid vendor dependency Error decoding JSON", "error", err)
return nil, err
}
return &vendordep, nil
}
func ListVendorDeps(projectFs fs.FS) ([]Vendordep, error) {
var out []Vendordep
err := fs.WalkDir(projectFs, "vendordeps", func(path string, d fs.DirEntry,
err error) error {
if err != nil { return err }
if d.IsDir() { return nil }
file, err := projectFs.Open(path)
if err != nil {
slog.Error("unable to open vendor directory", "error", err)
return err
}
defer file.Close()
dep, err := Parse(file)
if err != nil {
slog.Error("Unable to parse vendordep file", "file", d.Name, "error", err)
return err
}
out = append(out, *dep)
return nil
})
if err != nil {
slog.Error("Unable to read vendordep fs", "error", err)
return nil, err
}
return out, nil
}
func FindVendorDepFromName(name string, fs fs.FS) (*Vendordep, error) {
deps, err := ListVendorDeps(fs);
if err != nil {
slog.Error("Unable to find vendor deps", "error", err)
return nil, err
}
for _, dep := range deps {
if dep.Name == name {
return &dep, nil
}
}
return nil, errors.New("Vendordep not found")
}
func ShowInfo(Vendordep) {
}