1453cfc69b
gates / gates (push) Failing after 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
413 lines
16 KiB
Go
413 lines
16 KiB
Go
package stacks
|
|
|
|
import "strings"
|
|
|
|
// CATALOG COPY IN A SECOND LANGUAGE (localisation slice 5, R-560).
|
|
//
|
|
// A catalog template's customer-facing text is Hungarian in the fields the controller has always
|
|
// read (`description`, `app_info`, `deploy_fields[].label` …). English is a SIBLING BLOCK in the
|
|
// SAME file:
|
|
//
|
|
// description: "Titkosított jegyzet és szöveg megosztás"
|
|
// app_info:
|
|
// first_steps: [...]
|
|
// i18n:
|
|
// en:
|
|
// description: "Encrypted notes and text sharing"
|
|
// app_info:
|
|
// first_steps: [...]
|
|
//
|
|
// THREE PROPERTIES, EACH LOAD-BEARING.
|
|
//
|
|
// 1. **The Hungarian bytes never move.** A translation adds a block; it never edits a Hungarian
|
|
// string. `For("hu")` is the parsed struct with `I18n` cleared — pinned by
|
|
// TestMetaForHuIsIdentity over all 53 real catalog files.
|
|
//
|
|
// 2. **An older controller ignores the block.** `LoadMetadata` uses `yaml.Unmarshal`, which is
|
|
// NON-STRICT (no `KnownFields`; verified — this repo constructs no yaml.Decoder at all), so a
|
|
// controller built before this file silently drops `i18n:` and renders exactly as it did. That
|
|
// is what lets the catalog be pushed to the whole fleet ahead of the floor raise.
|
|
//
|
|
// 3. **Fallback is FIELD BY FIELD, never all-or-nothing.** A missing — or empty — English field
|
|
// shows the Hungarian one (10-localisation.md §4, operator ruling 3). A half-translated app is
|
|
// therefore a legal, shippable state, which is what makes the batch pushes safe.
|
|
//
|
|
// LISTS ARE REPLACED WHOLE, NEVER MERGED BY INDEX. `use_cases`, `first_steps` and `prerequisites`
|
|
// are prose in a running order; merging item 3 of one language with item 4 of another would produce
|
|
// a list nobody wrote. An English list that is present replaces the Hungarian list entirely; an
|
|
// absent or empty one leaves the Hungarian list alone.
|
|
//
|
|
// EVERY OTHER LIST IS MATCHED BY ITS OWN KEY, NEVER BY POSITION: `deploy_fields` by `env_var`,
|
|
// `options` by `value`, `optional_config` groups by `match_group` (the Hungarian `group` value they
|
|
// translate — a group has no other stable identity), `optional_config[].fields` by `env_var`,
|
|
// `integrations` by `target`, `data_paths` by `path`. Position matching would silently mistranslate
|
|
// the moment a Hungarian field is inserted above another, and nothing on the page would look wrong.
|
|
//
|
|
// WHAT AN OVERLAY MAY CARRY IS EXACTLY THE COPY FIELDS — no `env_var`, `type`, `default`,
|
|
// `generate`, `required`, `locked_after_deploy`, `data_key`, `path`, `role`, `docs_url`, no image,
|
|
// port or healthcheck. The struct shape below IS that rule: a translation cannot change what an app
|
|
// does, only what it says. The catalog's `check-copy-i18n.py` gate states the same rule on the file
|
|
// before it is ever parsed here.
|
|
|
|
// MetadataOverlay is one language's copy for one app. Every scalar is a POINTER so that "the
|
|
// translator did not write this field" is distinguishable from "the translator wrote an empty
|
|
// string" — both fall back to Hungarian, but only the first is silent.
|
|
type MetadataOverlay struct {
|
|
Description *string `yaml:"description,omitempty"`
|
|
AppInfo *AppInfoOverlay `yaml:"app_info,omitempty"`
|
|
DeployFields []DeployFieldOverlay `yaml:"deploy_fields,omitempty"`
|
|
OptionalConfig []OptionalConfigGroupOverlay `yaml:"optional_config,omitempty"`
|
|
Integrations []IntegrationOverlay `yaml:"integrations,omitempty"`
|
|
DataPaths []DataPathOverlay `yaml:"data_paths,omitempty"`
|
|
InitialCreds *InitialCredentialsOverlay `yaml:"initial_credentials,omitempty"`
|
|
}
|
|
|
|
// AppInfoOverlay carries the info page's prose. The three lists replace whole.
|
|
type AppInfoOverlay struct {
|
|
Tagline *string `yaml:"tagline,omitempty"`
|
|
UseCases []string `yaml:"use_cases,omitempty"`
|
|
FirstSteps []string `yaml:"first_steps,omitempty"`
|
|
Prerequisites []string `yaml:"prerequisites,omitempty"`
|
|
DefaultCreds *string `yaml:"default_creds,omitempty"`
|
|
AddPeople *string `yaml:"add_people,omitempty"`
|
|
}
|
|
|
|
// DeployFieldOverlay translates one deploy field, found by EnvVar.
|
|
type DeployFieldOverlay struct {
|
|
EnvVar string `yaml:"env_var"`
|
|
Label *string `yaml:"label,omitempty"`
|
|
Description *string `yaml:"description,omitempty"`
|
|
Placeholder *string `yaml:"placeholder,omitempty"`
|
|
Options []SelectOptionOverlay `yaml:"options,omitempty"`
|
|
}
|
|
|
|
// SelectOptionOverlay translates one select option's label, found by Value.
|
|
type SelectOptionOverlay struct {
|
|
Value string `yaml:"value"`
|
|
Label *string `yaml:"label,omitempty"`
|
|
}
|
|
|
|
// OptionalConfigGroupOverlay translates one optional-config group. MatchGroup is the HUNGARIAN
|
|
// `group:` value it belongs to — a group carries no id, and its own label is the thing being
|
|
// translated, so the match key has to be stated rather than derived.
|
|
type OptionalConfigGroupOverlay struct {
|
|
MatchGroup string `yaml:"match_group"`
|
|
Group *string `yaml:"group,omitempty"`
|
|
Description *string `yaml:"description,omitempty"`
|
|
Fields []OptionalConfigFieldOverlay `yaml:"fields,omitempty"`
|
|
}
|
|
|
|
// OptionalConfigFieldOverlay translates one optional-config field, found by EnvVar.
|
|
type OptionalConfigFieldOverlay struct {
|
|
EnvVar string `yaml:"env_var"`
|
|
Label *string `yaml:"label,omitempty"`
|
|
HelpText *string `yaml:"help_text,omitempty"`
|
|
}
|
|
|
|
// IntegrationOverlay translates one integration offer, found by Target.
|
|
type IntegrationOverlay struct {
|
|
Target string `yaml:"target"`
|
|
Label *string `yaml:"label,omitempty"`
|
|
Description *string `yaml:"description,omitempty"`
|
|
}
|
|
|
|
// DataPathOverlay translates one data-folder label, found by Path.
|
|
type DataPathOverlay struct {
|
|
Path string `yaml:"path"`
|
|
Label *string `yaml:"label,omitempty"`
|
|
}
|
|
|
|
// InitialCredentialsOverlay translates the note shown beside a first-login credential. Only the
|
|
// note: the file path, the format and the keys are configuration, and the credential VALUE never
|
|
// passes through here at all.
|
|
type InitialCredentialsOverlay struct {
|
|
Note *string `yaml:"note,omitempty"`
|
|
}
|
|
|
|
// BaseLanguage is the language the unmarshalled fields themselves are written in. It is not a
|
|
// choice a template can make — the catalog's Hungarian is the base by construction (the freeze
|
|
// gate in the catalog repo pins it byte for byte).
|
|
const BaseLanguage = "hu"
|
|
|
|
// overlayStr returns the translated string, or the base one when the translation is absent or
|
|
// blank. A blank translation is treated as ABSENT and never renders an empty box — operator
|
|
// ruling 3, 10-localisation.md §4.
|
|
func overlayStr(p *string, base string) string {
|
|
if p == nil || strings.TrimSpace(*p) == "" {
|
|
return base
|
|
}
|
|
return *p
|
|
}
|
|
|
|
// overlayList returns the translated list, or the base one when the translation is absent or
|
|
// empty. Whole-list replacement, never a per-index merge — see the header.
|
|
func overlayList(in []string, base []string) []string {
|
|
if len(in) == 0 {
|
|
return base
|
|
}
|
|
out := make([]string, len(in))
|
|
copy(out, in)
|
|
return out
|
|
}
|
|
|
|
// For returns this app's metadata as the given language renders it.
|
|
//
|
|
// For the base language — and for any language this template has no block for — it is the parsed
|
|
// struct with `I18n` cleared, so a Hungarian page cannot be changed by the presence of a
|
|
// translation. For a language with a block, copy fields are replaced one by one and EVERYTHING
|
|
// ELSE is carried through untouched.
|
|
//
|
|
// It NEVER mutates the receiver. Every slice it changes is copied first: `Metadata` values live
|
|
// inside the stack manager's cache and are handed to many requests at once, so an in-place edit
|
|
// would leak one household's language into another's page.
|
|
//
|
|
// VALUE receiver, like CanInstall and CatalogSinceAge above it — see the comment there: a pointer
|
|
// receiver on a type that reaches html/template inside an interface{} is a render-time 500.
|
|
func (m Metadata) For(lang string) Metadata {
|
|
out := m
|
|
out.I18n = nil
|
|
if lang == "" || lang == BaseLanguage {
|
|
return out
|
|
}
|
|
ov, ok := m.I18n[lang]
|
|
if !ok {
|
|
return out
|
|
}
|
|
|
|
out.Description = overlayStr(ov.Description, out.Description)
|
|
|
|
if ov.AppInfo != nil {
|
|
out.AppInfo.Tagline = overlayStr(ov.AppInfo.Tagline, out.AppInfo.Tagline)
|
|
out.AppInfo.DefaultCreds = overlayStr(ov.AppInfo.DefaultCreds, out.AppInfo.DefaultCreds)
|
|
out.AppInfo.AddPeople = overlayStr(ov.AppInfo.AddPeople, out.AppInfo.AddPeople)
|
|
out.AppInfo.UseCases = overlayList(ov.AppInfo.UseCases, out.AppInfo.UseCases)
|
|
out.AppInfo.FirstSteps = overlayList(ov.AppInfo.FirstSteps, out.AppInfo.FirstSteps)
|
|
out.AppInfo.Prerequisites = overlayList(ov.AppInfo.Prerequisites, out.AppInfo.Prerequisites)
|
|
}
|
|
|
|
if len(ov.DeployFields) > 0 && len(out.DeployFields) > 0 {
|
|
fields := make([]DeployField, len(out.DeployFields))
|
|
copy(fields, out.DeployFields)
|
|
for _, fo := range ov.DeployFields {
|
|
for i := range fields {
|
|
if fields[i].EnvVar != fo.EnvVar || fo.EnvVar == "" {
|
|
continue
|
|
}
|
|
fields[i].Label = overlayStr(fo.Label, fields[i].Label)
|
|
fields[i].Description = overlayStr(fo.Description, fields[i].Description)
|
|
fields[i].Placeholder = overlayStr(fo.Placeholder, fields[i].Placeholder)
|
|
if len(fo.Options) > 0 && len(fields[i].Options) > 0 {
|
|
opts := make([]SelectOption, len(fields[i].Options))
|
|
copy(opts, fields[i].Options)
|
|
for _, oo := range fo.Options {
|
|
for j := range opts {
|
|
if opts[j].Value == oo.Value && oo.Value != "" {
|
|
opts[j].Label = overlayStr(oo.Label, opts[j].Label)
|
|
}
|
|
}
|
|
}
|
|
fields[i].Options = opts
|
|
}
|
|
break
|
|
}
|
|
}
|
|
out.DeployFields = fields
|
|
}
|
|
|
|
if len(ov.OptionalConfig) > 0 && len(out.OptionalConfig) > 0 {
|
|
groups := make([]OptionalConfigGroup, len(out.OptionalConfig))
|
|
copy(groups, out.OptionalConfig)
|
|
for _, gov := range ov.OptionalConfig {
|
|
for i := range groups {
|
|
if groups[i].Group != gov.MatchGroup || gov.MatchGroup == "" {
|
|
continue
|
|
}
|
|
groups[i].Description = overlayStr(gov.Description, groups[i].Description)
|
|
if len(gov.Fields) > 0 && len(groups[i].Fields) > 0 {
|
|
flds := make([]OptionalConfigField, len(groups[i].Fields))
|
|
copy(flds, groups[i].Fields)
|
|
for _, fo := range gov.Fields {
|
|
for j := range flds {
|
|
if flds[j].EnvVar == fo.EnvVar && fo.EnvVar != "" {
|
|
flds[j].Label = overlayStr(fo.Label, flds[j].Label)
|
|
flds[j].HelpText = overlayStr(fo.HelpText, flds[j].HelpText)
|
|
}
|
|
}
|
|
}
|
|
groups[i].Fields = flds
|
|
}
|
|
// Group LAST: it is the match key, so translating it earlier would make the
|
|
// remaining overlay entries for this group unmatchable.
|
|
groups[i].Group = overlayStr(gov.Group, groups[i].Group)
|
|
break
|
|
}
|
|
}
|
|
out.OptionalConfig = groups
|
|
}
|
|
|
|
if len(ov.Integrations) > 0 && len(out.Integrations) > 0 {
|
|
ints := make([]IntegrationDef, len(out.Integrations))
|
|
copy(ints, out.Integrations)
|
|
for _, io := range ov.Integrations {
|
|
for i := range ints {
|
|
if ints[i].Target == io.Target && io.Target != "" {
|
|
ints[i].Label = overlayStr(io.Label, ints[i].Label)
|
|
ints[i].Description = overlayStr(io.Description, ints[i].Description)
|
|
}
|
|
}
|
|
}
|
|
out.Integrations = ints
|
|
}
|
|
|
|
if len(ov.DataPaths) > 0 && len(out.DataPaths) > 0 {
|
|
dps := make([]DataPath, len(out.DataPaths))
|
|
copy(dps, out.DataPaths)
|
|
for _, do := range ov.DataPaths {
|
|
for i := range dps {
|
|
if dps[i].Path == do.Path && do.Path != "" {
|
|
dps[i].Label = overlayStr(do.Label, dps[i].Label)
|
|
}
|
|
}
|
|
}
|
|
out.DataPaths = dps
|
|
}
|
|
|
|
if ov.InitialCreds != nil && out.InitialCreds != nil {
|
|
ic := *out.InitialCreds
|
|
ic.Note = overlayStr(ov.InitialCreds.Note, ic.Note)
|
|
out.InitialCreds = &ic
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// MetaFor is For on a stack, and it is the ONLY way a page should reach catalog copy: reading
|
|
// `stack.Meta.Description` straight out of the manager renders Hungarian to an English household.
|
|
// TestNoDirectMetaCopyReadOnPages pins that rule on the handler sources.
|
|
func (s Stack) MetaFor(lang string) Metadata { return s.Meta.For(lang) }
|
|
|
|
// LocalizeStacks returns the list as the given language renders it.
|
|
//
|
|
// For the base language it returns the INPUT SLICE ITSELF — not a copy. That is deliberate and it
|
|
// is the parity guarantee in code: the Hungarian page cannot differ from the pre-change one,
|
|
// because nothing on its path was touched. For another language every element is copied (Stack is
|
|
// a value type) and only its Meta replaced.
|
|
func LocalizeStacks(in []Stack, lang string) []Stack {
|
|
if lang == "" || lang == BaseLanguage {
|
|
return in
|
|
}
|
|
out := make([]Stack, len(in))
|
|
copy(out, in)
|
|
for i := range out {
|
|
out[i].Meta = out[i].Meta.For(lang)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// LocalizeStack is LocalizeStacks for a single stack.
|
|
func LocalizeStack(in Stack, lang string) Stack {
|
|
in.Meta = in.Meta.For(lang)
|
|
return in
|
|
}
|
|
|
|
// LocalizeStackPtr is LocalizeStack for a stack POINTER, returning a NEW pointer so the value the
|
|
// caller holds — which may be the manager's own — is never written through. A nil stack stays nil.
|
|
func LocalizeStackPtr(in *Stack, lang string) *Stack {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := *in
|
|
out.Meta = out.Meta.For(lang)
|
|
return &out
|
|
}
|
|
|
|
// cloneStrPtr returns a fresh pointer to a copy of *p, or nil.
|
|
func cloneStrPtr(p *string) *string {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
v := *p
|
|
return &v
|
|
}
|
|
|
|
// cloneStrs returns a fresh copy of in, keeping nil as nil and empty as empty.
|
|
func cloneStrs(in []string) []string {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make([]string, len(in))
|
|
copy(out, in)
|
|
return out
|
|
}
|
|
|
|
// Clone returns a deep copy of the overlay: no pointer, slice or nested struct is shared with the
|
|
// receiver. It exists for deepCopyStack (R-591) — a Stack copy is "a snapshot the caller may
|
|
// mutate", and the overlay must be one too. Pinned by TestDeepCopyStackI18nIsNotShared.
|
|
func (o MetadataOverlay) Clone() MetadataOverlay {
|
|
c := MetadataOverlay{
|
|
Description: cloneStrPtr(o.Description),
|
|
}
|
|
if o.AppInfo != nil {
|
|
ai := AppInfoOverlay{
|
|
Tagline: cloneStrPtr(o.AppInfo.Tagline),
|
|
UseCases: cloneStrs(o.AppInfo.UseCases),
|
|
FirstSteps: cloneStrs(o.AppInfo.FirstSteps),
|
|
Prerequisites: cloneStrs(o.AppInfo.Prerequisites),
|
|
DefaultCreds: cloneStrPtr(o.AppInfo.DefaultCreds),
|
|
AddPeople: cloneStrPtr(o.AppInfo.AddPeople),
|
|
}
|
|
c.AppInfo = &ai
|
|
}
|
|
if o.DeployFields != nil {
|
|
c.DeployFields = make([]DeployFieldOverlay, len(o.DeployFields))
|
|
for i, f := range o.DeployFields {
|
|
nf := DeployFieldOverlay{
|
|
EnvVar: f.EnvVar,
|
|
Label: cloneStrPtr(f.Label),
|
|
Description: cloneStrPtr(f.Description),
|
|
Placeholder: cloneStrPtr(f.Placeholder),
|
|
}
|
|
if f.Options != nil {
|
|
nf.Options = make([]SelectOptionOverlay, len(f.Options))
|
|
for j, op := range f.Options {
|
|
nf.Options[j] = SelectOptionOverlay{Value: op.Value, Label: cloneStrPtr(op.Label)}
|
|
}
|
|
}
|
|
c.DeployFields[i] = nf
|
|
}
|
|
}
|
|
if o.OptionalConfig != nil {
|
|
c.OptionalConfig = make([]OptionalConfigGroupOverlay, len(o.OptionalConfig))
|
|
for i, g := range o.OptionalConfig {
|
|
ng := OptionalConfigGroupOverlay{
|
|
MatchGroup: g.MatchGroup,
|
|
Group: cloneStrPtr(g.Group),
|
|
Description: cloneStrPtr(g.Description),
|
|
}
|
|
if g.Fields != nil {
|
|
ng.Fields = make([]OptionalConfigFieldOverlay, len(g.Fields))
|
|
for j, f := range g.Fields {
|
|
ng.Fields[j] = OptionalConfigFieldOverlay{EnvVar: f.EnvVar, Label: cloneStrPtr(f.Label), HelpText: cloneStrPtr(f.HelpText)}
|
|
}
|
|
}
|
|
c.OptionalConfig[i] = ng
|
|
}
|
|
}
|
|
if o.Integrations != nil {
|
|
c.Integrations = make([]IntegrationOverlay, len(o.Integrations))
|
|
for i, in := range o.Integrations {
|
|
c.Integrations[i] = IntegrationOverlay{Target: in.Target, Label: cloneStrPtr(in.Label), Description: cloneStrPtr(in.Description)}
|
|
}
|
|
}
|
|
if o.DataPaths != nil {
|
|
c.DataPaths = make([]DataPathOverlay, len(o.DataPaths))
|
|
for i, d := range o.DataPaths {
|
|
c.DataPaths[i] = DataPathOverlay{Path: d.Path, Label: cloneStrPtr(d.Label)}
|
|
}
|
|
}
|
|
if o.InitialCreds != nil {
|
|
c.InitialCreds = &InitialCredentialsOverlay{Note: cloneStrPtr(o.InitialCreds.Note)}
|
|
}
|
|
return c
|
|
}
|