crucible build parity (#14)
forces build parity with crucible = local builds and CI/CD builds use different tools. Reviewed-on: #14 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit was merged in pull request #14.
This commit is contained in:
+249
-15
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -13,10 +14,13 @@ import (
|
||||
"time"
|
||||
|
||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const autogenManifestCacheMaxAge = time.Hour
|
||||
|
||||
const defaultBunnyCDNBase = "https://depot.westgate.pw"
|
||||
|
||||
// errAutogenManifestUnavailable marks a released autogen manifest that could not
|
||||
// be located or fetched (network failure, missing release/asset, empty payload,
|
||||
// or an undeterminable sow-assets repo). It is deliberately distinct from an
|
||||
@@ -178,17 +182,22 @@ func preserveAutogenConsumerLockEntries(collected []nativeCollectedDataset, cons
|
||||
func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig) func(string) bool {
|
||||
switch consumer.Mode {
|
||||
case "accessory_visualeffects":
|
||||
policy := resolveAccessoryVisualeffectsPolicy(consumer, nil)
|
||||
delimiter := policy.Delimiter
|
||||
if delimiter == "" {
|
||||
delimiter = "/"
|
||||
dataset := strings.TrimSpace(consumer.Dataset)
|
||||
if dataset == "" {
|
||||
dataset = "visualeffects"
|
||||
}
|
||||
prefix := "visualeffects:"
|
||||
prefix := dataset + ":"
|
||||
// Build the group set from the resolved policy so consumers that omit
|
||||
// AccessoryVisualeffects.Groups still match the four default group names.
|
||||
// NOTE: this matcher assumes the default folder_name+preserve group
|
||||
// representation. Keys produced under case:lower or
|
||||
// group_token_source:prefix would not match and would escape pruning;
|
||||
// no production config uses those combinations.
|
||||
policy := resolveAccessoryVisualeffectsPolicy(consumer, nil)
|
||||
groups := make(map[string]struct{}, len(policy.Groups))
|
||||
for group, groupPolicy := range policy.Groups {
|
||||
token := applyAccessoryVisualeffectCase(accessoryVisualeffectGroupToken(group, groupPolicy, policy), policy)
|
||||
if strings.TrimSpace(token) != "" {
|
||||
groups[token] = struct{}{}
|
||||
for group := range policy.Groups {
|
||||
if group = strings.TrimSpace(group); group != "" {
|
||||
groups[group] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
@@ -199,7 +208,7 @@ func autogenConsumerManagedLockKeyMatcher(consumer project.AutogenConsumerConfig
|
||||
return false
|
||||
}
|
||||
rest := key[len(prefix):]
|
||||
idx := strings.Index(rest, delimiter)
|
||||
idx := strings.Index(rest, "/")
|
||||
if idx <= 0 {
|
||||
return false
|
||||
}
|
||||
@@ -225,6 +234,9 @@ func autogenConsumerTargetsDataset(dataset nativeCollectedDataset, consumer proj
|
||||
}
|
||||
|
||||
func resolveAutogenConsumerManifest(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) (*autogenManifest, error) {
|
||||
if strings.TrimSpace(consumer.Source.Kind) == "cdn_channel" {
|
||||
return resolveCDNChannelManifest(p, consumer, consumer.Source, progress)
|
||||
}
|
||||
if manifestFile := strings.TrimSpace(consumer.ManifestFile); manifestFile != "" {
|
||||
return readAutogenConsumerManifestFile(p, consumer, manifestFile, progress)
|
||||
}
|
||||
@@ -276,6 +288,183 @@ func readAutogenConsumerManifestFile(p *project.Project, consumer project.Autoge
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// resolveCDNChannelManifest resolves the accessory-VFX model list anonymously
|
||||
// from the asset CDN: channel pointer -> tag -> per-tag vfxs.yml. It ports the
|
||||
// fail policy of the deleted resolve-accessory-vfx.sh: unreachable inputs fail
|
||||
// OPEN (errAutogenManifestUnavailable -> optional consumer preserves lock IDs);
|
||||
// a published-but-broken release (vfxs.yml 404 while haks.json present) or a
|
||||
// malformed vfxs.yml HARD fail.
|
||||
func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsumerConfig, src project.AutogenSourceConfig, progress func(string)) (*autogenManifest, error) {
|
||||
if progress == nil {
|
||||
progress = func(string) {}
|
||||
}
|
||||
|
||||
// 1. Offline / air-gapped override: a vfxs.yml path or a manifest-repo
|
||||
// checkout root containing assets/vfxs.yml. Skips the network entirely.
|
||||
if envName := strings.TrimSpace(src.OfflineOverrideEnv); envName != "" {
|
||||
if override := strings.TrimSpace(os.Getenv(envName)); override != "" {
|
||||
vfxsPath := override
|
||||
if info, err := os.Stat(override); err == nil && info.IsDir() {
|
||||
vfxsPath = filepath.Join(override, "assets", "vfxs.yml")
|
||||
}
|
||||
raw, err := os.ReadFile(vfxsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("offline vfxs override %s: %w", vfxsPath, err)
|
||||
}
|
||||
entries, err := filterCDNChannelEntries(raw, consumer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
progress(fmt.Sprintf("Using offline vfxs override for %s from %s...", consumer.ID, vfxsPath))
|
||||
return &autogenManifest{ID: consumer.Producer, Ref: "local", Entries: entries}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Channel: explicit env, else derived from the git tag.
|
||||
channel := strings.TrimSpace(os.Getenv(strings.TrimSpace(src.ChannelEnv)))
|
||||
if channel == "" {
|
||||
channel = deriveAssetChannel(p.Root)
|
||||
}
|
||||
|
||||
// 3. CDN base: baked default, then env override, then config literal (most specific wins).
|
||||
cdnBase := defaultBunnyCDNBase
|
||||
if envName := strings.TrimSpace(src.CDNBaseEnv); envName != "" {
|
||||
if v := strings.TrimSpace(os.Getenv(envName)); v != "" {
|
||||
cdnBase = v
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(src.CDNBase); v != "" {
|
||||
cdnBase = v
|
||||
}
|
||||
cdnBase = strings.TrimRight(cdnBase, "/")
|
||||
|
||||
join := func(path, tag string) string {
|
||||
return cdnBase + "/" + strings.TrimLeft(strings.ReplaceAll(path, "{tag}", tag), "/")
|
||||
}
|
||||
|
||||
// 4. channels.json -> tag. Unreachable / non-JSON / channel-absent = fail open.
|
||||
channelsURL := join(src.ChannelsPath, "")
|
||||
status, body, err := httpGetStatus(channelsURL)
|
||||
if err != nil {
|
||||
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json unreachable %s: %w", channelsURL, err))
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json HTTP %d %s", status, channelsURL))
|
||||
}
|
||||
var channels map[string]string
|
||||
if err := json.Unmarshal(body, &channels); err != nil {
|
||||
return nil, unavailableAutogenManifest(fmt.Errorf("channels.json not JSON %s: %w", channelsURL, err))
|
||||
}
|
||||
tag := strings.TrimSpace(channels[channel])
|
||||
if tag == "" {
|
||||
return nil, unavailableAutogenManifest(fmt.Errorf("channel %q absent in channels.json (%s)", channel, channelsURL))
|
||||
}
|
||||
progress(fmt.Sprintf("Accessory VFX: channel %s -> tag %s", channel, tag))
|
||||
|
||||
// 5. vfxs.yml for the tag.
|
||||
manifestURL := join(src.ManifestPath, tag)
|
||||
vstatus, vbody, err := httpGetStatus(manifestURL)
|
||||
if err != nil {
|
||||
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml unreachable %s: %w", manifestURL, err))
|
||||
}
|
||||
switch vstatus {
|
||||
case http.StatusOK:
|
||||
// fall through to parse
|
||||
case http.StatusNotFound:
|
||||
// 404 + release marker present = published-but-broken release: HARD fail
|
||||
// (this is the v0.1.4 silent-drop bug). 404 + marker absent = no published
|
||||
// release for this tag: fail open.
|
||||
if marker := strings.TrimSpace(src.ReleaseMarkerPath); marker != "" {
|
||||
markerURL := join(marker, tag)
|
||||
if mstatus, _, merr := httpGetStatus(markerURL); merr == nil && mstatus == http.StatusOK {
|
||||
return nil, fmt.Errorf("release %s has %s but no vfxs.yml (%s) — accessory rows would silently vanish; backfill/republish vfxs.yml for %s", tag, marker, manifestURL, tag)
|
||||
}
|
||||
}
|
||||
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml 404 %s (no published release for %s)", manifestURL, tag))
|
||||
default:
|
||||
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml HTTP %d %s", vstatus, manifestURL))
|
||||
}
|
||||
|
||||
entries, err := filterCDNChannelEntries(vbody, consumer)
|
||||
if err != nil {
|
||||
return nil, err // malformed vfxs.yml = HARD fail
|
||||
}
|
||||
progress(fmt.Sprintf("Accessory VFX: resolved %d model entries from %s", len(entries), manifestURL))
|
||||
return &autogenManifest{ID: consumer.Producer, Ref: tag, Entries: entries}, nil
|
||||
}
|
||||
|
||||
// filterCDNChannelEntries parses vfxs.yml and keeps restype==mdl assets under
|
||||
// vfxs/<group>/ for the consumer's 4 accessory groups, stripping the leading
|
||||
// vfxs/ from each source path. A present-but-malformed manifest (unparseable, or
|
||||
// no assets array) is an error; an empty assets array yields zero entries.
|
||||
func filterCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
|
||||
var manifest struct {
|
||||
Assets *[]struct {
|
||||
Path string `yaml:"path"`
|
||||
Restype string `yaml:"restype"`
|
||||
} `yaml:"assets"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("malformed vfxs.yml (cannot parse): %w", err)
|
||||
}
|
||||
if manifest.Assets == nil {
|
||||
return nil, fmt.Errorf("malformed vfxs.yml (no assets array)")
|
||||
}
|
||||
groups := make(map[string]struct{}, len(consumer.AccessoryVisualeffects.Groups))
|
||||
for g := range consumer.AccessoryVisualeffects.Groups {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
groups[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
var entries []autogenManifestEntry
|
||||
for _, a := range *manifest.Assets {
|
||||
if a.Restype != "mdl" {
|
||||
continue
|
||||
}
|
||||
path := filepath.ToSlash(strings.TrimSpace(a.Path))
|
||||
if !strings.HasPrefix(path, "vfxs/") {
|
||||
continue
|
||||
}
|
||||
rel := strings.TrimPrefix(path, "vfxs/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
if _, ok := groups[parts[0]]; !ok {
|
||||
continue
|
||||
}
|
||||
entry := autogenManifestEntry{
|
||||
Source: rel,
|
||||
Group: parts[0],
|
||||
ModelStem: strings.TrimSuffix(parts[len(parts)-1], ".mdl"),
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
entry.Subgroup = strings.Join(parts[1:len(parts)-1], "/")
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
|
||||
return strings.Compare(a.Source, b.Source)
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// deriveAssetChannel maps the current git tag (or GITHUB_REF_NAME) to a channel:
|
||||
// a prerelease tag (v*-*) -> testing; a stable tag (v*) -> current; anything
|
||||
// else (branch/PR/dev) -> current. Mirrors resolve-accessory-vfx.sh.
|
||||
func deriveAssetChannel(root string) string {
|
||||
ref := strings.TrimSpace(os.Getenv("GITHUB_REF_NAME"))
|
||||
if ref == "" {
|
||||
if out, err := gitOutput(root, "describe", "--tags", "--exact-match"); err == nil {
|
||||
ref = strings.TrimSpace(out)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(ref, "v") && strings.Contains(ref, "-") {
|
||||
return "testing"
|
||||
}
|
||||
return "current"
|
||||
}
|
||||
|
||||
func resolveAutogenLocalOverrideRoot(p *project.Project, consumer project.AutogenConsumerConfig) (string, error) {
|
||||
root := strings.TrimSpace(consumer.LocalOverrideRoot)
|
||||
if root != "" {
|
||||
@@ -870,30 +1059,61 @@ func augmentWithAutogeneratedAccessoryVisualeffects(collected []nativeCollectedD
|
||||
return rowID
|
||||
}
|
||||
|
||||
liveLockKeys := make(map[string]struct{}, len(entries))
|
||||
for _, entry := range entries {
|
||||
key, label, modelStem, groupPolicy, ok := accessoryVisualeffectIdentity(dataset.Dataset.Name, entry, policy)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lockKey := accessoryVisualeffectLockKey(dataset.Dataset.Name, entry)
|
||||
liveLockKeys[lockKey] = struct{}{}
|
||||
|
||||
if existing, exists := rowByKey[key]; exists {
|
||||
applyDiscoveredAccessoryVisualeffectDefaults(existing, dataset.Columns, modelStem, label, policy, groupPolicy)
|
||||
if _, pinned := lockData[lockKey]; !pinned {
|
||||
if rowID, ok := existing["id"].(int); ok {
|
||||
lockData[lockKey] = rowID
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
rowID, ok := lockData[key]
|
||||
rowID, ok := lockData[lockKey]
|
||||
if !ok {
|
||||
if preservedRowID, preserved := historicalLockData[key]; preserved {
|
||||
rowID = preservedRowID
|
||||
} else {
|
||||
switch {
|
||||
case hasKey(historicalLockData, lockKey):
|
||||
rowID = historicalLockData[lockKey]
|
||||
case hasKey(historicalLockData, key):
|
||||
// One-time cutover: adopt the ID from the old config-derived key
|
||||
// so IDs carry over with zero shift on the key-scheme switch. The
|
||||
// stale old key is pruned below.
|
||||
rowID = historicalLockData[key]
|
||||
default:
|
||||
rowID = allocateNextID()
|
||||
}
|
||||
lockData[key] = rowID
|
||||
lockData[lockKey] = rowID
|
||||
}
|
||||
newRow := createDefaultAccessoryVisualeffectRow(dataset.Columns, rowID, key, label, modelStem, policy, groupPolicy)
|
||||
rows = append(rows, newRow)
|
||||
rowByKey[key] = newRow
|
||||
}
|
||||
|
||||
// Stale pruning: drop managed lock keys (this consumer's accessory keys,
|
||||
// incl. now-superseded old config-scheme keys) that no live entry resolves
|
||||
// to, freeing their IDs for first-free reuse. Safe here because the
|
||||
// augmentor only runs on a successful, non-empty resolution; the fail-open
|
||||
// paths skip it entirely and never reach this code.
|
||||
if managed := autogenConsumerManagedLockKeyMatcher(consumer); managed != nil {
|
||||
for lk := range lockData {
|
||||
if _, live := liveLockKeys[lk]; live {
|
||||
continue
|
||||
}
|
||||
if managed(lk) {
|
||||
delete(lockData, lk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(rows, func(a, b map[string]any) int {
|
||||
return a["id"].(int) - b["id"].(int)
|
||||
})
|
||||
@@ -912,6 +1132,20 @@ func nextAvailableAutogenID(used map[int]struct{}) int {
|
||||
}
|
||||
}
|
||||
|
||||
// accessoryVisualeffectLockKey is the model-anchored autogen lock identity:
|
||||
// the dataset namespace prefix plus the model source path from vfxs.yml (leading
|
||||
// vfxs/ already stripped on entry.Source). Config-derived presentation parts
|
||||
// (group token, category, delimiter, case, prefix-stripping) are deliberately
|
||||
// excluded so a config-only edit never reshuffles row IDs.
|
||||
func accessoryVisualeffectLockKey(dataset string, entry autogenManifestEntry) string {
|
||||
return dataset + ":" + strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(entry.Source)), "vfxs/")
|
||||
}
|
||||
|
||||
func hasKey(m map[string]int, key string) bool {
|
||||
_, ok := m[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
type accessoryVisualeffectsPolicy struct {
|
||||
Groups map[string]accessoryVisualeffectsGroupPolicy
|
||||
GroupTokenSource string
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
)
|
||||
|
||||
func cdnConsumer() project.AutogenConsumerConfig {
|
||||
return project.AutogenConsumerConfig{
|
||||
ID: "accessory_visualeffects", Producer: "accessory_visualeffects",
|
||||
Dataset: "visualeffects", Mode: "accessory_visualeffects", Optional: true,
|
||||
AccessoryVisualeffects: project.AccessoryVisualeffectsConfig{
|
||||
Groups: map[string]project.AccessoryVisualeffectGroupConfig{
|
||||
"head_accessories": {}, "chest_accessories": {},
|
||||
"head_decorations": {}, "head_features": {},
|
||||
},
|
||||
},
|
||||
Source: project.AutogenSourceConfig{
|
||||
Kind: "cdn_channel",
|
||||
ChannelsPath: "releases/haks/channels.json",
|
||||
ManifestPath: "releases/haks/{tag}/vfxs.yml",
|
||||
ReleaseMarkerPath: "releases/haks/{tag}/haks.json",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// cdnServer serves channels.json, vfxs.yml, and haks.json from in-memory maps.
|
||||
// A key absent from the map returns 404.
|
||||
func cdnServer(t *testing.T, files map[string]string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, ok := files[r.URL.Path]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestResolveCDNChannelHappyPath(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
srv := cdnServer(t, map[string]string{
|
||||
"/releases/haks/channels.json": `{"current":"v1.2.3"}`,
|
||||
"/releases/haks/v1.2.3/vfxs.yml": `assets:
|
||||
- path: vfxs/head_accessories/hat/hfx_bandana.mdl
|
||||
restype: mdl
|
||||
sha256: aaa
|
||||
- path: vfxs/chest_accessories/cape/cfx_cloak.mdl
|
||||
restype: mdl
|
||||
sha256: bbb
|
||||
- path: vfxs/head_accessories/hat/hfx_bandana.tga
|
||||
restype: tga
|
||||
sha256: ccc
|
||||
- path: vfxs/weapons/sword.mdl
|
||||
restype: mdl
|
||||
sha256: ddd
|
||||
`,
|
||||
})
|
||||
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
||||
t.Setenv("SOW_TOPDATA_ASSET_CHANNEL", "current")
|
||||
|
||||
p := testProject(root)
|
||||
c := cdnConsumer()
|
||||
c.Source.CDNBaseEnv = "BUNNY_CDN_BASE"
|
||||
c.Source.ChannelEnv = "SOW_TOPDATA_ASSET_CHANNEL"
|
||||
|
||||
m, err := resolveCDNChannelManifest(p, c, c.Source, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
if len(m.Entries) != 2 {
|
||||
t.Fatalf("want 2 mdl entries under target groups, got %d: %#v", len(m.Entries), m.Entries)
|
||||
}
|
||||
// Sorted by source: chest_accessories/... before head_accessories/...
|
||||
if m.Entries[0].Source != "chest_accessories/cape/cfx_cloak.mdl" {
|
||||
t.Fatalf("unexpected first entry: %#v", m.Entries[0])
|
||||
}
|
||||
if m.Entries[1].Source != "head_accessories/hat/hfx_bandana.mdl" ||
|
||||
m.Entries[1].Group != "head_accessories" ||
|
||||
m.Entries[1].Subgroup != "hat" ||
|
||||
m.Entries[1].ModelStem != "hfx_bandana" {
|
||||
t.Fatalf("unexpected derived entry: %#v", m.Entries[1])
|
||||
}
|
||||
if m.Ref != "v1.2.3" {
|
||||
t.Fatalf("want ref v1.2.3, got %q", m.Ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCDNChannelFailOpen(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
c := cdnConsumer()
|
||||
c.Source.CDNBaseEnv = "BUNNY_CDN_BASE"
|
||||
c.Source.ChannelEnv = "SOW_TOPDATA_ASSET_CHANNEL"
|
||||
t.Setenv("SOW_TOPDATA_ASSET_CHANNEL", "current")
|
||||
|
||||
t.Run("channels.json unreachable", func(t *testing.T) {
|
||||
t.Setenv("BUNNY_CDN_BASE", "http://127.0.0.1:0") // unroutable
|
||||
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
||||
if err == nil || !errorIsUnavailable(err) {
|
||||
t.Fatalf("want fail-open (unavailable), got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("channel absent", func(t *testing.T) {
|
||||
srv := cdnServer(t, map[string]string{"/releases/haks/channels.json": `{"testing":"v9"}`})
|
||||
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
||||
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
||||
if err == nil || !errorIsUnavailable(err) {
|
||||
t.Fatalf("want fail-open (unavailable), got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vfxs 404 no release marker", func(t *testing.T) {
|
||||
srv := cdnServer(t, map[string]string{"/releases/haks/channels.json": `{"current":"v1"}`})
|
||||
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
||||
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
||||
if err == nil || !errorIsUnavailable(err) {
|
||||
t.Fatalf("want fail-open (unavailable), got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveCDNChannelHardFail(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
c := cdnConsumer()
|
||||
c.Source.CDNBaseEnv = "BUNNY_CDN_BASE"
|
||||
c.Source.ChannelEnv = "SOW_TOPDATA_ASSET_CHANNEL"
|
||||
t.Setenv("SOW_TOPDATA_ASSET_CHANNEL", "current")
|
||||
|
||||
t.Run("broken release: vfxs 404 + haks.json present", func(t *testing.T) {
|
||||
srv := cdnServer(t, map[string]string{
|
||||
"/releases/haks/channels.json": `{"current":"v1"}`,
|
||||
"/releases/haks/v1/haks.json": `{}`,
|
||||
})
|
||||
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
||||
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
||||
if err == nil || errorIsUnavailable(err) {
|
||||
t.Fatalf("want HARD fail, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed vfxs.yml: no assets array", func(t *testing.T) {
|
||||
srv := cdnServer(t, map[string]string{
|
||||
"/releases/haks/channels.json": `{"current":"v1"}`,
|
||||
"/releases/haks/v1/vfxs.yml": "generated_at: 2026-01-01\n",
|
||||
})
|
||||
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
||||
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
||||
if err == nil || errorIsUnavailable(err) {
|
||||
t.Fatalf("want HARD fail, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed vfxs.yml: syntactically broken YAML", func(t *testing.T) {
|
||||
srv := cdnServer(t, map[string]string{
|
||||
"/releases/haks/channels.json": `{"current":"v1"}`,
|
||||
"/releases/haks/v1/vfxs.yml": "assets:\n - path: [unterminated",
|
||||
})
|
||||
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
||||
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
||||
if err == nil || errorIsUnavailable(err) {
|
||||
t.Fatalf("want HARD fail on broken YAML, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveCDNChannelOfflineOverride(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
vfxs := filepath.Join(root, "vfxs.yml")
|
||||
writeFile(t, vfxs, `assets:
|
||||
- path: vfxs/head_features/scar/hfx_scar.mdl
|
||||
restype: mdl
|
||||
sha256: zzz
|
||||
`)
|
||||
t.Setenv("SOW_VFXS_MANIFEST", vfxs)
|
||||
c := cdnConsumer()
|
||||
c.Source.OfflineOverrideEnv = "SOW_VFXS_MANIFEST"
|
||||
|
||||
m, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("offline override failed: %v", err)
|
||||
}
|
||||
if len(m.Entries) != 1 || m.Entries[0].Source != "head_features/scar/hfx_scar.mdl" {
|
||||
t.Fatalf("unexpected offline entries: %#v", m.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func errorIsUnavailable(err error) bool {
|
||||
return errors.Is(err, errAutogenManifestUnavailable)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
)
|
||||
|
||||
func accConsumer() project.AutogenConsumerConfig {
|
||||
return project.AutogenConsumerConfig{
|
||||
ID: "accessory_visualeffects", Producer: "accessory_visualeffects",
|
||||
Dataset: "visualeffects", Mode: "accessory_visualeffects", Optional: true,
|
||||
AccessoryVisualeffects: project.AccessoryVisualeffectsConfig{
|
||||
Groups: map[string]project.AccessoryVisualeffectGroupConfig{
|
||||
"head_accessories": {ModelColumns: []string{"Imp_HeadCon_Node"}},
|
||||
},
|
||||
GroupTokenSource: "folder_name", CategoryFrom: "immediate_parent",
|
||||
Delimiter: "/", Case: "preserve",
|
||||
StripModelPrefixes: []string{"hfx_"},
|
||||
KeyFormat: "{dataset}:{group}{delimiter}{category_segment}{stem}",
|
||||
LabelFormat: "{group}{delimiter}{category_segment}{stem}",
|
||||
ModelColumn: "Imp_HeadCon_Node",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func vfxEntry() autogenManifestEntry {
|
||||
return autogenManifestEntry{
|
||||
Source: "head_accessories/hat/hfx_bandana.mdl", Group: "head_accessories",
|
||||
Subgroup: "hat", ModelStem: "hfx_bandana",
|
||||
}
|
||||
}
|
||||
|
||||
func vfxDataset(lock map[string]int) nativeCollectedDataset {
|
||||
return nativeCollectedDataset{
|
||||
Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase},
|
||||
Columns: []string{"Label", "Imp_HeadCon_Node"},
|
||||
Rows: nil,
|
||||
LockData: lock,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessoryLockKeyIsModelAnchored(t *testing.T) {
|
||||
got := accessoryVisualeffectLockKey("visualeffects", vfxEntry())
|
||||
want := "visualeffects:head_accessories/hat/hfx_bandana.mdl"
|
||||
if got != want {
|
||||
t.Fatalf("lock key = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A config-only change (here: a different key_format) must NOT move the ID,
|
||||
// because identity is the model path, not the presentation key.
|
||||
func TestConfigOnlyChangeKeepsLockID(t *testing.T) {
|
||||
lockKey := "visualeffects:head_accessories/hat/hfx_bandana.mdl"
|
||||
collected := []nativeCollectedDataset{vfxDataset(map[string]int{lockKey: 555})}
|
||||
|
||||
c := accConsumer()
|
||||
c.AccessoryVisualeffects.KeyFormat = "{dataset}:{stem}" // config-only change
|
||||
got, err := augmentWithAutogeneratedAccessoryVisualeffects(collected, []autogenManifestEntry{vfxEntry()}, c, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("augment failed: %v", err)
|
||||
}
|
||||
if got[0].LockData[lockKey] != 555 {
|
||||
t.Fatalf("expected ID preserved at 555, got %#v", got[0].LockData)
|
||||
}
|
||||
}
|
||||
|
||||
// A model re-export (same path) keeps its ID — content change != identity change.
|
||||
func TestModelReExportKeepsLockID(t *testing.T) {
|
||||
lockKey := "visualeffects:head_accessories/hat/hfx_bandana.mdl"
|
||||
collected := []nativeCollectedDataset{vfxDataset(map[string]int{lockKey: 777})}
|
||||
got, err := augmentWithAutogeneratedAccessoryVisualeffects(collected, []autogenManifestEntry{vfxEntry()}, accConsumer(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("augment failed: %v", err)
|
||||
}
|
||||
if got[0].LockData[lockKey] != 777 {
|
||||
t.Fatalf("expected ID 777 preserved, got %#v", got[0].LockData)
|
||||
}
|
||||
}
|
||||
|
||||
// A removed model frees its old ID; the surviving model keeps its lock entry.
|
||||
func TestModelRemovalPrunesStaleLockKey(t *testing.T) {
|
||||
keep := "visualeffects:head_accessories/hat/hfx_bandana.mdl"
|
||||
gone := "visualeffects:head_accessories/hat/hfx_removed.mdl"
|
||||
collected := []nativeCollectedDataset{vfxDataset(map[string]int{keep: 1, gone: 2})}
|
||||
got, err := augmentWithAutogeneratedAccessoryVisualeffects(collected, []autogenManifestEntry{vfxEntry()}, accConsumer(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("augment failed: %v", err)
|
||||
}
|
||||
if _, present := got[0].LockData[gone]; present {
|
||||
t.Fatalf("expected removed model's lock key pruned, got %#v", got[0].LockData)
|
||||
}
|
||||
if got[0].LockData[keep] != 1 {
|
||||
t.Fatalf("expected surviving model ID 1 kept, got %#v", got[0].LockData)
|
||||
}
|
||||
}
|
||||
|
||||
// Cutover: a lock that only has the OLD config-derived key adopts that ID under
|
||||
// the new model-path key, with zero shift, and prunes the old key.
|
||||
func TestCutoverRemapPreservesID(t *testing.T) {
|
||||
oldKey := "visualeffects:head_accessories/hat/bandana" // {dataset}:{group}/{category}/{stem-without-hfx_}
|
||||
newKey := "visualeffects:head_accessories/hat/hfx_bandana.mdl"
|
||||
collected := []nativeCollectedDataset{vfxDataset(map[string]int{oldKey: 4242})}
|
||||
got, err := augmentWithAutogeneratedAccessoryVisualeffects(collected, []autogenManifestEntry{vfxEntry()}, accConsumer(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("augment failed: %v", err)
|
||||
}
|
||||
if got[0].LockData[newKey] != 4242 {
|
||||
t.Fatalf("expected cutover to carry ID 4242 to new key, got %#v", got[0].LockData)
|
||||
}
|
||||
if _, present := got[0].LockData[oldKey]; present {
|
||||
t.Fatalf("expected old config key pruned after cutover, got %#v", got[0].LockData)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestApplyAutogenConsumersReadsManifestFile(t *testing.T) {
|
||||
if row["key"] != "visualeffects:head_accessories/hat/bandana" || row["Imp_HeadCon_Node"] != "hfx_bandana" {
|
||||
t.Fatalf("unexpected generated row: %#v", row)
|
||||
}
|
||||
if _, ok := got[0].LockData["visualeffects:head_accessories/hat/bandana"]; !ok {
|
||||
if _, ok := got[0].LockData["visualeffects:head_accessories/hat/hfx_bandana.mdl"]; !ok {
|
||||
t.Fatalf("expected lock id for generated accessory, got %#v", got[0].LockData)
|
||||
}
|
||||
// Authored key untouched.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHasLFSPointerStubsDetectsStub(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "a"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
stub := "version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 12345\n"
|
||||
writeFile(t, filepath.Join(root, "a", "model.mdl"), stub)
|
||||
writeFile(t, filepath.Join(root, "a", "real.txt"), "not a pointer, real bytes here")
|
||||
|
||||
if !hasLFSPointerStubs(root) {
|
||||
t.Fatal("expected stub detection to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasLFSPointerStubsNoStub(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "real.mdl"), "binary-ish content without a pointer header")
|
||||
if hasLFSPointerStubs(root) {
|
||||
t.Fatal("expected no stub detected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
)
|
||||
|
||||
// PARITY GUARD: a bare project (no manifest_file, no NWN_ROOT, no token) with a
|
||||
// cdn_channel consumer must produce accessory rows. If accessory resolution ever
|
||||
// drifts back out of Crucible into a wrapper, this fails.
|
||||
func TestParityGuardCDNChannelProducesAccessoryRows(t *testing.T) {
|
||||
root := testProjectRoot(t)
|
||||
srv := cdnServer(t, map[string]string{
|
||||
"/releases/haks/channels.json": `{"current":"v1.0.0"}`,
|
||||
"/releases/haks/v1.0.0/vfxs.yml": `assets:
|
||||
- path: vfxs/head_accessories/hat/hfx_bandana.mdl
|
||||
restype: mdl
|
||||
sha256: aaa
|
||||
- path: vfxs/chest_accessories/cape/cfx_cloak.mdl
|
||||
restype: mdl
|
||||
sha256: bbb
|
||||
`,
|
||||
})
|
||||
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
||||
t.Setenv("SOW_TOPDATA_ASSET_CHANNEL", "current")
|
||||
// Prove R3: no NWN_ROOT in the environment.
|
||||
t.Setenv("NWN_ROOT", "")
|
||||
|
||||
c := cdnConsumer()
|
||||
c.Source.CDNBaseEnv = "BUNNY_CDN_BASE"
|
||||
c.Source.ChannelEnv = "SOW_TOPDATA_ASSET_CHANNEL"
|
||||
c.AccessoryVisualeffects.ModelColumn = "Imp_HeadCon_Node"
|
||||
|
||||
p := testProject(root)
|
||||
p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{c}
|
||||
|
||||
collected := []nativeCollectedDataset{{
|
||||
Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase},
|
||||
Columns: []string{"Label", "Imp_HeadCon_Node", "Imp_Root_S_Node"},
|
||||
Rows: nil,
|
||||
LockData: map[string]int{},
|
||||
}}
|
||||
|
||||
got, err := applyAutogenConsumers(p, collected, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("applyAutogenConsumers failed: %v", err)
|
||||
}
|
||||
if len(got[0].Rows) != 2 {
|
||||
t.Fatalf("PARITY GUARD: expected 2 accessory rows, got %d (%#v)", len(got[0].Rows), got[0].Rows)
|
||||
}
|
||||
wantKey := "visualeffects:head_accessories/hat/hfx_bandana.mdl"
|
||||
if _, ok := got[0].LockData[wantKey]; !ok {
|
||||
t.Fatalf("PARITY GUARD: expected lock id for %s, got %#v", wantKey, got[0].LockData)
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,19 @@ func writePartsManifestCache(path string, manifest *partsManifest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpGetStatus performs an anonymous GET and returns the status code plus the
|
||||
// body (capped). No auth header: cdn_channel inputs (channels.json, vfxs.yml,
|
||||
// haks.json) are public CDN text — R1.
|
||||
func httpGetStatus(target string) (int, []byte, error) {
|
||||
resp, err := partsManifestHTTPClient.Get(target)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
func inventoryFromPartsManifest(manifest *partsManifest) map[string]map[int]struct{} {
|
||||
result := make(map[string]map[int]struct{}, len(supportedPartCategories))
|
||||
for _, category := range supportedPartCategories {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package topdata
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -75,6 +76,8 @@ func BuildAndPackage(p *project.Project, progress func(string)) (PackageResult,
|
||||
type BuildAndPackageOptions struct {
|
||||
Force bool
|
||||
BuildWiki bool
|
||||
// ponytail: no SkipLFS field — CRUCIBLE_SKIP_LFS env is the single skip mechanism;
|
||||
// the CLI flag sets it directly before calling into this package.
|
||||
}
|
||||
|
||||
func BuildAndPackageWithOptions(p *project.Project, opts BuildAndPackageOptions, progress func(string)) (PackageResult, error) {
|
||||
@@ -123,6 +126,9 @@ func packageBuiltTopData(p *project.Project, nativeResult BuildResult, progress
|
||||
if progress == nil {
|
||||
progress = func(string) {}
|
||||
}
|
||||
if err := materializeAssetLFS(p, progress); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
outputHAK := p.TopDataPackageHAKPath()
|
||||
outputTLK := p.TopDataPackageTLKPath()
|
||||
|
||||
@@ -711,3 +717,80 @@ func countCompiledFiles(dir, extension string) (int, time.Time, error) {
|
||||
}
|
||||
return count, oldest, nil
|
||||
}
|
||||
|
||||
// materializeAssetLFS ensures the topdata asset tree holds real bytes, not
|
||||
// Git-LFS pointer stubs, before the hak is packed. A bare CI checkout does not
|
||||
// smudge LFS, so a direct Crucible build would otherwise ship ~130-byte stubs.
|
||||
// It uses the clone's own credentials (R1) and hard-fails if stubs survive the
|
||||
// pull. Set CRUCIBLE_SKIP_LFS=1 to opt out (maintain-tree regenerates the data/
|
||||
// tree and discards the package, so it must not hard-depend on LFS).
|
||||
func materializeAssetLFS(p *project.Project, progress func(string)) error {
|
||||
if progress == nil {
|
||||
progress = func(string) {}
|
||||
}
|
||||
assetsDir := filepath.Join(p.TopDataSourceDir(), "assets")
|
||||
info, err := os.Stat(assetsDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil // no asset tree to materialize
|
||||
}
|
||||
if !hasLFSPointerStubs(assetsDir) {
|
||||
return nil // already real bytes (or repo does not use LFS)
|
||||
}
|
||||
if isTruthyEnv(os.Getenv("CRUCIBLE_SKIP_LFS")) {
|
||||
progress("CRUCIBLE_SKIP_LFS set: leaving Git-LFS pointer stubs unmaterialized (package will be discarded)")
|
||||
return nil
|
||||
}
|
||||
if _, err := gitOutput(p.Root, "lfs", "version"); err != nil {
|
||||
return fmt.Errorf("asset tree has Git-LFS pointer stubs but git-lfs is unavailable; install git-lfs or enter the nix devshell: %w", err)
|
||||
}
|
||||
progress("Materializing Git-LFS assets (git lfs pull)...")
|
||||
// A fresh clone may lack the lfs smudge/clean filters; install them locally
|
||||
// first (scoped to this repo's .git/config, no host mutation). Idempotent.
|
||||
if _, err := gitOutput(p.Root, "lfs", "install", "--local"); err != nil {
|
||||
return fmt.Errorf("git lfs install --local failed: %w", err)
|
||||
}
|
||||
if _, err := gitOutput(p.Root, "lfs", "pull"); err != nil {
|
||||
return fmt.Errorf("git lfs pull failed: %w", err)
|
||||
}
|
||||
if hasLFSPointerStubs(assetsDir) {
|
||||
return fmt.Errorf("refusing to build from pointer stubs: Git-LFS pointer stubs remain under %s after git lfs pull", assetsDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasLFSPointerStubs reports whether any small text file under root is a Git-LFS
|
||||
// pointer. Real assets are larger than any pointer stub, so the size gate keeps
|
||||
// this cheap on a tree of binaries.
|
||||
func hasLFSPointerStubs(root string) bool {
|
||||
found := false
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || found {
|
||||
return nil
|
||||
}
|
||||
info, ierr := d.Info()
|
||||
if ierr != nil || info.Size() == 0 || info.Size() > 1024 {
|
||||
return nil
|
||||
}
|
||||
f, oerr := os.Open(path)
|
||||
if oerr != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, 256)
|
||||
n, _ := f.Read(buf)
|
||||
if bytes.Contains(buf[:n], []byte("git-lfs.github.com/spec/v1")) {
|
||||
found = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func isTruthyEnv(v string) bool {
|
||||
switch strings.TrimSpace(strings.ToLower(v)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7706,13 +7706,13 @@ func TestApplyAutogenConsumersAugmentsAccessoryVisualeffectsFromLocalOverride(t
|
||||
t.Fatalf("unexpected head feature defaults: %#v", hair)
|
||||
}
|
||||
|
||||
if _, ok := got[0].LockData["visualeffects:head_accessories/hat/bandana"]; !ok {
|
||||
if _, ok := got[0].LockData["visualeffects:head_accessories/hat/hfx_bandana.mdl"]; !ok {
|
||||
t.Fatalf("expected head accessory lock id, got %#v", got[0].LockData)
|
||||
}
|
||||
if _, ok := got[0].LockData["visualeffects:head_decorations/laurel/laurel"]; !ok {
|
||||
if _, ok := got[0].LockData["visualeffects:head_decorations/laurel/hfx_laurel.mdl"]; !ok {
|
||||
t.Fatalf("expected head decoration lock id, got %#v", got[0].LockData)
|
||||
}
|
||||
if _, ok := got[0].LockData["visualeffects:head_features/hair/hair_bangs"]; !ok {
|
||||
if _, ok := got[0].LockData["visualeffects:head_features/hair/hfx_hair_bangs.mdl"]; !ok {
|
||||
t.Fatalf("expected head feature lock id, got %#v", got[0].LockData)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user