205 lines
6.9 KiB
Go
205 lines
6.9 KiB
Go
package topdata
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
)
|
|
|
|
const (
|
|
defaultSowAssetsRepoOwner = "ShadowsOverWestgate"
|
|
defaultSowAssetsRepoName = "sow-assets"
|
|
defaultGiteaBaseURL = "https://gitea.westgate.pw"
|
|
partsManifestReleaseTag = "parts-manifest-current"
|
|
partsManifestAssetName = "sow-parts-manifest.json"
|
|
partsManifestCacheFileName = "sow-parts-manifest.json"
|
|
partsManifestRequestTimeout = 30 * time.Second
|
|
)
|
|
|
|
type partsManifest struct {
|
|
Repo string `json:"repo"`
|
|
Ref string `json:"ref"`
|
|
GeneratedAt string `json:"generated_at"`
|
|
Categories map[string][]int `json:"categories"`
|
|
}
|
|
|
|
type giteaRepoSpec struct {
|
|
BaseURL string
|
|
Owner string
|
|
Repo string
|
|
}
|
|
|
|
var partsManifestHTTPClient = &http.Client{Timeout: partsManifestRequestTimeout}
|
|
|
|
func resolveReleasedPartsManifest(p *project.Project, progress func(string)) (map[string]map[int]struct{}, error) {
|
|
if progress == nil {
|
|
progress = func(string) {}
|
|
}
|
|
spec, err := deriveSowAssetsRepoSpec(p.Root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
manifestURL, err := resolvePartsManifestAssetURL(spec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
progress(fmt.Sprintf("Fetching released parts manifest from %s...", manifestURL))
|
|
manifest, err := fetchPartsManifest(manifestURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cachePath := filepath.Join(p.Root, ".cache", "topdata", partsManifestCacheFileName)
|
|
if err := writePartsManifestCache(cachePath, manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
return inventoryFromPartsManifest(manifest), nil
|
|
}
|
|
|
|
func deriveSowAssetsRepoSpec(root string) (giteaRepoSpec, error) {
|
|
remote, err := gitOutput(root, "remote", "get-url", "origin")
|
|
if err != nil || strings.TrimSpace(remote) == "" {
|
|
return giteaRepoSpec{BaseURL: defaultGiteaBaseURL, Owner: defaultSowAssetsRepoOwner, Repo: defaultSowAssetsRepoName}, nil
|
|
}
|
|
if spec, ok := parseRepoSpec(strings.TrimSpace(remote)); ok {
|
|
spec.Repo = defaultSowAssetsRepoName
|
|
return spec, nil
|
|
}
|
|
return giteaRepoSpec{BaseURL: defaultGiteaBaseURL, Owner: defaultSowAssetsRepoOwner, Repo: defaultSowAssetsRepoName}, nil
|
|
}
|
|
|
|
func parseRepoSpec(raw string) (giteaRepoSpec, bool) {
|
|
if strings.HasPrefix(raw, "ssh://") || strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" {
|
|
return giteaRepoSpec{}, false
|
|
}
|
|
parts := strings.Split(strings.Trim(strings.TrimSuffix(u.Path, ".git"), "/"), "/")
|
|
if len(parts) < 2 {
|
|
return giteaRepoSpec{}, false
|
|
}
|
|
base := (&url.URL{Scheme: "https", Host: u.Hostname()}).String()
|
|
if u.Scheme == "http" || u.Scheme == "https" {
|
|
base = (&url.URL{Scheme: u.Scheme, Host: u.Host}).String()
|
|
}
|
|
return giteaRepoSpec{BaseURL: strings.TrimRight(base, "/"), Owner: parts[len(parts)-2], Repo: parts[len(parts)-1]}, true
|
|
}
|
|
if strings.HasPrefix(raw, "git@") {
|
|
withoutUser := strings.TrimPrefix(raw, "git@")
|
|
hostAndPath := strings.SplitN(withoutUser, ":", 2)
|
|
if len(hostAndPath) != 2 {
|
|
return giteaRepoSpec{}, false
|
|
}
|
|
parts := strings.Split(strings.Trim(strings.TrimSuffix(hostAndPath[1], ".git"), "/"), "/")
|
|
if len(parts) < 2 {
|
|
return giteaRepoSpec{}, false
|
|
}
|
|
base := (&url.URL{Scheme: "https", Host: hostAndPath[0]}).String()
|
|
return giteaRepoSpec{BaseURL: strings.TrimRight(base, "/"), Owner: parts[len(parts)-2], Repo: parts[len(parts)-1]}, true
|
|
}
|
|
return giteaRepoSpec{}, false
|
|
}
|
|
|
|
func resolvePartsManifestAssetURL(spec giteaRepoSpec) (string, error) {
|
|
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/tags/%s", strings.TrimRight(spec.BaseURL, "/"), url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(partsManifestReleaseTag))
|
|
var release struct {
|
|
Assets []struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
DownloadURL string `json:"download_url"`
|
|
} `json:"assets"`
|
|
}
|
|
if err := fetchJSON(apiURL, &release); err != nil {
|
|
return "", fmt.Errorf("fetch parts manifest release metadata: %w", err)
|
|
}
|
|
for _, asset := range release.Assets {
|
|
if asset.Name != partsManifestAssetName {
|
|
continue
|
|
}
|
|
if asset.DownloadURL != "" {
|
|
return asset.DownloadURL, nil
|
|
}
|
|
if asset.BrowserDownloadURL != "" {
|
|
return asset.BrowserDownloadURL, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("parts manifest asset %s not found in release %s/%s:%s", partsManifestAssetName, spec.Owner, spec.Repo, partsManifestReleaseTag)
|
|
}
|
|
|
|
func fetchPartsManifest(manifestURL string) (*partsManifest, error) {
|
|
var manifest partsManifest
|
|
if err := fetchJSON(manifestURL, &manifest); err != nil {
|
|
return nil, fmt.Errorf("download parts manifest: %w", err)
|
|
}
|
|
if len(manifest.Categories) == 0 {
|
|
return nil, fmt.Errorf("download parts manifest: categories is empty")
|
|
}
|
|
return &manifest, nil
|
|
}
|
|
|
|
func fetchJSON(target string, out any) error {
|
|
req, err := http.NewRequest(http.MethodGet, target, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if token := strings.TrimSpace(os.Getenv("SOW_ASSETS_TOKEN")); token != "" {
|
|
req.Header.Set("Authorization", "token "+token)
|
|
} else if token := strings.TrimSpace(os.Getenv("GITEA_TOKEN")); token != "" {
|
|
req.Header.Set("Authorization", "token "+token)
|
|
}
|
|
resp, err := partsManifestHTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
return fmt.Errorf("HTTP %d from %s; set SOW_ASSETS_TOKEN or GITEA_TOKEN if the repo is private", resp.StatusCode, target)
|
|
}
|
|
return fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, target, strings.TrimSpace(string(body)))
|
|
}
|
|
decoder := json.NewDecoder(resp.Body)
|
|
if err := decoder.Decode(out); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writePartsManifestCache(path string, manifest *partsManifest) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create parts manifest cache dir: %w", err)
|
|
}
|
|
payload, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal parts manifest cache: %w", err)
|
|
}
|
|
tmpPath := path + ".tmp"
|
|
if err := os.WriteFile(tmpPath, append(payload, '\n'), 0o644); err != nil {
|
|
return fmt.Errorf("write parts manifest cache: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
return fmt.Errorf("replace parts manifest cache: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inventoryFromPartsManifest(manifest *partsManifest) map[string]map[int]struct{} {
|
|
result := make(map[string]map[int]struct{}, len(supportedPartCategories))
|
|
for _, category := range supportedPartCategories {
|
|
ids := make(map[int]struct{})
|
|
for _, rowID := range manifest.Categories[category] {
|
|
ids[rowID] = struct{}{}
|
|
}
|
|
result[category] = ids
|
|
}
|
|
return result
|
|
}
|