From 6b37ba912aa3b57f8e3efcbe981b2b516d3e3101 Mon Sep 17 00:00:00 2001 From: vic1707 <28602203+vic1707@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:13:18 +0100 Subject: [PATCH 1/5] init interface and new package --- butane/translator/common_fields.go | 61 ++++++++++++++++++++++++++++++ butane/translator/interface.go | 23 +++++++++++ butane/translator/registery.go | 54 ++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 butane/translator/common_fields.go create mode 100644 butane/translator/interface.go create mode 100644 butane/translator/registery.go diff --git a/butane/translator/common_fields.go b/butane/translator/common_fields.go new file mode 100644 index 000000000..ae8a25b6c --- /dev/null +++ b/butane/translator/common_fields.go @@ -0,0 +1,61 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package translator + +import ( + "fmt" + + "github.com/coreos/go-semver/semver" +) + +type commonFields struct { + Variant string `yaml:"variant"` + Version semver.Version `yaml:"version"` +} + +func (c *commonFields) UnmarshalYAML(unmarshal func(interface{}) error) error { + type plain commonFields + var raw plain + + if err := unmarshal(&raw); err != nil { + return err + } + + if raw.Variant == "" { + return fmt.Errorf("variant cannot be empty") + } + + *c = commonFields(raw) + return nil +} + +func (c *commonFields) asKey() string { + return fmt.Sprintf("%s+%s", c.Variant, c.Version.String()) +} + +func newCF(variant, version string) (commonFields, error) { + if variant == "" { + return commonFields{}, fmt.Errorf("variant cannot be empty") + } + + v, err := semver.NewVersion(version) + if err != nil { + return commonFields{}, fmt.Errorf("invalid version: %w", err) + } + + return commonFields{ + Variant: variant, + Version: *v, + }, nil +} diff --git a/butane/translator/interface.go b/butane/translator/interface.go new file mode 100644 index 000000000..f3cec89a5 --- /dev/null +++ b/butane/translator/interface.go @@ -0,0 +1,23 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package translator + +import ( + "github.com/coreos/butane/config/common" + "github.com/coreos/vcontext/report" +) + +type Translator interface { + TranslateBytes(input []byte, options common.TranslateBytesOptions) ([]byte, report.Report, error) +} diff --git a/butane/translator/registery.go b/butane/translator/registery.go new file mode 100644 index 000000000..5cddff78d --- /dev/null +++ b/butane/translator/registery.go @@ -0,0 +1,54 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package translator + +import ( + "fmt" + + "github.com/coreos/butane/config/common" + "github.com/coreos/vcontext/report" + "gopkg.in/yaml.v3" +) + +var TranslatorRegistry = &Registry{ + translators: make(map[string]Translator), +} + +type Registry struct { + translators map[string]Translator +} + +func (r *Registry) RegisterTranslator(variant, version string, trans Translator) { + cf, err := newCF(variant, version) + if err != nil { + panic(fmt.Sprintf("tried to register a translator with an invalid key (%s+%s)", variant, version)) + } + if _, ok := r.translators[cf.asKey()]; ok { + panic(fmt.Sprintf("tried to reregister existing translator (%s+%s)", variant, version)) + } + r.translators[cf.asKey()] = trans +} + +func (r *Registry) TranslateBytes(input []byte, options common.TranslateBytesOptions) ([]byte, report.Report, error) { + // first determine version; this will ignore most fields + cf := commonFields{} + if err := yaml.Unmarshal(input, &cf); err != nil { + return nil, report.Report{}, common.ErrUnmarshal{ + Detail: err.Error(), + } + } + + translator := r.translators[cf.asKey()] + return translator.TranslateBytes(input, options) +} From 07cdc7114f2337b5425c622a882b26359af192ae Mon Sep 17 00:00:00 2001 From: vic1707 <28602203+vic1707@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:09:42 +0100 Subject: [PATCH 2/5] interfaces v2? --- butane/translator/interface.go | 8 ++++++++ butane/translator/{common_fields.go => metadata.go} | 7 +++++++ butane/translator/registery.go | 11 ++++------- 3 files changed, 19 insertions(+), 7 deletions(-) rename butane/translator/{common_fields.go => metadata.go} (92%) diff --git a/butane/translator/interface.go b/butane/translator/interface.go index f3cec89a5..8208ddd61 100644 --- a/butane/translator/interface.go +++ b/butane/translator/interface.go @@ -19,5 +19,13 @@ import ( ) type Translator interface { + Metadata() Metadata + // Parse yml into struct + Parse(input []byte) interface{} + // From yml input to Ignition struct + Translate(input []byte, options common.TranslateBytesOptions) (interface{}, report.Report, error) + // From yml input to Ingition JSON TranslateBytes(input []byte, options common.TranslateBytesOptions) ([]byte, report.Report, error) + // Validates yml struct + Validate(in interface{}) report.Report } diff --git a/butane/translator/common_fields.go b/butane/translator/metadata.go similarity index 92% rename from butane/translator/common_fields.go rename to butane/translator/metadata.go index ae8a25b6c..cfc7546ab 100644 --- a/butane/translator/common_fields.go +++ b/butane/translator/metadata.go @@ -24,6 +24,13 @@ type commonFields struct { Version semver.Version `yaml:"version"` } +type Metadata struct { + commonFields + Description string + Experimental bool + IgnitionVersion semver.Version +} + func (c *commonFields) UnmarshalYAML(unmarshal func(interface{}) error) error { type plain commonFields var raw plain diff --git a/butane/translator/registery.go b/butane/translator/registery.go index 5cddff78d..2c5402c67 100644 --- a/butane/translator/registery.go +++ b/butane/translator/registery.go @@ -22,20 +22,17 @@ import ( ) var TranslatorRegistry = &Registry{ - translators: make(map[string]Translator), + translators: map[string]Translator{}, } type Registry struct { translators map[string]Translator } -func (r *Registry) RegisterTranslator(variant, version string, trans Translator) { - cf, err := newCF(variant, version) - if err != nil { - panic(fmt.Sprintf("tried to register a translator with an invalid key (%s+%s)", variant, version)) - } +func (r *Registry) RegisterTranslator(trans Translator) { + cf := trans.Metadata().commonFields if _, ok := r.translators[cf.asKey()]; ok { - panic(fmt.Sprintf("tried to reregister existing translator (%s+%s)", variant, version)) + panic(fmt.Sprintf("tried to reregister existing translator (%+v)", trans.Metadata())) } r.translators[cf.asKey()] = trans } From 65dfa11fff0133bc0426cf98c6cd3e8258bde73f Mon Sep 17 00:00:00 2001 From: Steven Presti Date: Wed, 26 Nov 2025 15:12:50 -0500 Subject: [PATCH 3/5] WIP:translator: update interface and registry --- butane/translator/helpers.go | 40 ++++++++++++++ butane/translator/interface.go | 24 +++++---- butane/translator/options.go | 23 ++++++++ butane/translator/registery.go | 51 ------------------ butane/translator/registry.go | 97 ++++++++++++++++++++++++++++++++++ butane/translator/result.go | 37 +++++++++++++ 6 files changed, 212 insertions(+), 60 deletions(-) create mode 100644 butane/translator/helpers.go create mode 100644 butane/translator/options.go delete mode 100644 butane/translator/registery.go create mode 100644 butane/translator/registry.go create mode 100644 butane/translator/result.go diff --git a/butane/translator/helpers.go b/butane/translator/helpers.go new file mode 100644 index 000000000..aff6b4f1b --- /dev/null +++ b/butane/translator/helpers.go @@ -0,0 +1,40 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package translator + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// ParseVariantVersion extracts the variant and version from Butane config bytes. +// +// This function only parses the minimal metadata needed to identify which +// translator to use. It does not validate the full config structure. +// +// Returns an error if the variant or version fields are missing or invalid. +func ParseVariantVersion(input []byte) (variant, version string, err error) { + var cf commonFields + if err := yaml.Unmarshal(input, &cf); err != nil { + return "", "", fmt.Errorf("failed to parse config: %w", err) + } + + if cf.Variant == "" { + return "", "", fmt.Errorf("missing 'variant' field in config") + } + + return cf.Variant, cf.Version.String(), nil +} diff --git a/butane/translator/interface.go b/butane/translator/interface.go index 8208ddd61..4799f7585 100644 --- a/butane/translator/interface.go +++ b/butane/translator/interface.go @@ -11,21 +11,27 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + package translator import ( - "github.com/coreos/butane/config/common" + "context" + "github.com/coreos/vcontext/report" ) +// Translator translates Butane configuration to Ignition configuration. +// +// Each Butane variant (fcos, flatcar, r4e, openshift, etc.) should implement this +// interface for each supported version. type Translator interface { + // Metadata the variant, version, and target Ignition version. Metadata() Metadata - // Parse yml into struct - Parse(input []byte) interface{} - // From yml input to Ignition struct - Translate(input []byte, options common.TranslateBytesOptions) (interface{}, report.Report, error) - // From yml input to Ingition JSON - TranslateBytes(input []byte, options common.TranslateBytesOptions) ([]byte, report.Report, error) - // Validates yml struct - Validate(in interface{}) report.Report + + // Translate converts Butane config bytes to Ignition config bytes. + Translate(ctx context.Context, input []byte, opts Options) (Result, error) + + // Validate validates a Butane config without performing translation. + Validate(ctx context.Context, input []byte) (report.Report, error) } + diff --git a/butane/translator/options.go b/butane/translator/options.go new file mode 100644 index 000000000..5405d63f0 --- /dev/null +++ b/butane/translator/options.go @@ -0,0 +1,23 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package translator + +type Options struct { + FilesDir string + NoResourceAutoCompression bool + DebugPrintTranslations bool + Pretty bool + Raw bool +} diff --git a/butane/translator/registery.go b/butane/translator/registery.go deleted file mode 100644 index 2c5402c67..000000000 --- a/butane/translator/registery.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2022 Red Hat, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -package translator - -import ( - "fmt" - - "github.com/coreos/butane/config/common" - "github.com/coreos/vcontext/report" - "gopkg.in/yaml.v3" -) - -var TranslatorRegistry = &Registry{ - translators: map[string]Translator{}, -} - -type Registry struct { - translators map[string]Translator -} - -func (r *Registry) RegisterTranslator(trans Translator) { - cf := trans.Metadata().commonFields - if _, ok := r.translators[cf.asKey()]; ok { - panic(fmt.Sprintf("tried to reregister existing translator (%+v)", trans.Metadata())) - } - r.translators[cf.asKey()] = trans -} - -func (r *Registry) TranslateBytes(input []byte, options common.TranslateBytesOptions) ([]byte, report.Report, error) { - // first determine version; this will ignore most fields - cf := commonFields{} - if err := yaml.Unmarshal(input, &cf); err != nil { - return nil, report.Report{}, common.ErrUnmarshal{ - Detail: err.Error(), - } - } - - translator := r.translators[cf.asKey()] - return translator.TranslateBytes(input, options) -} diff --git a/butane/translator/registry.go b/butane/translator/registry.go new file mode 100644 index 000000000..ed005d2bc --- /dev/null +++ b/butane/translator/registry.go @@ -0,0 +1,97 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package translator + +import ( + "context" + "fmt" +) + +// Global registry for all translators. +// Variants register in init() functions. +var Global = NewRegistry() + +type Registry struct { + translators map[string]Translator +} + +func NewRegistry() *Registry { + return &Registry{ + translators: make(map[string]Translator), + } +} + +// Register adds a translator. Panics if already registered. +func (r *Registry) Register(t Translator) { + meta := t.Metadata() + key := meta.commonFields.asKey() + + if _, exists := r.translators[key]; exists { + panic(fmt.Sprintf("translator already registered: %s version %s", + meta.Variant, meta.Version.String())) + } + + r.translators[key] = t +} + +// Get retrieves a translator by variant and version. +func (r *Registry) Get(variant, version string) (Translator, error) { + cf, err := newCF(variant, version) + if err != nil { + return nil, fmt.Errorf("invalid variant/version: %w", err) + } + + key := cf.asKey() + t, ok := r.translators[key] + if !ok { + return nil, fmt.Errorf("no translator registered for %s version %s", variant, version) + } + + return t, nil +} + +func (r *Registry) IsRegistered(variant, version string) bool { + cf, err := newCF(variant, version) + if err != nil { + return false + } + + _, ok := r.translators[cf.asKey()] + return ok +} + +// List returns all registered translator metadata. +func (r *Registry) List() []Metadata { + result := make([]Metadata, 0, len(r.translators)) + for _, t := range r.translators { + result = append(result, t.Metadata()) + } + return result +} + +// Translate auto-detects variant/version and translates the input. +func (r *Registry) Translate(ctx context.Context, input []byte, opts Options) (Result, error) { + variant, version, err := ParseVariantVersion(input) + if err != nil { + return Result{}, fmt.Errorf("failed to parse variant/version: %w", err) + } + + t, err := r.Get(variant, version) + if err != nil { + return Result{}, err + } + + return t.Translate(ctx, input, opts) +} diff --git a/butane/translator/result.go b/butane/translator/result.go new file mode 100644 index 000000000..9b40b26e4 --- /dev/null +++ b/butane/translator/result.go @@ -0,0 +1,37 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package translator + +import ( + "github.com/coreos/butane/translate" + "github.com/coreos/vcontext/report" +) + +// Result contains the output of a translation operation. +// +// This matches the existing return pattern from ToIgnXXBytes functions +// but wraps them in a struct for better extensibility. +type Result struct { + // Output is the translated Ignition configuration as JSON bytes. + Output []byte + + // Report contains warnings and errors from the translation process. + // Use Report.IsFatal() to check if translation failed. + Report report.Report + + // TranslationSet tracks how source paths in the Butane config map to + // output paths in the Ignition config. Used for debugging and tooling. + TranslationSet translate.TranslationSet +} From 74ff59345ba0b10637cfd5e6034074deb672f62f Mon Sep 17 00:00:00 2001 From: vic1707 <28602203+vic1707@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:53:02 +0100 Subject: [PATCH 4/5] finish interfaces --- butane/translator/interface.go | 15 ++++++-------- butane/translator/registry.go | 38 +++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/butane/translator/interface.go b/butane/translator/interface.go index 4799f7585..00e8369bc 100644 --- a/butane/translator/interface.go +++ b/butane/translator/interface.go @@ -15,8 +15,6 @@ package translator import ( - "context" - "github.com/coreos/vcontext/report" ) @@ -27,11 +25,10 @@ import ( type Translator interface { // Metadata the variant, version, and target Ignition version. Metadata() Metadata - - // Translate converts Butane config bytes to Ignition config bytes. - Translate(ctx context.Context, input []byte, opts Options) (Result, error) - - // Validate validates a Butane config without performing translation. - Validate(ctx context.Context, input []byte) (report.Report, error) + // Parse yml into schema struct, basically a yaml.Unmarshal wrapper? + Parse(input []byte /*opts?*/) (interface{}, error) + // From inner schema struct to Ignition struct + Translate(input interface{}, options Options) (interface{}, report.Report, error) + // Validates yml inner struct + Validate(in interface{}) (report.Report, error) } - diff --git a/butane/translator/registry.go b/butane/translator/registry.go index ed005d2bc..1c88d4cf2 100644 --- a/butane/translator/registry.go +++ b/butane/translator/registry.go @@ -16,6 +16,7 @@ package translator import ( "context" + "encoding/json" "fmt" ) @@ -83,15 +84,46 @@ func (r *Registry) List() []Metadata { // Translate auto-detects variant/version and translates the input. func (r *Registry) Translate(ctx context.Context, input []byte, opts Options) (Result, error) { + res := Result{} variant, version, err := ParseVariantVersion(input) if err != nil { - return Result{}, fmt.Errorf("failed to parse variant/version: %w", err) + return res, fmt.Errorf("failed to parse variant/version: %w", err) } t, err := r.Get(variant, version) if err != nil { - return Result{}, err + return res, err } - return t.Translate(ctx, input, opts) + parsed, err := t.Parse(input) + if err != nil { + return res, err + } + + report, err := t.Validate(parsed) + res.Report = report + if err != nil { + return res, err + } + + translated, report, err := t.Translate(parsed, opts) + res.Report.Merge(report) + if err != nil { + return res, err + } + + out, err := marshal(translated, opts.Pretty) + if err != nil { + return res, err + } + res.Output = out + + return res, nil +} + +func marshal(from interface{}, pretty bool) ([]byte, error) { + if pretty { + return json.MarshalIndent(from, "", " ") + } + return json.Marshal(from) } From bde98a83be062764a1d3c939e08703837e14fb39 Mon Sep 17 00:00:00 2001 From: vic1707 <28602203+vic1707@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:04:18 +0200 Subject: [PATCH 5/5] wip: impl new interface for fcos1.8 --- butane/config/fcos/v1_8_exp/translator.go | 101 ++++++++++ .../config/fcos/v1_8_exp/translator_test.go | 188 ++++++++++++++++++ butane/config/util/util.go | 58 ++++-- butane/translator/helpers_test.go | 31 +++ butane/translator/interface.go | 14 +- butane/translator/metadata.go | 21 +- butane/translator/registry.go | 14 +- butane/translator/result.go | 9 +- 8 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 butane/config/fcos/v1_8_exp/translator.go create mode 100644 butane/config/fcos/v1_8_exp/translator_test.go create mode 100644 butane/translator/helpers_test.go diff --git a/butane/config/fcos/v1_8_exp/translator.go b/butane/config/fcos/v1_8_exp/translator.go new file mode 100644 index 000000000..2ec9bfc49 --- /dev/null +++ b/butane/config/fcos/v1_8_exp/translator.go @@ -0,0 +1,101 @@ +// Copyright 2026 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.) + +package v1_8_exp + +import ( + "fmt" + + "github.com/coreos/ignition/v2/butane/config/common" + cutil "github.com/coreos/ignition/v2/butane/config/util" + "github.com/coreos/ignition/v2/butane/translator" + + "github.com/coreos/go-semver/semver" + "github.com/coreos/ignition/v2/config/v3_7_experimental/types" + "github.com/coreos/vcontext/report" + "github.com/coreos/vcontext/tree" +) + +type specTranslator struct{} + +type parsedConfig struct { + config Config + contextTree tree.Node +} + +var _ translator.Translator = specTranslator{} + +func init() { + translator.Global.Register(specTranslator{}) +} + +func (specTranslator) Metadata() translator.Metadata { + return translator.Metadata{ + Variant: "fcos", + Version: semver.Version{ + Major: 1, + Minor: 8, + PreRelease: "experimental", + }, + Description: "Fedora CoreOS", + Experimental: true, + IgnitionVersion: types.MaxVersion, + } +} + +func (specTranslator) Parse(input []byte) (interface{}, error) { + parsed := &parsedConfig{} + contextTree, err := cutil.Unmarshal(input, &parsed.config) + if err != nil { + return nil, err + } + parsed.contextTree = contextTree + return parsed, nil +} + +func (specTranslator) Validate(input interface{}) (report.Report, error) { + parsed, err := getParsedConfig(input) + if err != nil { + return report.Report{}, err + } + return cutil.ValidateSourceConfig(&parsed.config, parsed.contextTree) +} + +func (specTranslator) Translate(input interface{}, options translator.Options) (interface{}, report.Report, error) { + parsed, err := getParsedConfig(input) + if err != nil { + return types.Config{}, report.Report{}, err + } + + translateOptions := common.TranslateOptions{ + FilesDir: options.FilesDir, + NoResourceAutoCompression: options.NoResourceAutoCompression, + DebugPrintTranslations: options.DebugPrintTranslations, + } + final, translations, translationReport := parsed.config.ToIgn3_7Unvalidated(translateOptions) + r, err := cutil.ValidateTranslatedConfig(parsed.config, final, translations, translationReport, translateOptions) + r.Correlate(parsed.contextTree) + if err != nil { + return types.Config{}, r, err + } + return final, r, nil +} + +func getParsedConfig(input interface{}) (*parsedConfig, error) { + parsed, ok := input.(*parsedConfig) + if !ok || parsed == nil { + return nil, fmt.Errorf("fcos v1.8 experimental translator: unexpected parsed config type %T", input) + } + return parsed, nil +} diff --git a/butane/config/fcos/v1_8_exp/translator_test.go b/butane/config/fcos/v1_8_exp/translator_test.go new file mode 100644 index 000000000..a671ba7f8 --- /dev/null +++ b/butane/config/fcos/v1_8_exp/translator_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.) + +package v1_8_exp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/coreos/ignition/v2/butane/config/common" + "github.com/coreos/ignition/v2/butane/translator" + + "github.com/stretchr/testify/assert" +) + +const configHeader = `variant: fcos +version: 1.8.0-experimental +` + +func TestRegistryTranslationParity(t *testing.T) { + filesDir := t.TempDir() + if err := os.WriteFile(filepath.Join(filesDir, "contents"), []byte("local contents"), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + input string + options common.TranslateBytesOptions + }{ + { + name: "minimal", + input: configHeader, + }, + { + name: "pretty", + input: configHeader, + options: common.TranslateBytesOptions{ + Pretty: true, + }, + }, + { + name: "unused key", + input: configHeader + `storage: + files: + - path: /etc/example + unused: true +`, + }, + { + name: "source validation", + input: configHeader + `storage: + files: + - path: /etc/example + contents: + source: https://example.com + inline: example +`, + }, + { + name: "source warning", + input: configHeader + `storage: + files: + - path: /etc/example + mode: 420 +`, + }, + { + name: "translation warning", + input: configHeader + `storage: + disks: + - device: /dev/vda + partitions: + - label: root + number: 5 + size_mib: 8192 +`, + }, + { + name: "local file", + input: configHeader + `storage: + files: + - path: /etc/example + contents: + local: contents +`, + options: common.TranslateBytesOptions{ + TranslateOptions: common.TranslateOptions{ + FilesDir: filesDir, + }, + }, + }, + { + name: "missing files dir", + input: configHeader + `storage: + files: + - path: /etc/example + contents: + local: contents +`, + }, + { + name: "disabled auto compression", + input: configHeader + `storage: + files: + - path: /etc/example + contents: + inline: ` + strings.Repeat("z", 2048) + "\n", + options: common.TranslateBytesOptions{ + TranslateOptions: common.TranslateOptions{ + NoResourceAutoCompression: true, + }, + }, + }, + { + name: "generated validation", + input: configHeader + `storage: + files: + - path: relative +`, + }, + { + name: "duplicate generated keys", + input: configHeader + `storage: + files: + - path: /etc/example + - path: /etc/example +`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + expectedOutput, expectedReport, expectedErr := ToIgn3_7Bytes([]byte(test.input), test.options) + result, err := translator.Global.Translate(context.Background(), []byte(test.input), translator.Options{ + FilesDir: test.options.FilesDir, + NoResourceAutoCompression: test.options.NoResourceAutoCompression, + DebugPrintTranslations: test.options.DebugPrintTranslations, + Pretty: test.options.Pretty, + Raw: test.options.Raw, + }) + + assert.Equal(t, expectedOutput, result.Output) + assert.Equal(t, expectedReport, result.Report) + if expectedErr == nil { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, expectedErr.Error()) + } + }) + } +} + +func TestSpecTranslatorMetadata(t *testing.T) { + registered, err := translator.Global.Get("fcos", "1.8.0-experimental") + if err != nil { + t.Fatal(err) + } + + metadata := registered.Metadata() + assert.Equal(t, "fcos", metadata.Variant) + assert.Equal(t, "1.8.0-experimental", metadata.Version.String()) + assert.Equal(t, "3.7.0-experimental", metadata.IgnitionVersion.String()) + assert.Equal(t, "Fedora CoreOS", metadata.Description) + assert.True(t, metadata.Experimental) +} + +func TestSpecTranslatorRejectsUnexpectedType(t *testing.T) { + implementation := specTranslator{} + _, err := implementation.Validate(Config{}) + assert.Error(t, err) + _, _, err = implementation.Translate(Config{}, translator.Options{}) + assert.Error(t, err) +} diff --git a/butane/config/util/util.go b/butane/config/util/util.go index fc6de99db..7cc77d747 100644 --- a/butane/config/util/util.go +++ b/butane/config/util/util.go @@ -67,9 +67,39 @@ func Translate(cfg Config, translateMethod string, options common.TranslateOptio final := translateRet[0].Interface() translations := translateRet[1].Interface().(translate.TranslationSet) translateReport := translateRet[2].Interface().(report.Report) - r.Merge(TranslateReportPaths(translateReport, translations)) + postReport, err := ValidateTranslatedConfig(cfg, final, translations, translateReport, options) + r.Merge(postReport) + if err != nil { + return zeroValue, r, err + } + return final, r, nil +} + +// ValidateSourceConfig checks unused keys and validates a parsed Butane config. +func ValidateSourceConfig(cfg interface{}, contextTree tree.Node) (report.Report, error) { + unusedKeyCheck := func(v reflect.Value, c path.ContextPath) report.Report { + return ignvalidate.ValidateUnusedKeys(v, c, contextTree) + } + r := validate.ValidateCustom(cfg, "yaml", unusedKeyCheck) + r.Correlate(contextTree) if r.IsFatal() { - return zeroValue, r, common.ErrInvalidSourceConfig + return r, common.ErrInvalidSourceConfig + } + + validationReport := validate.Validate(cfg, "yaml") + validationReport.Correlate(contextTree) + r.Merge(validationReport) + if r.IsFatal() { + return r, common.ErrInvalidSourceConfig + } + return r, nil +} + +// ValidateTranslatedConfig validates the result of an unvalidated translation. +func ValidateTranslatedConfig(cfg Config, final interface{}, translations translate.TranslationSet, translateReport report.Report, options common.TranslateOptions) (report.Report, error) { + r := TranslateReportPaths(translateReport, translations) + if r.IsFatal() { + return r, common.ErrInvalidSourceConfig } if options.DebugPrintTranslations { fmt.Fprint(os.Stderr, translations) @@ -79,12 +109,10 @@ func Translate(cfg Config, translateMethod string, options common.TranslateOptio } // Check for fields forbidden by this spec. - filters := cfg.FieldFilters() - if filters != nil { - filterReport := filters.Verify(final) - r.Merge(TranslateReportPaths(filterReport, translations)) + if filters := cfg.FieldFilters(); filters != nil { + r.Merge(TranslateReportPaths(filters.Verify(final), translations)) if r.IsFatal() { - return zeroValue, r, common.ErrInvalidSourceConfig + return r, common.ErrInvalidSourceConfig } } @@ -97,9 +125,9 @@ func Translate(cfg Config, translateMethod string, options common.TranslateOptio r.Merge(TranslateReportPaths(jsonReport, translations)) if r.IsFatal() { - return zeroValue, r, common.ErrInvalidGeneratedConfig + return r, common.ErrInvalidGeneratedConfig } - return final, r, nil + return r, nil } // TranslateBytes unmarshals the Butane config specified in input into the @@ -112,7 +140,7 @@ func TranslateBytes(input []byte, container interface{}, translateMethod string, cfg := container // Unmarshal the YAML. - contextTree, err := unmarshal(input, cfg) + contextTree, err := Unmarshal(input, cfg) if err != nil { return nil, report.Report{}, err } @@ -142,7 +170,7 @@ func TranslateBytes(input []byte, container interface{}, translateMethod string, } // Marshal the JSON. - outbytes, err := marshal(final, options.Pretty) + outbytes, err := Marshal(final, options.Pretty) return outbytes, r, err } @@ -196,8 +224,8 @@ func CheckForElidedFields(struct_ interface{}) report.Report { return r } -// unmarshal unmarshals the data to "to" and also returns a context tree for the source. -func unmarshal(data []byte, to interface{}) (tree.Node, error) { +// Unmarshal unmarshals data to "to" and returns a context tree for the source. +func Unmarshal(data []byte, to interface{}) (tree.Node, error) { dec := yaml.NewDecoder(bytes.NewReader(data)) if err := dec.Decode(to); err != nil { return nil, err @@ -205,8 +233,8 @@ func unmarshal(data []byte, to interface{}) (tree.Node, error) { return vyaml.UnmarshalToContext(data) } -// marshal is a wrapper for marshaling to json with or without pretty-printing the output -func marshal(from interface{}, pretty bool) ([]byte, error) { +// Marshal is a wrapper for marshaling to json with or without pretty-printing the output. +func Marshal(from interface{}, pretty bool) ([]byte, error) { if pretty { return json.MarshalIndent(from, "", " ") } diff --git a/butane/translator/helpers_test.go b/butane/translator/helpers_test.go new file mode 100644 index 000000000..014f74fd5 --- /dev/null +++ b/butane/translator/helpers_test.go @@ -0,0 +1,31 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package translator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseVariantVersion(t *testing.T) { + variant, version, err := ParseVariantVersion([]byte("variant: fcos\nversion: 1.8.0-experimental\n")) + assert.NoError(t, err) + assert.Equal(t, "fcos", variant) + assert.Equal(t, "1.8.0-experimental", version) + + _, _, err = ParseVariantVersion([]byte("variant: fcos\n")) + assert.Error(t, err) +} diff --git a/butane/translator/interface.go b/butane/translator/interface.go index 00e8369bc..9bb80e4dc 100644 --- a/butane/translator/interface.go +++ b/butane/translator/interface.go @@ -14,21 +14,19 @@ package translator -import ( - "github.com/coreos/vcontext/report" -) +import "github.com/coreos/vcontext/report" // Translator translates Butane configuration to Ignition configuration. // // Each Butane variant (fcos, flatcar, r4e, openshift, etc.) should implement this // interface for each supported version. type Translator interface { - // Metadata the variant, version, and target Ignition version. + // Metadata returns the variant, version, and target Ignition version. Metadata() Metadata - // Parse yml into schema struct, basically a yaml.Unmarshal wrapper? - Parse(input []byte /*opts?*/) (interface{}, error) - // From inner schema struct to Ignition struct + // Parse parses YAML into the translator's schema type. + Parse(input []byte) (interface{}, error) + // Translate translates a parsed config after successful validation. Translate(input interface{}, options Options) (interface{}, report.Report, error) - // Validates yml inner struct + // Validate validates a parsed config. Validate(in interface{}) (report.Report, error) } diff --git a/butane/translator/metadata.go b/butane/translator/metadata.go index cfc7546ab..805058aad 100644 --- a/butane/translator/metadata.go +++ b/butane/translator/metadata.go @@ -25,15 +25,18 @@ type commonFields struct { } type Metadata struct { - commonFields + Variant string + Version semver.Version Description string Experimental bool IgnitionVersion semver.Version } func (c *commonFields) UnmarshalYAML(unmarshal func(interface{}) error) error { - type plain commonFields - var raw plain + var raw struct { + Variant string `yaml:"variant"` + Version *string `yaml:"version"` + } if err := unmarshal(&raw); err != nil { return err @@ -42,8 +45,18 @@ func (c *commonFields) UnmarshalYAML(unmarshal func(interface{}) error) error { if raw.Variant == "" { return fmt.Errorf("variant cannot be empty") } + if raw.Version == nil { + return fmt.Errorf("version cannot be empty") + } + version, err := semver.NewVersion(*raw.Version) + if err != nil { + return fmt.Errorf("invalid version: %w", err) + } - *c = commonFields(raw) + *c = commonFields{ + Variant: raw.Variant, + Version: *version, + } return nil } diff --git a/butane/translator/registry.go b/butane/translator/registry.go index 1c88d4cf2..00f071a54 100644 --- a/butane/translator/registry.go +++ b/butane/translator/registry.go @@ -16,8 +16,9 @@ package translator import ( "context" - "encoding/json" "fmt" + + cutil "github.com/coreos/ignition/v2/butane/config/util" ) // Global registry for all translators. @@ -37,7 +38,7 @@ func NewRegistry() *Registry { // Register adds a translator. Panics if already registered. func (r *Registry) Register(t Translator) { meta := t.Metadata() - key := meta.commonFields.asKey() + key := fmt.Sprintf("%s+%s", meta.Variant, meta.Version.String()) if _, exists := r.translators[key]; exists { panic(fmt.Sprintf("translator already registered: %s version %s", @@ -112,7 +113,7 @@ func (r *Registry) Translate(ctx context.Context, input []byte, opts Options) (R return res, err } - out, err := marshal(translated, opts.Pretty) + out, err := cutil.Marshal(translated, opts.Pretty) if err != nil { return res, err } @@ -120,10 +121,3 @@ func (r *Registry) Translate(ctx context.Context, input []byte, opts Options) (R return res, nil } - -func marshal(from interface{}, pretty bool) ([]byte, error) { - if pretty { - return json.MarshalIndent(from, "", " ") - } - return json.Marshal(from) -} diff --git a/butane/translator/result.go b/butane/translator/result.go index 9b40b26e4..150a9592a 100644 --- a/butane/translator/result.go +++ b/butane/translator/result.go @@ -14,10 +14,7 @@ package translator -import ( - "github.com/coreos/butane/translate" - "github.com/coreos/vcontext/report" -) +import "github.com/coreos/vcontext/report" // Result contains the output of a translation operation. // @@ -30,8 +27,4 @@ type Result struct { // Report contains warnings and errors from the translation process. // Use Report.IsFatal() to check if translation failed. Report report.Report - - // TranslationSet tracks how source paths in the Butane config map to - // output paths in the Ignition config. Used for debugging and tooling. - TranslationSet translate.TranslationSet }