diff --git a/src/api/api.go b/src/api/api.go new file mode 100644 index 0000000000..8148dcca5d --- /dev/null +++ b/src/api/api.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package api supplies helpers for working generically with the different API versions +package api + +import ( + "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/api/v1beta1" +) + +// PackageAccessor is the read contract for a package source, exposing a per-version definition. +type PackageAccessor interface { + AsV1alpha1() (v1alpha1.ZarfPackage, error) + AsV1beta1() (v1beta1.Package, error) +} diff --git a/src/api/convert/convert.go b/src/api/convert/convert.go index f9af0402df..82961ca3be 100644 --- a/src/api/convert/convert.go +++ b/src/api/convert/convert.go @@ -7,10 +7,18 @@ package convert import ( "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/api/v1beta1" + "github.com/zarf-dev/zarf/src/internal/api/types" internalv1alpha1 "github.com/zarf-dev/zarf/src/internal/api/v1alpha1" internalv1beta1 "github.com/zarf-dev/zarf/src/internal/api/v1beta1" ) +// GenericToCanonicalAPIVersion takes a generic Package and converts it to the API version +// that package layout is currently using. +// Intentionally only callable internally since it uses the generic type +func GenericToCanonicalAPIVersion(gen types.Package) v1alpha1.ZarfPackage { + return internalv1alpha1.ConvertFromGeneric(gen) +} + // PackageV1alpha1ToV1beta1 converts a v1alpha1 ZarfPackage to a v1beta1 Package. func PackageV1alpha1ToV1beta1(pkg v1alpha1.ZarfPackage) v1beta1.Package { generic := internalv1alpha1.ConvertToGeneric(pkg) diff --git a/src/api/convert/convert_test.go b/src/api/convert/convert_test.go index 0c38f6eeac..351f197d85 100644 --- a/src/api/convert/convert_test.go +++ b/src/api/convert/convert_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/api/v1beta1" - "github.com/zarf-dev/zarf/src/internal/api/types" ) func TestV1Alpha1PkgToV1Beta1_Metadata(t *testing.T) { @@ -155,79 +154,6 @@ func TestV1Alpha1PkgToV1Beta1_Build(t *testing.T) { require.Equal(t, []string{"sig.json"}, result.Build.ProvenanceFiles) } -func TestV1Alpha1PkgToV1Beta1_VariablesAndConstantsShim(t *testing.T) { - t.Parallel() - pkg := v1alpha1.ZarfPackage{ - Kind: v1alpha1.ZarfPackageConfig, - Variables: []v1alpha1.InteractiveVariable{ - { - Variable: v1alpha1.Variable{ - Name: "MY_VAR", - Sensitive: true, - AutoIndent: true, - Pattern: "^[a-z]+$", - Type: v1alpha1.FileVariableType, - }, - Description: "A variable", - Default: "default-val", - Prompt: true, - }, - }, - Constants: []v1alpha1.Constant{ - { - Name: "MY_CONST", - Value: "const-val", - Description: "A constant", - AutoIndent: true, - Pattern: ".*", - }, - }, - } - - result := PackageV1alpha1ToV1beta1(pkg) - - vars := result.GetDeprecatedVariables() //nolint:staticcheck // shim used only by the API conversion layer - require.Len(t, vars, 1) - require.Equal(t, "MY_VAR", vars[0].Name) - require.True(t, vars[0].Sensitive) - require.True(t, vars[0].AutoIndent) - require.Equal(t, "^[a-z]+$", vars[0].Pattern) - require.Equal(t, v1beta1.FileVariableType, vars[0].Type) - require.Equal(t, "A variable", vars[0].Description) - require.Equal(t, "default-val", vars[0].Default) - require.True(t, vars[0].Prompt) - - consts := result.GetDeprecatedConstants() //nolint:staticcheck // shim used only by the API conversion layer - require.Len(t, consts, 1) - require.Equal(t, "MY_CONST", consts[0].Name) - require.Equal(t, "const-val", consts[0].Value) - require.Equal(t, "A constant", consts[0].Description) - require.True(t, consts[0].AutoIndent) -} - -func TestV1Alpha1PkgToV1Beta1_YOLOAndGroupShim(t *testing.T) { - t.Parallel() - pkg := v1alpha1.ZarfPackage{ - Kind: v1alpha1.ZarfPackageConfig, - Metadata: v1alpha1.ZarfMetadata{ - Name: "yolo-pkg", - YOLO: true, - }, - Components: []v1alpha1.ZarfComponent{ - { - Name: "comp", - DeprecatedGroup: "my-group", - }, - }, - } - - result := PackageV1alpha1ToV1beta1(pkg) - - require.True(t, result.Metadata.GetDeprecatedYOLO()) //nolint:staticcheck // shim used only by the API conversion layer - require.Len(t, result.Components, 1) - require.Equal(t, "my-group", result.Components[0].GetDeprecatedGroup()) //nolint:staticcheck // shim used only by the API conversion layer -} - func TestV1Alpha1PkgToV1Beta1_ComponentBasics(t *testing.T) { t.Parallel() required := true @@ -255,9 +181,6 @@ func TestV1Alpha1PkgToV1Beta1_ComponentBasics(t *testing.T) { v1alpha1.StateAccessRegistryCredentials, v1alpha1.StateAccessGitCredentials, }, - DataInjections: []v1alpha1.ZarfDataInjection{ - {Source: "/data", Target: v1alpha1.ZarfContainerTarget{Namespace: "default", Selector: "app=test", Container: "main", Path: "/inject"}}, - }, HealthChecks: []v1alpha1.NamespacedObjectKindReference{ {APIVersion: "apps/v1", Kind: "Deployment", Namespace: "default", Name: "my-deploy"}, {APIVersion: "v1", Kind: "Pod", Namespace: "default", Name: "my-pod"}, @@ -303,11 +226,6 @@ func TestV1Alpha1PkgToV1Beta1_ComponentBasics(t *testing.T) { v1beta1.StateAccessGitCredentials, }, comp.StateAccess) - // DataInjections should be preserved via the private shim. - di := comp.GetDeprecatedDataInjections() //nolint:staticcheck // shim used only by the API conversion layer - require.Len(t, di, 1) - require.Equal(t, "/data", di[0].Source) - // HealthChecks should become onDeploy.onSuccess wait actions with kind in .. format. require.Len(t, comp.Actions.OnDeploy.OnSuccess, 2) require.NotNil(t, comp.Actions.OnDeploy.OnSuccess[0].Wait) @@ -689,6 +607,75 @@ func TestV1Alpha1PkgToV1Beta1_WaitConditionBackfill(t *testing.T) { require.Equal(t, "Ready", onSuccess[1].Wait.Cluster.Condition) } +func TestV1Alpha1PkgToV1Beta1_HealthCheckKeepsKStatusCondition(t *testing.T) { + t.Parallel() + pkg := v1alpha1.ZarfPackage{ + Kind: v1alpha1.ZarfPackageConfig, + Components: []v1alpha1.ZarfComponent{ + { + Name: "hc-comp", + HealthChecks: []v1alpha1.NamespacedObjectKindReference{ + {APIVersion: "apps/v1", Kind: "Deployment", Namespace: "default", Name: "my-deploy"}, + }, + }, + }, + } + + result := PackageV1alpha1ToV1beta1(pkg) + + onSuccess := result.Components[0].Actions.OnDeploy.OnSuccess + require.Len(t, onSuccess, 1) + require.NotNil(t, onSuccess[0].Wait) + require.NotNil(t, onSuccess[0].Wait.Cluster) + require.Equal(t, "Deployment.v1.apps", onSuccess[0].Wait.Cluster.Kind) + // A v1alpha1 health check is a kstatus readiness check. v1beta1 treats an empty condition as + // kstatus readiness, so the derived wait must keep an empty condition rather than backfill "exists". + require.Empty(t, onSuccess[0].Wait.Cluster.Condition) +} + +func TestV1Alpha1PkgToV1Beta1_HealthCheckAndWaitConditionInteraction(t *testing.T) { + t.Parallel() + pkg := v1alpha1.ZarfPackage{ + Kind: v1alpha1.ZarfPackageConfig, + Components: []v1alpha1.ZarfComponent{ + { + Name: "mixed-comp", + Actions: v1alpha1.ZarfComponentActions{ + OnDeploy: v1alpha1.ZarfComponentActionSet{ + OnSuccess: []v1alpha1.ZarfComponentAction{ + { + Wait: &v1alpha1.ZarfComponentActionWait{ + Cluster: &v1alpha1.ZarfComponentActionWaitCluster{ + Kind: "Pod", Name: "my-pod", Namespace: "default", + }, + }, + }, + }, + }, + }, + HealthChecks: []v1alpha1.NamespacedObjectKindReference{ + {APIVersion: "apps/v1", Kind: "Deployment", Namespace: "default", Name: "my-deploy"}, + }, + }, + }, + } + + result := PackageV1alpha1ToV1beta1(pkg) + + onSuccess := result.Components[0].Actions.OnDeploy.OnSuccess + require.Len(t, onSuccess, 2) + + // The authored wait carried an empty condition, which meant "wait until exists" in v1alpha1; it is + // backfilled so v1beta1's kstatus-readiness default does not silently change its behavior. + require.Equal(t, "Pod", onSuccess[0].Wait.Cluster.Kind) + require.Equal(t, "exists", onSuccess[0].Wait.Cluster.Condition) + + // The health-check-derived wait is appended after the backfill and keeps an empty condition, so it + // runs as a v1beta1 kstatus readiness check, matching v1alpha1 health-check semantics. + require.Equal(t, "Deployment.v1.apps", onSuccess[1].Wait.Cluster.Kind) + require.Empty(t, onSuccess[1].Wait.Cluster.Condition) +} + func TestV1Alpha1PkgToV1Beta1_ManifestSkipWait(t *testing.T) { t.Parallel() pkg := v1alpha1.ZarfPackage{ @@ -751,10 +738,6 @@ func TestV1Alpha1PkgToV1Beta1_Actions(t *testing.T) { MaxRetries: &maxRetries, Dir: &dir, Description: "run before", - SetVariables: []v1alpha1.Variable{ - {Name: "OUT_VAR", Sensitive: true}, - }, - DeprecatedSetVariable: "OLD_VAR", }, }, After: []v1alpha1.ZarfComponentAction{ @@ -796,12 +779,6 @@ func TestV1Alpha1PkgToV1Beta1_Actions(t *testing.T) { require.NotNil(t, before.Retries) require.Equal(t, int32(3), *before.Retries) require.Equal(t, "run before", before.Description) - // SetVariables should include both the explicit one and the deprecated one, surfaced via the shim. - setVars := before.GetDeprecatedSetVariables() //nolint:staticcheck // shim used only by the API conversion layer - require.Len(t, setVars, 2) - require.Equal(t, "OUT_VAR", setVars[0].Name) - require.True(t, setVars[0].Sensitive) - require.Equal(t, "OLD_VAR", setVars[1].Name) // OnSuccess should be the merge of v1alpha1 After + OnSuccess. require.Len(t, actions.OnDeploy.OnSuccess, 2) @@ -813,51 +790,6 @@ func TestV1Alpha1PkgToV1Beta1_Actions(t *testing.T) { require.Equal(t, "echo failure", actions.OnDeploy.OnFailure[0].Cmd) } -func TestV1Alpha1PkgToV1Beta1_AfterOnSuccessSetVariables(t *testing.T) { - t.Parallel() - pkg := v1alpha1.ZarfPackage{ - Kind: v1alpha1.ZarfPackageConfig, - Components: []v1alpha1.ZarfComponent{ - { - Name: "sv-comp", - Actions: v1alpha1.ZarfComponentActions{ - OnDeploy: v1alpha1.ZarfComponentActionSet{ - After: []v1alpha1.ZarfComponentAction{ - { - Cmd: "echo after", - SetVariables: []v1alpha1.Variable{{Name: "AFTER_VAR"}}, - }, - }, - OnSuccess: []v1alpha1.ZarfComponentAction{ - { - Cmd: "echo success", - SetVariables: []v1alpha1.Variable{{Name: "SUCCESS_VAR", Sensitive: true}}, - }, - }, - }, - }, - }, - }, - } - - result := PackageV1alpha1ToV1beta1(pkg) - - require.Len(t, result.Components[0].Actions.OnDeploy.OnSuccess, 2) - - afterAction := result.Components[0].Actions.OnDeploy.OnSuccess[0] - require.Equal(t, "echo after", afterAction.Cmd) - afterVars := afterAction.GetDeprecatedSetVariables() //nolint:staticcheck // shim used only by the API conversion layer - require.Len(t, afterVars, 1) - require.Equal(t, "AFTER_VAR", afterVars[0].Name) - - successAction := result.Components[0].Actions.OnDeploy.OnSuccess[1] - require.Equal(t, "echo success", successAction.Cmd) - successVars := successAction.GetDeprecatedSetVariables() //nolint:staticcheck // shim used only by the API conversion layer - require.Len(t, successVars, 1) - require.Equal(t, "SUCCESS_VAR", successVars[0].Name) - require.True(t, successVars[0].Sensitive) -} - func TestV1Alpha1PkgToV1Beta1_Files(t *testing.T) { t.Parallel() tmpl := true @@ -914,31 +846,6 @@ func TestV1Alpha1PkgToV1Beta1_ValuesAndDocumentation(t *testing.T) { require.Equal(t, "# Hello", result.Documentation["readme"]) } -func TestV1Alpha1PkgToV1Beta1_DeprecatedVersionShim(t *testing.T) { - t.Parallel() - pkg := v1alpha1.ZarfPackage{ - Kind: v1alpha1.ZarfPackageConfig, - Components: []v1alpha1.ZarfComponent{ - { - Name: "chart-comp", - Charts: []v1alpha1.ZarfChart{ - { - Name: "my-chart", - URL: "https://charts.example.com", - Version: "1.2.3", - }, - }, - }, - }, - } - - result := PackageV1alpha1ToV1beta1(pkg) - - require.Len(t, result.Components[0].Charts, 1) - chart := result.Components[0].Charts[0] - require.Equal(t, "1.2.3", chart.GetDeprecatedVersion()) //nolint:staticcheck // shim used only by the API conversion layer -} - // --- v1beta1 → v1alpha1 tests --- func TestV1Beta1PkgToV1Alpha1_Metadata(t *testing.T) { @@ -1328,44 +1235,6 @@ func TestV1Beta1PkgToV1Alpha1_Actions(t *testing.T) { require.Equal(t, "echo failure", actions.OnDeploy.OnFailure[0].Cmd) } -func TestV1Beta1PkgToV1Alpha1_VariablesShim(t *testing.T) { - t.Parallel() - pkg := v1beta1.SetDeprecatedFromGeneric(types.Package{ - Variables: []types.InteractiveVariable{ - { - Variable: types.Variable{ - Name: "MY_VAR", - Sensitive: true, - AutoIndent: true, - Pattern: "^[a-z]+$", - Type: types.FileVariableType, - }, - Description: "A variable", - Default: "default-val", - Prompt: true, - }, - }, - Constants: []types.Constant{ - {Name: "MY_CONST", Value: "const-val"}, - }, - }, v1beta1.Package{Kind: v1beta1.ZarfPackageConfig}) - - result := PackageV1beta1ToV1alpha1(pkg) - - require.Len(t, result.Variables, 1) - v := result.Variables[0] - require.Equal(t, "MY_VAR", v.Name) - require.True(t, v.Sensitive) - require.Equal(t, v1alpha1.FileVariableType, v.Type) - require.Equal(t, "A variable", v.Description) - require.Equal(t, "default-val", v.Default) - require.True(t, v.Prompt) - - require.Len(t, result.Constants, 1) - require.Equal(t, "MY_CONST", result.Constants[0].Name) - require.Equal(t, "const-val", result.Constants[0].Value) -} - func TestGitRepoRefConversion(t *testing.T) { t.Parallel() @@ -1573,7 +1442,6 @@ func TestRoundTrip_V1Alpha1_To_V1Beta1_And_Back(t *testing.T) { URL: "https://example.com", Authors: "Test Author", AllowNamespaceOverride: &allowOverride, - YOLO: true, }, Build: v1alpha1.ZarfBuildData{ Terminal: "my-machine", @@ -1583,11 +1451,10 @@ func TestRoundTrip_V1Alpha1_To_V1Beta1_And_Back(t *testing.T) { }, Components: []v1alpha1.ZarfComponent{ { - Name: "test-comp", - Required: &required, - DeprecatedGroup: "my-group", - Images: []string{"nginx:latest"}, - Repos: []string{"https://github.com/example/repo", "https://github.com/example/other"}, + Name: "test-comp", + Required: &required, + Images: []string{"nginx:latest"}, + Repos: []string{"https://github.com/example/repo", "https://github.com/example/other"}, StateAccess: []v1alpha1.StateAccessKey{ v1alpha1.StateAccessRegistryCredentials, v1alpha1.StateAccessGitCredentials, @@ -1627,24 +1494,10 @@ func TestRoundTrip_V1Alpha1_To_V1Beta1_And_Back(t *testing.T) { }, }, }, - DataInjections: []v1alpha1.ZarfDataInjection{ - {Source: "/data", Target: v1alpha1.ZarfContainerTarget{Namespace: "default", Selector: "app=test", Container: "main", Path: "/inject"}}, - }, - }, - }, - Constants: []v1alpha1.Constant{ - {Name: "MY_CONST", Value: "val"}, - }, - Variables: []v1alpha1.InteractiveVariable{ - { - Variable: v1alpha1.Variable{Name: "MY_VAR"}, - Description: "a var", - Default: "default", }, }, } - // Round-trip: v1alpha1 → v1beta1 → v1alpha1. beta := PackageV1alpha1ToV1beta1(original) result := PackageV1beta1ToV1alpha1(beta) @@ -1656,14 +1509,12 @@ func TestRoundTrip_V1Alpha1_To_V1Beta1_And_Back(t *testing.T) { require.Equal(t, original.Metadata.Architecture, result.Metadata.Architecture) require.Equal(t, original.Metadata.URL, result.Metadata.URL) require.Equal(t, original.Metadata.Authors, result.Metadata.Authors) - require.True(t, result.Metadata.YOLO) require.Len(t, result.Components, 1) comp := result.Components[0] require.Equal(t, "test-comp", comp.Name) require.NotNil(t, comp.Required) require.True(t, *comp.Required) - require.Equal(t, "my-group", comp.DeprecatedGroup) require.Equal(t, []string{"nginx:latest"}, comp.Images) // Repos and StateAccess should survive the round-trip unchanged. @@ -1690,14 +1541,4 @@ func TestRoundTrip_V1Alpha1_To_V1Beta1_And_Back(t *testing.T) { require.Len(t, comp.Actions.OnDeploy.Before, 1) require.NotNil(t, comp.Actions.OnDeploy.Before[0].MaxTotalSeconds) require.Equal(t, 30, *comp.Actions.OnDeploy.Before[0].MaxTotalSeconds) - - // DataInjections should survive round-trip via private shim. - require.Len(t, comp.DataInjections, 1) - require.Equal(t, "/data", comp.DataInjections[0].Source) - - // Constants and variables. - require.Len(t, result.Constants, 1) - require.Equal(t, "MY_CONST", result.Constants[0].Name) - require.Len(t, result.Variables, 1) - require.Equal(t, "MY_VAR", result.Variables[0].Name) } diff --git a/src/api/v1beta1/component.go b/src/api/v1beta1/component.go index 81819f4827..07ebcd8bbf 100644 --- a/src/api/v1beta1/component.go +++ b/src/api/v1beta1/component.go @@ -12,25 +12,6 @@ type Component struct { // Do not install this component unless explicitly requested. Defaults to false, meaning the component is required. Optional bool `json:"optional,omitempty"` ComponentSpec `json:",inline"` - - // *** REMOVED FIELDS *** - // Below you'll find a set of fields which are kept here for v1alpha1 backwards-compatibility. - dataInjections []ZarfDataInjection - group string -} - -// GetDeprecatedDataInjections returns the v1alpha1 data injections carried as a backwards-compatibility shim. -// -// Deprecated: only used to convert v1alpha1 packages; will be removed once v1alpha1 support is dropped. -func (c Component) GetDeprecatedDataInjections() []ZarfDataInjection { - return c.dataInjections -} - -// GetDeprecatedGroup returns the v1alpha1 group carried as a backwards-compatibility shim. -// -// Deprecated: only used to convert v1alpha1 packages; will be removed once v1alpha1 support is dropped. -func (c Component) GetDeprecatedGroup() string { - return c.group } // GetImages returns all image names specified in the component, including those from ImageArchives. @@ -166,25 +147,6 @@ type Chart struct { SkipSchemaValidation bool `json:"skipSchemaValidation,omitempty"` // Controls whether Helm uses Server-Side Apply (SSA) or client-side apply (CSA) when deploying this chart. Defaults to "auto" when omitted. ServerSideApply ServerSideApplyMode `json:"serverSideApply,omitempty" jsonschema:"enum=true,enum=false,enum=auto,default=auto"` - - // *** REMOVED FIELDS *** - // Below you'll find a set of fields which are kept here for v1alpha1 backwards-compatibility. - version string - variables []ZarfChartVariable -} - -// GetDeprecatedVersion returns the deprecated top-level chart version, used as a v1alpha1 backwards-compatibility shim. -// -// Deprecated: only used to convert v1alpha1 packages; will be removed once v1alpha1 support is dropped. -func (c Chart) GetDeprecatedVersion() string { - return c.version -} - -// GetDeprecatedVariables returns the v1alpha1 chart variables carried as a backwards-compatibility shim. -// -// Deprecated: only used to convert v1alpha1 packages; will be removed once v1alpha1 support is dropped. -func (c Chart) GetDeprecatedVariables() []ZarfChartVariable { - return c.variables } // ValuesFile is a values file merged into a Helm chart on deploy. @@ -373,17 +335,6 @@ type ComponentAction struct { Wait *ComponentActionWait `json:"wait,omitempty"` // EnableTemplating enables go-template processing on the cmd field. EnableTemplating bool `json:"enableTemplating,omitempty"` - - // *** REMOVED FIELDS *** - // Below you'll find a set of fields which are kept here for v1alpha1 backwards-compatibility. - setVariables []Variable -} - -// GetDeprecatedSetVariables returns the v1alpha1 setVariables carried as a backwards-compatibility shim. -// -// Deprecated: only used to convert v1alpha1 packages; will be removed once v1alpha1 support is dropped. -func (a ComponentAction) GetDeprecatedSetVariables() []Variable { - return a.setVariables } // SetValueType declares the expected input back from the cmd, allowing structured data to be parsed. diff --git a/src/api/v1beta1/convert.go b/src/api/v1beta1/convert.go deleted file mode 100644 index feff64f907..0000000000 --- a/src/api/v1beta1/convert.go +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2021-Present The Zarf Authors - -package v1beta1 - -import "github.com/zarf-dev/zarf/src/internal/api/types" - -// SetDeprecatedFromGeneric populates the unexported v1alpha1 backwards-compatibility -// shim fields on an already-converted package. -// This is intentionally not reachable outside Zarf as types.Package is internal. -func SetDeprecatedFromGeneric(g types.Package, pkg Package) Package { - pkg.variables = interactiveVarsFromGeneric(g.Variables) - pkg.constants = constantsFromGeneric(g.Constants) - pkg.Metadata.yolo = g.Metadata.YOLO - - for i := range pkg.Components { - gc := g.Components[i] - pkg.Components[i].dataInjections = dataInjectionsFromGeneric(gc.DataInjections) - pkg.Components[i].group = gc.Group - - for j := range pkg.Components[i].Charts { - gch := gc.Charts[j] - pkg.Components[i].Charts[j].version = gch.Version - pkg.Components[i].Charts[j].variables = chartVarsFromGeneric(gch.Variables) - } - - applyActionSetSetVariables(&pkg.Components[i].Actions.OnCreate, gc.Actions.OnCreate) - applyActionSetSetVariables(&pkg.Components[i].Actions.OnDeploy, gc.Actions.OnDeploy) - applyActionSetSetVariables(&pkg.Components[i].Actions.OnRemove, gc.Actions.OnRemove) - } - - return pkg -} - -func applyActionSetSetVariables(set *ComponentActionSet, g types.ComponentActionSet) { - applyActionSliceSetVariables(set.Before, g.Before) - // set.OnSuccess is [After..., OnSuccess...] after the fold in actionSetFromGeneric. - afterLen := len(g.After) - applyActionSliceSetVariables(set.OnSuccess[:afterLen], g.After) - applyActionSliceSetVariables(set.OnSuccess[afterLen:], g.OnSuccess) - applyActionSliceSetVariables(set.OnFailure, g.OnFailure) -} - -func applyActionSliceSetVariables(actions []ComponentAction, g []types.ComponentAction) { - for k := range g { - setVars := setVarsFromGeneric(g[k].SetVariables) - if g[k].DeprecatedSetVariable != "" { - setVars = append(setVars, Variable{Name: g[k].DeprecatedSetVariable}) - } - if len(setVars) > 0 { - actions[k].setVariables = setVars - } - } -} - -func variableFromGeneric(v types.Variable) Variable { - return Variable{ - Name: v.Name, - Sensitive: v.Sensitive, - AutoIndent: v.AutoIndent, - Pattern: v.Pattern, - Type: VariableType(v.Type), - } -} - -func setVarsFromGeneric(in []types.Variable) []Variable { - var out []Variable - for _, v := range in { - out = append(out, variableFromGeneric(v)) - } - return out -} - -func interactiveVarsFromGeneric(in []types.InteractiveVariable) []InteractiveVariable { - var out []InteractiveVariable - for _, v := range in { - out = append(out, InteractiveVariable{ - Variable: variableFromGeneric(v.Variable), - Description: v.Description, - Default: v.Default, - Prompt: v.Prompt, - }) - } - return out -} - -func constantsFromGeneric(in []types.Constant) []Constant { - var out []Constant - for _, c := range in { - out = append(out, Constant{ - Name: c.Name, - Value: c.Value, - Description: c.Description, - AutoIndent: c.AutoIndent, - Pattern: c.Pattern, - }) - } - return out -} - -func chartVarsFromGeneric(in []types.ZarfChartVariable) []ZarfChartVariable { - var out []ZarfChartVariable - for _, v := range in { - out = append(out, ZarfChartVariable{Name: v.Name, Description: v.Description, Path: v.Path}) - } - return out -} - -func dataInjectionsFromGeneric(in []types.ZarfDataInjection) []ZarfDataInjection { - var out []ZarfDataInjection - for _, d := range in { - out = append(out, ZarfDataInjection{ - Source: d.Source, - Target: ZarfContainerTarget{ - Namespace: d.Target.Namespace, - Selector: d.Target.Selector, - Container: d.Target.Container, - Path: d.Target.Path, - }, - Compress: d.Compress, - }) - } - return out -} diff --git a/src/api/v1beta1/package.go b/src/api/v1beta1/package.go index acfd04cb8e..92bf268798 100644 --- a/src/api/v1beta1/package.go +++ b/src/api/v1beta1/package.go @@ -32,25 +32,6 @@ type Package struct { Values Values `json:"values,omitempty"` // Documentation files included in the package. Documentation map[string]string `json:"documentation,omitempty"` - - // *** REMOVED FIELDS *** - // Below you'll find a set of fields which are kept here for v1alpha1 backwards-compatibility. - variables []InteractiveVariable - constants []Constant -} - -// GetDeprecatedVariables returns the v1alpha1 variables carried as a backwards-compatibility shim. -// -// Deprecated: only used to convert v1alpha1 packages; will be removed once v1alpha1 support is dropped. -func (pkg Package) GetDeprecatedVariables() []InteractiveVariable { - return pkg.variables -} - -// GetDeprecatedConstants returns the v1alpha1 constants carried as a backwards-compatibility shim. -// -// Deprecated: only used to convert v1alpha1 packages; will be removed once v1alpha1 support is dropped. -func (pkg Package) GetDeprecatedConstants() []Constant { - return pkg.constants } // HasImages returns true if one of the components contains an image. @@ -89,16 +70,6 @@ type PackageMetadata struct { Annotations map[string]string `json:"annotations,omitempty"` // Prevent namespace overrides for this package. PreventNamespaceOverride bool `json:"preventNamespaceOverride,omitempty"` - // *** REMOVED FIELDS *** - // Below you'll find a set of fields which are kept here for v1alpha1 backwards-compatibility. - yolo bool -} - -// GetDeprecatedYOLO returns the v1alpha1 YOLO field carried as a backwards-compatibility shim. -// -// Deprecated: only used to convert v1alpha1 packages; will be removed once v1alpha1 support is dropped. -func (m PackageMetadata) GetDeprecatedYOLO() bool { - return m.yolo } // BuildData is written during package create to track details of the created package. diff --git a/src/cmd/dev.go b/src/cmd/dev.go index 53fb7d94ce..a8ada753db 100644 --- a/src/cmd/dev.go +++ b/src/cmd/dev.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/api/v1beta1" "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/config/lang" "github.com/zarf-dev/zarf/src/internal/packager/helm" @@ -136,10 +137,14 @@ func (o *devGenerateSchemaOptions) run(ctx context.Context, args []string) error if err != nil { return err } + pkg, err := defined.AsV1alpha1() + if err != nil { + return err + } // Step 1: Merge default values.files to create initial set of default Zarf values - valuesPaths := make([]string, len(defined.Pkg.Values.Files)) - for i, file := range defined.Pkg.Values.Files { + valuesPaths := make([]string, len(pkg.Values.Files)) + for i, file := range pkg.Values.Files { valuesPaths[i] = filepath.Join(basePath, file) } zarfValues, err := value.ParseFiles(ctx, valuesPaths, value.ParseFilesOptions{}) @@ -158,7 +163,7 @@ func (o *devGenerateSchemaOptions) run(ctx context.Context, args []string) error } }() - for _, component := range defined.Pkg.Components { + for _, component := range pkg.Components { for _, chart := range component.Charts { chartPath := filepath.Join(tmpDir, "charts", chart.Name) valuesFilePath := filepath.Join(tmpDir, "values") @@ -201,7 +206,7 @@ func (o *devGenerateSchemaOptions) run(ctx context.Context, args []string) error generatedSchema := value.GenerateJSONSchema(zarfValues) // Step 4: Merge and reconcile any existing schema - existingSchema, mergeErr := value.MergeSchemaFiles(defined.Pkg.Values.Schema, defined.ImportedSchemas, basePath) + existingSchema, mergeErr := value.MergeSchemaFiles(pkg.Values.Schema, defined.ImportedSchemas, basePath) if mergeErr != nil { return fmt.Errorf("unable to merge imported schemas for schema generation: %w", mergeErr) } @@ -220,11 +225,11 @@ func (o *devGenerateSchemaOptions) run(ctx context.Context, args []string) error if o.update { outputFileName := filepath.Join(basePath, "values.schema.json") - if defined.Pkg.Values.Schema != "" { - if !filepath.IsAbs(defined.Pkg.Values.Schema) { - outputFileName = filepath.Join(basePath, defined.Pkg.Values.Schema) + if pkg.Values.Schema != "" { + if !filepath.IsAbs(pkg.Values.Schema) { + outputFileName = filepath.Join(basePath, pkg.Values.Schema) } else { - outputFileName = defined.Pkg.Values.Schema + outputFileName = pkg.Values.Schema } } else { if err := packager.UpdateSchema(ctx, basePath, "values.schema.json"); err != nil { @@ -301,12 +306,22 @@ func (o *devInspectDefinitionOptions) run(cmd *cobra.Command, args []string) err if err != nil { return err } - defined.Pkg.Build = v1alpha1.ZarfBuildData{} - err = utils.ColorPrintYAML(defined.Pkg, nil, false) + + // The definition is printed in the apiVersion it was authored in. + if defined.OriginalAPIVersion() == v1beta1.APIVersion { + pkg, err := defined.AsV1beta1() + if err != nil { + return err + } + pkg.Build = v1beta1.BuildData{} + return utils.ColorPrintYAML(pkg, nil, false) + } + pkg, err := defined.AsV1alpha1() if err != nil { return err } - return nil + pkg.Build = v1alpha1.ZarfBuildData{} + return utils.ColorPrintYAML(pkg, nil, false) } type devInspectManifestsOptions struct { diff --git a/src/internal/api/v1alpha1/migrate.go b/src/internal/api/v1alpha1/migrate.go new file mode 100644 index 0000000000..2ae2d00ad5 --- /dev/null +++ b/src/internal/api/v1alpha1/migrate.go @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package v1alpha1 + +import ( + "context" + "fmt" + "math" + "slices" + + "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/pkg/logger" +) + +// List of migrations tracked in the zarf.yaml build data. +const ( + ScriptsToActionsMigrated = "scripts-to-actions" + PluralizeSetVariable = "pluralize-set-variable" +) + +// ApplyMigrations applies all v1alpha1 schema migrations to pkg, logging any warnings to ctx. +func ApplyMigrations(ctx context.Context, pkg v1alpha1.ZarfPackage) v1alpha1.ZarfPackage { + pkg, warnings := migrateDeprecated(pkg) + for _, warning := range warnings { + logger.From(ctx).Warn(warning) + } + return pkg +} + +func migrateDeprecated(pkg v1alpha1.ZarfPackage) (v1alpha1.ZarfPackage, []string) { + warnings := []string{} + + migratedComponents := []v1alpha1.ZarfComponent{} + for _, comp := range pkg.Components { + if slices.Contains(pkg.Build.Migrations, ScriptsToActionsMigrated) { + comp.DeprecatedScripts = v1alpha1.DeprecatedZarfComponentScripts{} + } else { + var warning string + if comp, warning = migrateScriptsToActions(comp); warning != "" { + warnings = append(warnings, warning) + } + } + + if slices.Contains(pkg.Build.Migrations, PluralizeSetVariable) { + comp = clearSetVariables(comp) + } else { + var warning string + if comp, warning = migrateSetVariableToSetVariables(comp); warning != "" { + warnings = append(warnings, warning) + } + } + + // Show a warning if the component contains a group as that has been deprecated and will be removed. + if comp.DeprecatedGroup != "" { + warnings = append(warnings, fmt.Sprintf("Component %s is using group which has been deprecated and will be removed in the next schema version. Please migrate to another solution.", comp.Name)) + } + + if len(comp.DataInjections) != 0 { + warnings = append(warnings, fmt.Sprintf("Component %s is using data injections which has been deprecated and will be removed in the next schema version. Please migrate to another solution.", comp.Name)) + } + + migratedComponents = append(migratedComponents, comp) + } + pkg.Components = migratedComponents + + // Record the migrations that have been run on the package. + pkg.Build.Migrations = []string{ + ScriptsToActionsMigrated, + PluralizeSetVariable, + } + + return pkg, warnings +} + +// migrateScriptsToActions coverts the deprecated scripts to the new actions +// The following have no migration: +// - Actions.Create.After +// - Actions.Remove.* +// - Actions.*.OnSuccess +// - Actions.*.OnFailure +// - Actions.*.*.Env +func migrateScriptsToActions(c v1alpha1.ZarfComponent) (v1alpha1.ZarfComponent, string) { + var hasScripts bool + + // Convert a script configs to action defaults. + defaults := v1alpha1.ZarfComponentActionDefaults{ + // ShowOutput (default false) -> Mute (default false) + Mute: !c.DeprecatedScripts.ShowOutput, + // TimeoutSeconds -> MaxSeconds + MaxTotalSeconds: c.DeprecatedScripts.TimeoutSeconds, + } + + // Retry is now an integer vs a boolean (implicit infinite retries), so set to an absurdly high number + if c.DeprecatedScripts.Retry { + defaults.MaxRetries = math.MaxInt + } + + // Scripts.Prepare -> Actions.Create.Before + if len(c.DeprecatedScripts.Prepare) > 0 { + hasScripts = true + c.Actions.OnCreate.Defaults = defaults + for _, s := range c.DeprecatedScripts.Prepare { + c.Actions.OnCreate.Before = append(c.Actions.OnCreate.Before, v1alpha1.ZarfComponentAction{Cmd: s}) + } + } + + // Scripts.Before -> Actions.Deploy.Before + if len(c.DeprecatedScripts.Before) > 0 { + hasScripts = true + c.Actions.OnDeploy.Defaults = defaults + for _, s := range c.DeprecatedScripts.Before { + c.Actions.OnDeploy.Before = append(c.Actions.OnDeploy.Before, v1alpha1.ZarfComponentAction{Cmd: s}) + } + } + + // Scripts.After -> Actions.Deploy.After + if len(c.DeprecatedScripts.After) > 0 { + hasScripts = true + c.Actions.OnDeploy.Defaults = defaults + for _, s := range c.DeprecatedScripts.After { + c.Actions.OnDeploy.After = append(c.Actions.OnDeploy.After, v1alpha1.ZarfComponentAction{Cmd: s}) + } + } + + // Leave deprecated scripts in place, but warn users + if hasScripts { + return c, fmt.Sprintf("Component '%s' is using scripts which will be removed in the next schema version. Please migrate to actions.", c.Name) + } + + return c, "" +} + +func migrateSetVariableToSetVariables(c v1alpha1.ZarfComponent) (v1alpha1.ZarfComponent, string) { + hasSetVariable := false + + migrate := func(actions []v1alpha1.ZarfComponentAction) []v1alpha1.ZarfComponentAction { + for i := range actions { + if actions[i].DeprecatedSetVariable != "" && len(actions[i].SetVariables) < 1 { + hasSetVariable = true + actions[i].SetVariables = []v1alpha1.Variable{ + { + Name: actions[i].DeprecatedSetVariable, + Sensitive: false, + }, + } + } + } + + return actions + } + + // Migrate OnCreate SetVariables + c.Actions.OnCreate.After = migrate(c.Actions.OnCreate.After) + c.Actions.OnCreate.Before = migrate(c.Actions.OnCreate.Before) + c.Actions.OnCreate.OnSuccess = migrate(c.Actions.OnCreate.OnSuccess) + c.Actions.OnCreate.OnFailure = migrate(c.Actions.OnCreate.OnFailure) + + // Migrate OnDeploy SetVariables + c.Actions.OnDeploy.After = migrate(c.Actions.OnDeploy.After) + c.Actions.OnDeploy.Before = migrate(c.Actions.OnDeploy.Before) + c.Actions.OnDeploy.OnSuccess = migrate(c.Actions.OnDeploy.OnSuccess) + c.Actions.OnDeploy.OnFailure = migrate(c.Actions.OnDeploy.OnFailure) + + // Migrate OnRemove SetVariables + c.Actions.OnRemove.After = migrate(c.Actions.OnRemove.After) + c.Actions.OnRemove.Before = migrate(c.Actions.OnRemove.Before) + c.Actions.OnRemove.OnSuccess = migrate(c.Actions.OnRemove.OnSuccess) + c.Actions.OnRemove.OnFailure = migrate(c.Actions.OnRemove.OnFailure) + + // Leave deprecated setVariable in place, but warn users + if hasSetVariable { + return c, fmt.Sprintf("Component '%s' is using setVariable in actions which will be removed in the next schema version. Please migrate to the list form of setVariables.", c.Name) + } + + return c, "" +} + +func clearSetVariables(c v1alpha1.ZarfComponent) v1alpha1.ZarfComponent { + clearVar := func(actions []v1alpha1.ZarfComponentAction) []v1alpha1.ZarfComponentAction { + for i := range actions { + actions[i].DeprecatedSetVariable = "" + } + + return actions + } + + // Clear OnCreate SetVariables + c.Actions.OnCreate.After = clearVar(c.Actions.OnCreate.After) + c.Actions.OnCreate.Before = clearVar(c.Actions.OnCreate.Before) + c.Actions.OnCreate.OnSuccess = clearVar(c.Actions.OnCreate.OnSuccess) + c.Actions.OnCreate.OnFailure = clearVar(c.Actions.OnCreate.OnFailure) + + // Clear OnDeploy SetVariables + c.Actions.OnDeploy.After = clearVar(c.Actions.OnDeploy.After) + c.Actions.OnDeploy.Before = clearVar(c.Actions.OnDeploy.Before) + c.Actions.OnDeploy.OnSuccess = clearVar(c.Actions.OnDeploy.OnSuccess) + c.Actions.OnDeploy.OnFailure = clearVar(c.Actions.OnDeploy.OnFailure) + + // Clear OnRemove SetVariables + c.Actions.OnRemove.After = clearVar(c.Actions.OnRemove.After) + c.Actions.OnRemove.Before = clearVar(c.Actions.OnRemove.Before) + c.Actions.OnRemove.OnSuccess = clearVar(c.Actions.OnRemove.OnSuccess) + c.Actions.OnRemove.OnFailure = clearVar(c.Actions.OnRemove.OnFailure) + + return c +} diff --git a/src/internal/api/v1alpha1/migrate_test.go b/src/internal/api/v1alpha1/migrate_test.go new file mode 100644 index 0000000000..0014b2b5d9 --- /dev/null +++ b/src/internal/api/v1alpha1/migrate_test.go @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package v1alpha1 + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zarf-dev/zarf/src/api/v1alpha1" +) + +func TestMigrateDeprecated(t *testing.T) { + t.Parallel() + + pkg := v1alpha1.ZarfPackage{ + Components: []v1alpha1.ZarfComponent{ + { + DeprecatedScripts: v1alpha1.DeprecatedZarfComponentScripts{ + Retry: true, + Prepare: []string{"p"}, + Before: []string{"b"}, + After: []string{"a"}, + }, + Actions: v1alpha1.ZarfComponentActions{ + OnCreate: v1alpha1.ZarfComponentActionSet{ + After: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "after", + }, + }, + Before: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "before", + }, + }, + OnSuccess: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-success", + }, + }, + OnFailure: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-failure", + }, + }, + }, + OnDeploy: v1alpha1.ZarfComponentActionSet{ + After: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "after", + }, + }, + Before: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "before", + }, + }, + OnSuccess: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-success", + }, + }, + OnFailure: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-failure", + }, + }, + }, + OnRemove: v1alpha1.ZarfComponentActionSet{ + After: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "after", + }, + }, + Before: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "before", + }, + }, + OnSuccess: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-success", + }, + }, + OnFailure: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-failure", + }, + }, + }, + }, + }, + }, + } + migratedPkg, _ := migrateDeprecated(pkg) + + expectedPkg := v1alpha1.ZarfPackage{ + Build: v1alpha1.ZarfBuildData{ + Migrations: []string{ + ScriptsToActionsMigrated, + PluralizeSetVariable, + }, + }, + Components: []v1alpha1.ZarfComponent{ + { + DeprecatedScripts: v1alpha1.DeprecatedZarfComponentScripts{ + Retry: true, + Prepare: []string{"p"}, + Before: []string{"b"}, + After: []string{"a"}, + }, + Actions: v1alpha1.ZarfComponentActions{ + OnCreate: v1alpha1.ZarfComponentActionSet{ + Defaults: v1alpha1.ZarfComponentActionDefaults{ + Mute: true, + MaxRetries: math.MaxInt, + }, + Before: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "before", + SetVariables: []v1alpha1.Variable{ + { + Name: "before", + }, + }, + }, + { + Cmd: "p", + }, + }, + After: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "after", + SetVariables: []v1alpha1.Variable{ + { + Name: "after", + }, + }, + }, + }, + OnSuccess: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-success", + SetVariables: []v1alpha1.Variable{ + { + Name: "on-success", + }, + }, + }, + }, + OnFailure: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-failure", + SetVariables: []v1alpha1.Variable{ + { + Name: "on-failure", + }, + }, + }, + }, + }, + OnDeploy: v1alpha1.ZarfComponentActionSet{ + Defaults: v1alpha1.ZarfComponentActionDefaults{ + Mute: true, + MaxRetries: math.MaxInt, + }, + Before: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "before", + SetVariables: []v1alpha1.Variable{ + { + Name: "before", + }, + }, + }, + { + Cmd: "b", + }, + }, + After: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "after", + SetVariables: []v1alpha1.Variable{ + { + Name: "after", + }, + }, + }, + { + Cmd: "a", + }, + }, + OnSuccess: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-success", + SetVariables: []v1alpha1.Variable{ + { + Name: "on-success", + }, + }, + }, + }, + OnFailure: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-failure", + SetVariables: []v1alpha1.Variable{ + { + Name: "on-failure", + }, + }, + }, + }, + }, + OnRemove: v1alpha1.ZarfComponentActionSet{ + Before: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "before", + SetVariables: []v1alpha1.Variable{ + { + Name: "before", + }, + }, + }, + }, + After: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "after", + SetVariables: []v1alpha1.Variable{ + { + Name: "after", + }, + }, + }, + }, + OnSuccess: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-success", + SetVariables: []v1alpha1.Variable{ + { + Name: "on-success", + }, + }, + }, + }, + OnFailure: []v1alpha1.ZarfComponentAction{ + { + DeprecatedSetVariable: "on-failure", + SetVariables: []v1alpha1.Variable{ + { + Name: "on-failure", + }, + }, + }, + }, + }, + }, + }, + }, + } + require.Equal(t, expectedPkg, migratedPkg) +} diff --git a/src/internal/api/v1beta1/convert.go b/src/internal/api/v1beta1/convert.go index 47b0426d4d..c70732e9d7 100644 --- a/src/internal/api/v1beta1/convert.go +++ b/src/internal/api/v1beta1/convert.go @@ -33,7 +33,6 @@ func ConvertToGeneric(pkg v1beta1.Package) types.Package { Annotations: pkg.Metadata.Annotations, PreventNamespaceOverride: pkg.Metadata.PreventNamespaceOverride, AllowNamespaceOverride: &allowNamespaceOverride, - YOLO: pkg.Metadata.GetDeprecatedYOLO(), //nolint:staticcheck // shim used only by the API conversion layer }, Build: types.BuildData{ Hostname: pkg.Build.Hostname, @@ -56,8 +55,6 @@ func ConvertToGeneric(pkg v1beta1.Package) types.Package { Schema: pkg.Values.Schema, }, Documentation: pkg.Documentation, - Variables: deprecatedVarsToGeneric(pkg.GetDeprecatedVariables()), //nolint:staticcheck // shim used only by the API conversion layer - Constants: deprecatedConstantsToGeneric(pkg.GetDeprecatedConstants()), //nolint:staticcheck // shim used only by the API conversion layer } for _, vr := range pkg.Build.VersionRequirements { @@ -87,10 +84,8 @@ func componentToGeneric(c v1beta1.Component) types.Component { Architecture: c.Selector.Architecture, Flavor: c.Selector.Flavor, }, - Import: importToGeneric(c.Import), - Actions: actionsToGeneric(c.Actions), - Group: c.GetDeprecatedGroup(), //nolint:staticcheck // shim used only by the API conversion layer - DataInjections: deprecatedDataInjectionsToGeneric(c.GetDeprecatedDataInjections()), //nolint:staticcheck // shim used only by the API conversion layer + Import: importToGeneric(c.Import), + Actions: actionsToGeneric(c.Actions), } for _, m := range c.Manifests { @@ -169,8 +164,6 @@ func chartToGeneric(ch v1beta1.Chart) types.Chart { SkipSchemaValidation: ch.SkipSchemaValidation, ServerSideApply: string(ch.ServerSideApply), SkipWait: ch.SkipWait, - Version: ch.GetDeprecatedVersion(), //nolint:staticcheck // shim used only by the API conversion layer - Variables: deprecatedChartVarsToGeneric(ch.GetDeprecatedVariables()), //nolint:staticcheck // shim used only by the API conversion layer } if ch.HelmRepository != nil { @@ -255,7 +248,6 @@ func actionToGeneric(a v1beta1.ComponentAction) types.ComponentAction { Description: a.Description, Wait: waitToGeneric(a.Wait), EnableTemplating: a.EnableTemplating, - SetVariables: deprecatedSetVarsToGeneric(a.GetDeprecatedSetVariables()), //nolint:staticcheck // shim used only by the API conversion layer } for _, sv := range a.SetValues { @@ -330,8 +322,6 @@ func ConvertFromGeneric(g types.Package) v1beta1.Package { pkg.Components = append(pkg.Components, componentFromGeneric(c, isInit, migrateFromV1alpha1)) } - pkg = v1beta1.SetDeprecatedFromGeneric(g, pkg) - return pkg } @@ -846,59 +836,6 @@ func stateAccessFromGeneric(in []string) []v1beta1.StateAccessKey { return out } -func deprecatedVarToGeneric(v v1beta1.Variable) types.Variable { - return types.Variable{ - Name: v.Name, - Sensitive: v.Sensitive, - AutoIndent: v.AutoIndent, - Pattern: v.Pattern, - Type: types.VariableType(v.Type), - } -} - -func deprecatedVarsToGeneric(in []v1beta1.InteractiveVariable) []types.InteractiveVariable { - var out []types.InteractiveVariable - for _, v := range in { - out = append(out, types.InteractiveVariable{ - Variable: deprecatedVarToGeneric(v.Variable), - Description: v.Description, - Default: v.Default, - Prompt: v.Prompt, - }) - } - return out -} - -func deprecatedConstantsToGeneric(in []v1beta1.Constant) []types.Constant { - var out []types.Constant - for _, c := range in { - out = append(out, types.Constant{ - Name: c.Name, - Value: c.Value, - Description: c.Description, - AutoIndent: c.AutoIndent, - Pattern: c.Pattern, - }) - } - return out -} - -func deprecatedChartVarsToGeneric(in []v1beta1.ZarfChartVariable) []types.ZarfChartVariable { - var out []types.ZarfChartVariable - for _, v := range in { - out = append(out, types.ZarfChartVariable{Name: v.Name, Description: v.Description, Path: v.Path}) - } - return out -} - -func deprecatedSetVarsToGeneric(in []v1beta1.Variable) []types.Variable { - var out []types.Variable - for _, v := range in { - out = append(out, deprecatedVarToGeneric(v)) - } - return out -} - func gitRefToGeneric(ref v1beta1.GitRef) *types.GitRef { if ref == (v1beta1.GitRef{}) { return nil @@ -954,20 +891,3 @@ func classifyGitRef(ref string) v1beta1.GitRef { } return v1beta1.GitRef{Tag: strings.TrimPrefix(parsed, "refs/tags/")} } - -func deprecatedDataInjectionsToGeneric(in []v1beta1.ZarfDataInjection) []types.ZarfDataInjection { - var out []types.ZarfDataInjection - for _, d := range in { - out = append(out, types.ZarfDataInjection{ - Source: d.Source, - Target: types.ZarfContainerTarget{ - Namespace: d.Target.Namespace, - Selector: d.Target.Selector, - Container: d.Target.Container, - Path: d.Target.Path, - }, - Compress: d.Compress, - }) - } - return out -} diff --git a/src/internal/api/v1beta1/validate.go b/src/internal/api/v1beta1/validate.go new file mode 100644 index 0000000000..db08416508 --- /dev/null +++ b/src/internal/api/v1beta1/validate.go @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package v1beta1 + +import ( + "errors" + "fmt" + "strings" + + "github.com/zarf-dev/zarf/src/api/v1beta1" + "k8s.io/apimachinery/pkg/util/validation" +) + +const ( + // ZarfMaxChartNameLength limits helm chart name size to account for K8s/helm limits and zarf prefix + ZarfMaxChartNameLength = 40 + errChartReleaseNameEmpty = "release name empty, unable to fallback to chart name" +) + +// Package errors found during validation. +const ( + PkgValidateErrComponentNameNotUnique = "component name %q is not unique" + PkgValidateErrChartNameNotUnique = "chart name %q is not unique" + PkgValidateErrChart = "invalid chart definition: %w" + PkgValidateErrManifestNameNotUnique = "manifest name %q is not unique" + PkgValidateErrManifest = "invalid manifest definition: %w" + PkgValidateErrAction = "invalid action: %w" + PkgValidateErrActionCmdWait = "action %q cannot be both a command and wait action" + PkgValidateErrActionClusterNetwork = "a single wait action must contain only one of cluster or network" + PkgValidateErrActionSetValueOnDeploy = "cannot contain setValues outside of onDeploy in actions" + PkgValidateErrActionTemplateOnCreate = "templating is not supported in onCreate actions" + PkgValidateErrChartName = "chart %q exceed the maximum length of %d characters" + PkgValidateErrChartNamespaceMissing = "chart %q must include a namespace" + PkgValidateErrChartSource = "chart %q must have exactly one source (helmRepository, git, local, or oci)" + PkgValidateErrManifestFileOrKustomize = "manifest %q must have at least one file or kustomization" + PkgValidateErrManifestNameLength = "manifest %q exceed the maximum length of %d characters" + PkgValidateErrNoComponents = "package does not contain any compatible components" +) + +// ValidatePackage runs all validation checks on the package. +func ValidatePackage(pkg v1beta1.Package) error { + var err error + if len(pkg.Components) == 0 { + err = errors.Join(err, errors.New(PkgValidateErrNoComponents)) + } + uniqueComponentNames := make(map[string]bool) + for _, component := range pkg.Components { + // ensure component name is unique + if _, ok := uniqueComponentNames[component.Name]; ok { + err = errors.Join(err, fmt.Errorf(PkgValidateErrComponentNameNotUnique, component.Name)) + } + uniqueComponentNames[component.Name] = true + + uniqueChartNames := make(map[string]bool) + for _, chart := range component.Charts { + // ensure chart name is unique + if _, ok := uniqueChartNames[chart.Name]; ok { + err = errors.Join(err, fmt.Errorf(PkgValidateErrChartNameNotUnique, chart.Name)) + } + uniqueChartNames[chart.Name] = true + if chartErr := validateChart(chart); chartErr != nil { + err = errors.Join(err, fmt.Errorf(PkgValidateErrChart, chartErr)) + } + } + uniqueManifestNames := make(map[string]bool) + for _, manifest := range component.Manifests { + // ensure manifest name is unique + if _, ok := uniqueManifestNames[manifest.Name]; ok { + err = errors.Join(err, fmt.Errorf(PkgValidateErrManifestNameNotUnique, manifest.Name)) + } + uniqueManifestNames[manifest.Name] = true + if manifestErr := validateManifest(manifest); manifestErr != nil { + err = errors.Join(err, fmt.Errorf(PkgValidateErrManifest, manifestErr)) + } + } + if actionsErr := validateActions(component.Actions); actionsErr != nil { + err = errors.Join(err, fmt.Errorf("%q: %w", component.Name, actionsErr)) + } + } + + return err +} + +// validateActions validates the actions of a component. +func validateActions(a v1beta1.ComponentActions) error { + var err error + + err = errors.Join(err, validateActionSet(a.OnCreate)) + + if hasSetValues(a.OnCreate) { + err = errors.Join(err, errors.New(PkgValidateErrActionSetValueOnDeploy)) + } + + if hasTemplating(a.OnCreate) { + err = errors.Join(err, errors.New(PkgValidateErrActionTemplateOnCreate)) + } + + err = errors.Join(err, validateActionSet(a.OnDeploy)) + err = errors.Join(err, validateActionSet(a.OnRemove)) + + return err +} + +// hasSetValues returns true if any of the actions contain setValues. +func hasSetValues(as v1beta1.ComponentActionSet) bool { + check := func(actions []v1beta1.ComponentAction) bool { + for _, action := range actions { + if len(action.SetValues) > 0 { + return true + } + } + return false + } + + return check(as.Before) || check(as.OnSuccess) || check(as.OnFailure) +} + +// hasTemplating returns true if any of the actions have templating enabled. +func hasTemplating(as v1beta1.ComponentActionSet) bool { + check := func(actions []v1beta1.ComponentAction) bool { + for _, action := range actions { + if action.EnableTemplating { + return true + } + } + return false + } + + return check(as.Before) || check(as.OnSuccess) || check(as.OnFailure) +} + +// validateActionSet runs all validation checks on component action sets. +func validateActionSet(as v1beta1.ComponentActionSet) error { + var err error + validate := func(actions []v1beta1.ComponentAction) { + for _, action := range actions { + if actionErr := validateAction(action); actionErr != nil { + err = errors.Join(err, fmt.Errorf(PkgValidateErrAction, actionErr)) + } + } + } + + validate(as.Before) + validate(as.OnFailure) + validate(as.OnSuccess) + return err +} + +// validateAction runs all validation checks on an action. +func validateAction(action v1beta1.ComponentAction) error { + var err error + + if action.Wait != nil { + // Validate only cmd or wait, not both + if action.Cmd != "" { + err = errors.Join(err, fmt.Errorf(PkgValidateErrActionCmdWait, action.Cmd)) + } + + // Validate only cluster or network, not both + if action.Wait.Cluster != nil && action.Wait.Network != nil { + err = errors.Join(err, errors.New(PkgValidateErrActionClusterNetwork)) + } + + // Validate at least one of cluster or network + if action.Wait.Cluster == nil && action.Wait.Network == nil { + err = errors.Join(err, errors.New(PkgValidateErrActionClusterNetwork)) + } + } + + return err +} + +// validateReleaseName validates a release name against DNS 1035 spec, using chartName as fallback. +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names +func validateReleaseName(chartName, releaseName string) error { + // Fallback to chartName if releaseName is empty + // NOTE: Similar fallback mechanism happens in src/internal/packager/helm/chart.go:InstallOrUpgradeChart + if releaseName == "" { + releaseName = chartName + } + + // Check if the final releaseName is empty and return an error if so + if releaseName == "" { + return errors.New(errChartReleaseNameEmpty) + } + + // Validate the releaseName against DNS 1035 label spec + if errs := validation.IsDNS1035Label(releaseName); len(errs) > 0 { + return fmt.Errorf("invalid release name '%s': %s", releaseName, strings.Join(errs, "; ")) + } + + return nil +} + +// validateChart runs all validation checks on a chart. +func validateChart(chart v1beta1.Chart) error { + var err error + + if len(chart.Name) > ZarfMaxChartNameLength { + err = errors.Join(err, fmt.Errorf(PkgValidateErrChartName, chart.Name, ZarfMaxChartNameLength)) + } + + if chart.Namespace == "" { + err = errors.Join(err, fmt.Errorf(PkgValidateErrChartNamespaceMissing, chart.Name)) + } + + // Must have exactly one source + sources := 0 + for _, set := range []bool{chart.HelmRepository != nil, chart.Git != nil, chart.Local != nil, chart.OCI != nil} { + if set { + sources++ + } + } + if sources != 1 { + err = errors.Join(err, fmt.Errorf(PkgValidateErrChartSource, chart.Name)) + } + + if nameErr := validateReleaseName(chart.Name, chart.ReleaseName); nameErr != nil { + err = errors.Join(err, nameErr) + } + + return err +} + +// validateManifest runs all validation checks on a manifest. +func validateManifest(manifest v1beta1.Manifest) error { + var err error + + if len(manifest.Name) > ZarfMaxChartNameLength { + err = errors.Join(err, fmt.Errorf(PkgValidateErrManifestNameLength, manifest.Name, ZarfMaxChartNameLength)) + } + + if len(manifest.Files) < 1 && manifest.Kustomize == nil { + err = errors.Join(err, fmt.Errorf(PkgValidateErrManifestFileOrKustomize, manifest.Name)) + } + + return err +} diff --git a/src/internal/api/v1beta1/validate_test.go b/src/internal/api/v1beta1/validate_test.go new file mode 100644 index 0000000000..9ebac5cab1 --- /dev/null +++ b/src/internal/api/v1beta1/validate_test.go @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package v1beta1 + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zarf-dev/zarf/src/api/v1beta1" +) + +func TestValidatePackage(t *testing.T) { + t.Parallel() + tests := []struct { + name string + pkg v1beta1.Package + expectedErrs []string + }{ + { + name: "valid package", + pkg: v1beta1.Package{ + Kind: v1beta1.ZarfPackageConfig, + Metadata: v1beta1.PackageMetadata{ + Name: "valid-package", + }, + Components: []v1beta1.Component{ + { + Name: "component1", + }, + }, + }, + expectedErrs: nil, + }, + { + name: "no components", + pkg: v1beta1.Package{ + Kind: v1beta1.ZarfPackageConfig, + Metadata: v1beta1.PackageMetadata{ + Name: "valid-package", + }, + }, + expectedErrs: []string{PkgValidateErrNoComponents}, + }, + { + name: "invalid package", + pkg: v1beta1.Package{ + Kind: v1beta1.ZarfPackageConfig, + Metadata: v1beta1.PackageMetadata{ + Name: "invalid-package", + }, + Components: []v1beta1.Component{ + { + Name: "invalid", + ComponentSpec: v1beta1.ComponentSpec{ + Charts: []v1beta1.Chart{ + {Name: "chart1", Namespace: "whatever", Local: &v1beta1.LocalSource{Path: "whatever"}}, + {Name: "chart1", Namespace: "whatever", Local: &v1beta1.LocalSource{Path: "whatever"}}, + }, + Manifests: []v1beta1.Manifest{ + {Name: "manifest1", Files: []string{"file1"}}, + {Name: "manifest1", Files: []string{"file2"}}, + }, + }, + }, + { + Name: "duplicate", + }, + { + Name: "duplicate", + }, + }, + }, + expectedErrs: []string{ + fmt.Sprintf(PkgValidateErrChartNameNotUnique, "chart1"), + fmt.Sprintf(PkgValidateErrManifestNameNotUnique, "manifest1"), + fmt.Sprintf(PkgValidateErrComponentNameNotUnique, "duplicate"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidatePackage(tt.pkg) + if tt.expectedErrs == nil { + require.NoError(t, err) + return + } + errs := strings.Split(err.Error(), "\n") + require.ElementsMatch(t, errs, tt.expectedErrs) + }) + } +} + +func TestValidateManifest(t *testing.T) { + t.Parallel() + longName := strings.Repeat("a", ZarfMaxChartNameLength+1) + tests := []struct { + manifest v1beta1.Manifest + expectedErrs []string + name string + }{ + { + name: "valid files", + manifest: v1beta1.Manifest{Name: "valid", Files: []string{"a-file"}}, + expectedErrs: nil, + }, + { + name: "valid kustomize", + manifest: v1beta1.Manifest{Name: "valid", Kustomize: &v1beta1.KustomizeManifest{Files: []string{"a-dir"}}}, + expectedErrs: nil, + }, + { + name: "long name", + manifest: v1beta1.Manifest{Name: longName, Files: []string{"a-file"}}, + expectedErrs: []string{fmt.Sprintf(PkgValidateErrManifestNameLength, longName, ZarfMaxChartNameLength)}, + }, + { + name: "no files or kustomize", + manifest: v1beta1.Manifest{Name: "nothing-there"}, + expectedErrs: []string{fmt.Sprintf(PkgValidateErrManifestFileOrKustomize, "nothing-there")}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateManifest(tt.manifest) + if tt.expectedErrs == nil { + require.NoError(t, err) + return + } + errs := strings.Split(err.Error(), "\n") + require.ElementsMatch(t, errs, tt.expectedErrs) + }) + } +} + +func TestValidateReleaseName(t *testing.T) { + tests := []struct { + name string + chartName string + releaseName string + expectError bool + errorSubstring string + }{ + { + name: "valid releaseName with hyphens", + chartName: "chart", + releaseName: "valid-release-hyphenated", + expectError: false, + }, + { + name: "invalid releaseName with periods", + chartName: "chart", + releaseName: "namedwithperiods-a.b.c", + expectError: true, + errorSubstring: "invalid release name 'namedwithperiods-a.b.c'", + }, + { + name: "empty releaseName, valid chartName", + chartName: "valid-chart", + releaseName: "", + expectError: false, + }, + { + name: "empty releaseName and chartName", + chartName: "", + releaseName: "", + expectError: true, + errorSubstring: errChartReleaseNameEmpty, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateReleaseName(tt.chartName, tt.releaseName) + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorSubstring) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateChart(t *testing.T) { + t.Parallel() + longName := strings.Repeat("a", ZarfMaxChartNameLength+1) + tests := []struct { + name string + chart v1beta1.Chart + expectedErrs []string + partialMatch bool + }{ + { + name: "valid", + chart: v1beta1.Chart{Name: "chart1", Namespace: "whatever", Local: &v1beta1.LocalSource{Path: "whatever"}, ReleaseName: "this-is-valid"}, + expectedErrs: nil, + }, + { + name: "long name", + chart: v1beta1.Chart{Name: longName, Namespace: "whatever", Local: &v1beta1.LocalSource{Path: "whatever"}}, + expectedErrs: []string{ + fmt.Sprintf(PkgValidateErrChartName, longName, ZarfMaxChartNameLength), + }, + }, + { + name: "no namespace or source", + chart: v1beta1.Chart{Name: "invalid"}, + expectedErrs: []string{ + fmt.Sprintf(PkgValidateErrChartNamespaceMissing, "invalid"), + fmt.Sprintf(PkgValidateErrChartSource, "invalid"), + }, + }, + { + name: "multiple sources", + chart: v1beta1.Chart{ + Name: "invalid", Namespace: "whatever", + Local: &v1beta1.LocalSource{Path: "whatever"}, + OCI: &v1beta1.OCISource{URL: "oci://whatever", Ref: v1beta1.OCIRef{Tag: "1.0.0"}}, + }, + expectedErrs: []string{ + fmt.Sprintf(PkgValidateErrChartSource, "invalid"), + }, + }, + { + name: "invalid releaseName", + chart: v1beta1.Chart{ReleaseName: "namedwithperiods-0.47.0", Name: "releaseName", Namespace: "whatever", Local: &v1beta1.LocalSource{Path: "whatever"}}, + expectedErrs: []string{"invalid release name 'namedwithperiods-0.47.0'"}, + partialMatch: true, + }, + { + name: "missing releaseName fallsback to name", + chart: v1beta1.Chart{Name: "chart3", Namespace: "namespace", Local: &v1beta1.LocalSource{Path: "whatever"}}, + expectedErrs: nil, + }, + { + name: "missing name and releaseName", + chart: v1beta1.Chart{Namespace: "namespace", Local: &v1beta1.LocalSource{Path: "whatever"}}, + expectedErrs: []string{errChartReleaseNameEmpty}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateChart(tt.chart) + if tt.expectedErrs == nil { + require.NoError(t, err) + return + } + require.Error(t, err) + errString := err.Error() + if tt.partialMatch { + for _, expectedErr := range tt.expectedErrs { + require.Contains(t, errString, expectedErr) + } + } else { + errs := strings.Split(errString, "\n") + require.ElementsMatch(t, tt.expectedErrs, errs) + } + }) + } +} + +func TestValidateComponentActions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + actions v1beta1.ComponentActions + expectedErrs []string + }{ + { + name: "valid actions", + actions: v1beta1.ComponentActions{ + OnCreate: v1beta1.ComponentActionSet{ + Before: []v1beta1.ComponentAction{ + { + Cmd: "echo 'onCreate before valid'", + }, + }, + }, + OnDeploy: v1beta1.ComponentActionSet{ + Before: []v1beta1.ComponentAction{ + { + Cmd: "echo 'onDeploy before valid'", + }, + }, + }, + }, + expectedErrs: nil, + }, + { + name: "setValues in onCreate", + actions: v1beta1.ComponentActions{ + OnCreate: v1beta1.ComponentActionSet{ + Before: []v1beta1.ComponentAction{ + { + Cmd: "echo 'invalid setValue'", + SetValues: []v1beta1.SetValue{{Key: "key"}}, + }, + }, + }, + }, + expectedErrs: []string{PkgValidateErrActionSetValueOnDeploy}, + }, + { + name: "templating in onCreate", + actions: v1beta1.ComponentActions{ + OnCreate: v1beta1.ComponentActionSet{ + Before: []v1beta1.ComponentAction{ + { + Cmd: "echo 'templating not allowed'", + EnableTemplating: true, + }, + }, + }, + }, + expectedErrs: []string{PkgValidateErrActionTemplateOnCreate}, + }, + { + name: "invalid actions", + actions: v1beta1.ComponentActions{ + OnCreate: v1beta1.ComponentActionSet{ + Before: []v1beta1.ComponentAction{ + { + Cmd: "create", + Wait: &v1beta1.ComponentActionWait{Cluster: &v1beta1.ComponentActionWaitCluster{}}, + }, + }, + }, + OnRemove: v1beta1.ComponentActionSet{ + OnSuccess: []v1beta1.ComponentAction{ + { + Cmd: "remove", + Wait: &v1beta1.ComponentActionWait{Cluster: &v1beta1.ComponentActionWaitCluster{}}, + }, + }, + OnFailure: []v1beta1.ComponentAction{ + { + Cmd: "remove2", + Wait: &v1beta1.ComponentActionWait{Cluster: &v1beta1.ComponentActionWaitCluster{}}, + }, + }, + }, + }, + expectedErrs: []string{ + fmt.Errorf(PkgValidateErrAction, fmt.Errorf(PkgValidateErrActionCmdWait, "create")).Error(), + fmt.Errorf(PkgValidateErrAction, fmt.Errorf(PkgValidateErrActionCmdWait, "remove")).Error(), + fmt.Errorf(PkgValidateErrAction, fmt.Errorf(PkgValidateErrActionCmdWait, "remove2")).Error(), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateActions(tt.actions) + if tt.expectedErrs == nil { + require.NoError(t, err) + return + } + errs := strings.Split(err.Error(), "\n") + require.ElementsMatch(t, tt.expectedErrs, errs) + }) + } +} + +func TestValidateComponentAction(t *testing.T) { + t.Parallel() + tests := []struct { + name string + action v1beta1.ComponentAction + expectedErrs []string + }{ + { + name: "valid action no conditions", + action: v1beta1.ComponentAction{}, + }, + { + name: "cmd and wait both set, nothing in wait", + action: v1beta1.ComponentAction{ + Cmd: "ls", + Wait: &v1beta1.ComponentActionWait{}, + }, + expectedErrs: []string{ + fmt.Sprintf(PkgValidateErrActionCmdWait, "ls"), + PkgValidateErrActionClusterNetwork, + }, + }, + { + name: "cluster and network both set", + action: v1beta1.ComponentAction{ + Wait: &v1beta1.ComponentActionWait{Cluster: &v1beta1.ComponentActionWaitCluster{}, Network: &v1beta1.ComponentActionWaitNetwork{}}, + }, + expectedErrs: []string{PkgValidateErrActionClusterNetwork}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateAction(tt.action) + if tt.expectedErrs == nil { + require.NoError(t, err) + return + } + errs := strings.Split(err.Error(), "\n") + require.ElementsMatch(t, tt.expectedErrs, errs) + }) + } +} diff --git a/src/internal/pkgcfg/pkgcfg.go b/src/internal/pkgcfg/pkgcfg.go index 16109c9073..138d249541 100644 --- a/src/internal/pkgcfg/pkgcfg.go +++ b/src/internal/pkgcfg/pkgcfg.go @@ -8,60 +8,152 @@ import ( "context" "errors" "fmt" - "math" - "slices" goyaml "github.com/goccy/go-yaml" "github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/parser" "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/api/v1beta1" + "github.com/zarf-dev/zarf/src/internal/api/types" + internalv1alpha1 "github.com/zarf-dev/zarf/src/internal/api/v1alpha1" + internalv1beta1 "github.com/zarf-dev/zarf/src/internal/api/v1beta1" "github.com/zarf-dev/zarf/src/pkg/logger" ) -// apiVersionHandler pairs a supported apiVersion with its decoder. -type apiVersionHandler struct { +// Decoder decodes one apiVersion's document into its native package type T and converts that into +// the internal generic representation +type Decoder[T any] struct { + version string + priority int + decode func(ctx context.Context, node ast.Node) (T, error) + toGeneric func(T) types.Package +} + +// V1Alpha1 decodes the v1alpha1 ZarfPackage schema. +var V1Alpha1 = Decoder[v1alpha1.ZarfPackage]{ + version: v1alpha1.APIVersion, + priority: 1, + decode: decodeV1Alpha1, + toGeneric: internalv1alpha1.ConvertToGeneric, +} + +// V1Beta1 decodes the v1beta1 Package schema. +var V1Beta1 = Decoder[v1beta1.Package]{ + version: v1beta1.APIVersion, + priority: 2, + decode: decodeV1Beta1, + toGeneric: internalv1beta1.ConvertToGeneric, +} + +// knownDecoders lists every apiVersion this Zarf version can decode, type-erased for version +// selection. To add a new version, declare its Decoder above and append it here with a higher +// priority than any existing one. +var knownDecoders = []genericDecoder{ + V1Alpha1.toGenericEncoder(), + V1Beta1.toGenericEncoder(), +} + +// genericDecoder is the type-erased view of a Decoder, decoding straight to the internal generic +// representation. It is used for version selection and for ParseMultiDoc, which do not need the +// native type. +type genericDecoder struct { version string priority int - decode func(ctx context.Context, node ast.Node) (v1alpha1.ZarfPackage, error) + decode func(ctx context.Context, node ast.Node) (types.Package, error) } -// knownAPIVersions lists every apiVersion this Zarf version can decode. To add a -// new version, append an entry with a higher priority than any existing one. -var knownAPIVersions = []apiVersionHandler{ - {version: v1alpha1.APIVersion, priority: 1, decode: decodeV1Alpha1}, +// erase drops the native type parameter, folding decode and toGeneric into a single node→generic step. +func (d Decoder[T]) toGenericEncoder() genericDecoder { + return genericDecoder{ + version: d.version, + priority: d.priority, + decode: func(ctx context.Context, node ast.Node) (types.Package, error) { + pkg, err := d.decode(ctx, node) + if err != nil { + return types.Package{}, err + } + return d.toGeneric(pkg), nil + }, + } } -// Parse parses a single Zarf package definition at any supported API version -func Parse(ctx context.Context, b []byte) (v1alpha1.ZarfPackage, error) { +// ParseAs returns the document matching the decoder's apiVersion from a package definition that may +// contain multiple documents, decoded into its native type. +func ParseAs[T any](ctx context.Context, b []byte, d Decoder[T]) (T, error) { + var zero T docs, err := parseZarfYAMLDocs(b) if err != nil { - return v1alpha1.ZarfPackage{}, err + return zero, err } - if len(docs) > 1 { - return v1alpha1.ZarfPackage{}, errors.New("package definition must contain a single YAML document") + for i, doc := range docs { + version, err := apiVersionFromNode(doc.Body) + if err != nil { + return zero, fmt.Errorf("document %d: reading apiVersion: %w", i, err) + } + if normalizeAPIVersion(version) != d.version { + continue + } + return d.decode(ctx, doc.Body) } - version, err := apiVersionFromNode(docs[0].Body) + return zero, fmt.Errorf("no %q document found in package definition", d.version) +} + +// SelectVersion returns the apiVersion Zarf will decode from a package definition that may contain +// multiple documents; the highest-priority known version wins. Use it to pick the decode target +// before calling ParseAs. +func SelectVersion(ctx context.Context, b []byte) (string, error) { + docs, err := parseZarfYAMLDocs(b) if err != nil { - return v1alpha1.ZarfPackage{}, fmt.Errorf("reading apiVersion: %w", err) + return "", err } - handler, known := handlerFor(version) - if !known { - return v1alpha1.ZarfPackage{}, fmt.Errorf("unsupported apiVersion %q", version) + d, _, err := selectDecoder(ctx, docs) + if err != nil { + return "", err } - return handler.decode(ctx, docs[0].Body) + return d.version, nil } -// ParseMultiDoc parses a multi doc zarf.yaml file, generally from an already built package. -// Multi doc definitions may contain one document per apiVersion. -func ParseMultiDoc(ctx context.Context, b []byte) (v1alpha1.ZarfPackage, error) { - l := logger.From(ctx) +// ParseMultiDoc parses a multi doc zarf.yaml file, into the internal generic representation +// Multi doc definitions may contain one document per apiVersion; the highest-priority known version wins. +func ParseMultiDoc(ctx context.Context, b []byte) (types.Package, error) { docs, err := parseZarfYAMLDocs(b) if err != nil { + return types.Package{}, err + } + d, node, err := selectDecoder(ctx, docs) + if err != nil { + return types.Package{}, err + } + return d.decode(ctx, node) +} + +func decodeV1Alpha1(ctx context.Context, node ast.Node) (v1alpha1.ZarfPackage, error) { + var pkg v1alpha1.ZarfPackage + if err := goyaml.NodeToValue(node, &pkg); err != nil { return v1alpha1.ZarfPackage{}, err } + pkg = internalv1alpha1.ApplyMigrations(ctx, pkg) + pkg.Build.SetOriginalAPIVersion(v1alpha1.APIVersion) + return pkg, nil +} + +// decodeV1Beta1 decodes a v1beta1 document into its native type. v1beta1 has no v1alpha1-style +// migrations. +func decodeV1Beta1(_ context.Context, node ast.Node) (v1beta1.Package, error) { + var pkg v1beta1.Package + if err := goyaml.NodeToValue(node, &pkg); err != nil { + return v1beta1.Package{}, err + } + pkg.Build.SetOriginalAPIVersion(v1beta1.APIVersion) + return pkg, nil +} +// selectDecoder picks the highest-priority known apiVersion among the documents, returning its +// decoder and body node. It errors on a duplicate apiVersion or when no known version is present. +func selectDecoder(ctx context.Context, docs []*ast.DocumentNode) (genericDecoder, ast.Node, error) { + l := logger.From(ctx) var ( - chosen apiVersionHandler + chosen genericDecoder chosenNode ast.Node found bool ) @@ -70,56 +162,46 @@ func ParseMultiDoc(ctx context.Context, b []byte) (v1alpha1.ZarfPackage, error) for i, doc := range docs { version, err := apiVersionFromNode(doc.Body) if err != nil { - return v1alpha1.ZarfPackage{}, fmt.Errorf("document %d: reading apiVersion: %w", i, err) + return genericDecoder{}, nil, fmt.Errorf("document %d: reading apiVersion: %w", i, err) } - handler, known := handlerFor(version) + d, known := decoderFor(version) if !known { l.Debug("found unsupported API version during parse", "apiVersion", version) continue } - if seenVersions[handler.version] { - return v1alpha1.ZarfPackage{}, fmt.Errorf("duplicate apiVersion %q in package definition", handler.version) + if seenVersions[d.version] { + return genericDecoder{}, nil, fmt.Errorf("duplicate apiVersion %q in package definition", d.version) } - seenVersions[handler.version] = true - if !found || handler.priority > chosen.priority { - chosen = handler + seenVersions[d.version] = true + if !found || d.priority > chosen.priority { + chosen = d chosenNode = doc.Body found = true } } if !found { - return v1alpha1.ZarfPackage{}, errors.New("no supported apiVersion found in package definition") + return genericDecoder{}, nil, errors.New("no supported apiVersion found in package definition") } - return chosen.decode(ctx, chosenNode) + return chosen, chosenNode, nil } -func decodeV1Alpha1(ctx context.Context, node ast.Node) (v1alpha1.ZarfPackage, error) { - var pkg v1alpha1.ZarfPackage - if err := goyaml.NodeToValue(node, &pkg); err != nil { - return v1alpha1.ZarfPackage{}, err - } - return applyV1Alpha1Migrations(ctx, pkg), nil -} - -func applyV1Alpha1Migrations(ctx context.Context, pkg v1alpha1.ZarfPackage) v1alpha1.ZarfPackage { - pkg, warnings := migrateDeprecated(pkg) - for _, warning := range warnings { - logger.From(ctx).Warn(warning) +func decoderFor(version string) (genericDecoder, bool) { + version = normalizeAPIVersion(version) + for _, d := range knownDecoders { + if d.version == version { + return d, true + } } - return pkg + return genericDecoder{}, false } -func handlerFor(version string) (apiVersionHandler, bool) { +// normalizeAPIVersion treats an absent apiVersion as v1alpha1, which predates the required field. +func normalizeAPIVersion(version string) string { if version == "" { - version = v1alpha1.APIVersion - } - for _, h := range knownAPIVersions { - if h.version == version { - return h, true - } + return v1alpha1.APIVersion } - return apiVersionHandler{}, false + return version } func apiVersionFromNode(node ast.Node) (string, error) { @@ -157,187 +239,3 @@ func filterEmptyDocs(docs []*ast.DocumentNode) []*ast.DocumentNode { } return out } - -// List of migrations tracked in the zarf.yaml build data. -const ( - ScriptsToActionsMigrated = "scripts-to-actions" - PluralizeSetVariable = "pluralize-set-variable" -) - -func migrateDeprecated(pkg v1alpha1.ZarfPackage) (v1alpha1.ZarfPackage, []string) { - warnings := []string{} - - migratedComponents := []v1alpha1.ZarfComponent{} - for _, comp := range pkg.Components { - if slices.Contains(pkg.Build.Migrations, ScriptsToActionsMigrated) { - comp.DeprecatedScripts = v1alpha1.DeprecatedZarfComponentScripts{} - } else { - var warning string - if comp, warning = migrateScriptsToActions(comp); warning != "" { - warnings = append(warnings, warning) - } - } - - if slices.Contains(pkg.Build.Migrations, PluralizeSetVariable) { - comp = clearSetVariables(comp) - } else { - var warning string - if comp, warning = migrateSetVariableToSetVariables(comp); warning != "" { - warnings = append(warnings, warning) - } - } - - // Show a warning if the component contains a group as that has been deprecated and will be removed. - if comp.DeprecatedGroup != "" { - warnings = append(warnings, fmt.Sprintf("Component %s is using group which has been deprecated and will be removed in the next schema version. Please migrate to another solution.", comp.Name)) - } - - if len(comp.DataInjections) != 0 { - warnings = append(warnings, fmt.Sprintf("Component %s is using data injections which has been deprecated and will be removed in the next schema version. Please migrate to another solution.", comp.Name)) - } - - migratedComponents = append(migratedComponents, comp) - } - pkg.Components = migratedComponents - - // Record the migrations that have been run on the package. - pkg.Build.Migrations = []string{ - ScriptsToActionsMigrated, - PluralizeSetVariable, - } - - return pkg, warnings -} - -// migrateScriptsToActions coverts the deprecated scripts to the new actions -// The following have no migration: -// - Actions.Create.After -// - Actions.Remove.* -// - Actions.*.OnSuccess -// - Actions.*.OnFailure -// - Actions.*.*.Env -func migrateScriptsToActions(c v1alpha1.ZarfComponent) (v1alpha1.ZarfComponent, string) { - var hasScripts bool - - // Convert a script configs to action defaults. - defaults := v1alpha1.ZarfComponentActionDefaults{ - // ShowOutput (default false) -> Mute (default false) - Mute: !c.DeprecatedScripts.ShowOutput, - // TimeoutSeconds -> MaxSeconds - MaxTotalSeconds: c.DeprecatedScripts.TimeoutSeconds, - } - - // Retry is now an integer vs a boolean (implicit infinite retries), so set to an absurdly high number - if c.DeprecatedScripts.Retry { - defaults.MaxRetries = math.MaxInt - } - - // Scripts.Prepare -> Actions.Create.Before - if len(c.DeprecatedScripts.Prepare) > 0 { - hasScripts = true - c.Actions.OnCreate.Defaults = defaults - for _, s := range c.DeprecatedScripts.Prepare { - c.Actions.OnCreate.Before = append(c.Actions.OnCreate.Before, v1alpha1.ZarfComponentAction{Cmd: s}) - } - } - - // Scripts.Before -> Actions.Deploy.Before - if len(c.DeprecatedScripts.Before) > 0 { - hasScripts = true - c.Actions.OnDeploy.Defaults = defaults - for _, s := range c.DeprecatedScripts.Before { - c.Actions.OnDeploy.Before = append(c.Actions.OnDeploy.Before, v1alpha1.ZarfComponentAction{Cmd: s}) - } - } - - // Scripts.After -> Actions.Deploy.After - if len(c.DeprecatedScripts.After) > 0 { - hasScripts = true - c.Actions.OnDeploy.Defaults = defaults - for _, s := range c.DeprecatedScripts.After { - c.Actions.OnDeploy.After = append(c.Actions.OnDeploy.After, v1alpha1.ZarfComponentAction{Cmd: s}) - } - } - - // Leave deprecated scripts in place, but warn users - if hasScripts { - return c, fmt.Sprintf("Component '%s' is using scripts which will be removed in the next schema version. Please migrate to actions.", c.Name) - } - - return c, "" -} - -func migrateSetVariableToSetVariables(c v1alpha1.ZarfComponent) (v1alpha1.ZarfComponent, string) { - hasSetVariable := false - - migrate := func(actions []v1alpha1.ZarfComponentAction) []v1alpha1.ZarfComponentAction { - for i := range actions { - if actions[i].DeprecatedSetVariable != "" && len(actions[i].SetVariables) < 1 { - hasSetVariable = true - actions[i].SetVariables = []v1alpha1.Variable{ - { - Name: actions[i].DeprecatedSetVariable, - Sensitive: false, - }, - } - } - } - - return actions - } - - // Migrate OnCreate SetVariables - c.Actions.OnCreate.After = migrate(c.Actions.OnCreate.After) - c.Actions.OnCreate.Before = migrate(c.Actions.OnCreate.Before) - c.Actions.OnCreate.OnSuccess = migrate(c.Actions.OnCreate.OnSuccess) - c.Actions.OnCreate.OnFailure = migrate(c.Actions.OnCreate.OnFailure) - - // Migrate OnDeploy SetVariables - c.Actions.OnDeploy.After = migrate(c.Actions.OnDeploy.After) - c.Actions.OnDeploy.Before = migrate(c.Actions.OnDeploy.Before) - c.Actions.OnDeploy.OnSuccess = migrate(c.Actions.OnDeploy.OnSuccess) - c.Actions.OnDeploy.OnFailure = migrate(c.Actions.OnDeploy.OnFailure) - - // Migrate OnRemove SetVariables - c.Actions.OnRemove.After = migrate(c.Actions.OnRemove.After) - c.Actions.OnRemove.Before = migrate(c.Actions.OnRemove.Before) - c.Actions.OnRemove.OnSuccess = migrate(c.Actions.OnRemove.OnSuccess) - c.Actions.OnRemove.OnFailure = migrate(c.Actions.OnRemove.OnFailure) - - // Leave deprecated setVariable in place, but warn users - if hasSetVariable { - return c, fmt.Sprintf("Component '%s' is using setVariable in actions which will be removed in the next schema version. Please migrate to the list form of setVariables.", c.Name) - } - - return c, "" -} - -func clearSetVariables(c v1alpha1.ZarfComponent) v1alpha1.ZarfComponent { - clearVar := func(actions []v1alpha1.ZarfComponentAction) []v1alpha1.ZarfComponentAction { - for i := range actions { - actions[i].DeprecatedSetVariable = "" - } - - return actions - } - - // Clear OnCreate SetVariables - c.Actions.OnCreate.After = clearVar(c.Actions.OnCreate.After) - c.Actions.OnCreate.Before = clearVar(c.Actions.OnCreate.Before) - c.Actions.OnCreate.OnSuccess = clearVar(c.Actions.OnCreate.OnSuccess) - c.Actions.OnCreate.OnFailure = clearVar(c.Actions.OnCreate.OnFailure) - - // Clear OnDeploy SetVariables - c.Actions.OnDeploy.After = clearVar(c.Actions.OnDeploy.After) - c.Actions.OnDeploy.Before = clearVar(c.Actions.OnDeploy.Before) - c.Actions.OnDeploy.OnSuccess = clearVar(c.Actions.OnDeploy.OnSuccess) - c.Actions.OnDeploy.OnFailure = clearVar(c.Actions.OnDeploy.OnFailure) - - // Clear OnRemove SetVariables - c.Actions.OnRemove.After = clearVar(c.Actions.OnRemove.After) - c.Actions.OnRemove.Before = clearVar(c.Actions.OnRemove.Before) - c.Actions.OnRemove.OnSuccess = clearVar(c.Actions.OnRemove.OnSuccess) - c.Actions.OnRemove.OnFailure = clearVar(c.Actions.OnRemove.OnFailure) - - return c -} diff --git a/src/internal/pkgcfg/pkgcfg_test.go b/src/internal/pkgcfg/pkgcfg_test.go index 19501beea0..6b99a5a63c 100644 --- a/src/internal/pkgcfg/pkgcfg_test.go +++ b/src/internal/pkgcfg/pkgcfg_test.go @@ -5,123 +5,18 @@ package pkgcfg import ( "context" - "math" "testing" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/api/v1beta1" + "github.com/zarf-dev/zarf/src/internal/api/types" + internalv1alpha1 "github.com/zarf-dev/zarf/src/internal/api/v1alpha1" ) // newer is a future apiVersion this binary does not understand. const newer = "zarf.dev/v1beta999" -func TestParseDefinition(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - yaml string - wantName string - wantErr string - }{ - { - name: "omitted apiVersion parses as v1alpha1", - yaml: ` -kind: ZarfPackageConfig -metadata: - name: no-api-version -`, - wantName: "no-api-version", - }, - { - name: "explicit v1alpha1 apiVersion parses", - yaml: ` -apiVersion: zarf.dev/v1alpha1 -kind: ZarfPackageConfig -metadata: - name: explicit-v1alpha1 -`, - wantName: "explicit-v1alpha1", - }, - { - name: "unknown apiVersion errors without silent fallback", - yaml: ` -apiVersion: ` + newer + ` -kind: ZarfPackageConfig -metadata: - name: from-future -`, - wantErr: `unsupported apiVersion "` + newer + `"`, - }, - { - name: "multi-document input errors", - yaml: ` -apiVersion: zarf.dev/v1alpha1 -kind: ZarfPackageConfig -metadata: - name: first ---- -apiVersion: zarf.dev/v1alpha1 -kind: ZarfPackageConfig -metadata: - name: second -`, - wantErr: "single YAML document", - }, - { - name: "leading document separator is accepted", - yaml: `--- -apiVersion: zarf.dev/v1alpha1 -kind: ZarfPackageConfig -metadata: - name: leading-sep -`, - wantName: "leading-sep", - }, - { - name: "leading and trailing separators are accepted", - yaml: `--- -apiVersion: zarf.dev/v1alpha1 -kind: ZarfPackageConfig -metadata: - name: both-sep ---- -`, - wantName: "both-sep", - }, - { - name: "empty input errors", - yaml: "", - wantErr: "no package definition found", - }, - { - name: "whitespace-only input errors", - yaml: "\n \n", - wantErr: "no package definition found", - }, - { - name: "malformed yaml bubbles up from the parser", - yaml: "apiVersion: [not, a, string]\n", - wantErr: "apiVersion", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - pkg, err := Parse(context.Background(), []byte(tt.yaml)) - if tt.wantErr != "" { - require.ErrorContains(t, err, tt.wantErr) - require.Equal(t, v1alpha1.ZarfPackage{}, pkg) - return - } - require.NoError(t, err) - require.Equal(t, tt.wantName, pkg.Metadata.Name) - }) - } -} - func TestParseBuiltPackageDefinition(t *testing.T) { t.Parallel() @@ -221,7 +116,7 @@ metadata: pkg, err := ParseMultiDoc(context.Background(), []byte(tt.yaml)) if tt.wantErr != "" { require.ErrorContains(t, err, tt.wantErr) - require.Equal(t, v1alpha1.ZarfPackage{}, pkg) + require.Equal(t, types.Package{}, pkg) return } require.NoError(t, err) @@ -230,291 +125,126 @@ metadata: } } -// TestParseDefinitionAndParseBuiltPackageAgreeOnSingleDoc confirms that a -// single-doc v1alpha1 yaml decodes identically through both entry points. -func TestParseDefinitionAndParseBuiltPackageAgreeOnSingleDoc(t *testing.T) { +func TestParseAs(t *testing.T) { + t.Parallel() + + yaml := ` +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: beta-pkg + description: a v1beta1 package +components: + - name: first + description: a component +` + pkg, err := ParseAs(context.Background(), []byte(yaml), V1Beta1) + require.NoError(t, err) + require.Equal(t, v1beta1.APIVersion, pkg.APIVersion) + require.Equal(t, "beta-pkg", pkg.Metadata.Name) + require.Equal(t, "a v1beta1 package", pkg.Metadata.Description) + require.Len(t, pkg.Components, 1) + require.Equal(t, "first", pkg.Components[0].Name) +} + +func TestParseAsSelectsFromMultiDoc(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // The requested apiVersion's document is returned regardless of where it sits among others. + mixed := "apiVersion: zarf.dev/v1alpha1\nkind: ZarfPackageConfig\nmetadata:\n name: alpha\n---\napiVersion: zarf.dev/v1beta1\nkind: ZarfPackageConfig\nmetadata:\n name: beta\ncomponents:\n - name: c\n" + pkg, err := ParseAs(ctx, []byte(mixed), V1Beta1) + require.NoError(t, err) + require.Equal(t, v1beta1.APIVersion, pkg.APIVersion) + require.Equal(t, "beta", pkg.Metadata.Name) + + // The same definition can be read as its v1alpha1 document by naming that apiVersion. + alpha, err := ParseAs(ctx, []byte(mixed), V1Alpha1) + require.NoError(t, err) + require.Equal(t, "alpha", alpha.Metadata.Name) +} + +func TestParseAsV1Alpha1(t *testing.T) { t.Parallel() ctx := context.Background() - body := []byte("apiVersion: " + v1alpha1.APIVersion + "\nkind: ZarfPackageConfig\nmetadata:\n name: agree\ncomponents:\n - name: c\n") - fromDef, err := Parse(ctx, body) + // A document with no apiVersion is treated as v1alpha1. + omitted := "kind: ZarfPackageConfig\nmetadata:\n name: no-api-version\ncomponents:\n - name: c\n" + pkg, err := ParseAs(ctx, []byte(omitted), V1Alpha1) require.NoError(t, err) - fromPkg, err := ParseMultiDoc(ctx, body) + require.Equal(t, "no-api-version", pkg.Metadata.Name) + + // v1alpha1 deprecation migrations run as part of decoding: a deprecated script becomes an + // action and the migration is recorded on the build data. + withScripts := ` +apiVersion: zarf.dev/v1alpha1 +kind: ZarfPackageConfig +metadata: + name: migrate-me +components: + - name: c + scripts: + prepare: + - "echo hello" +` + pkg, err = ParseAs(ctx, []byte(withScripts), V1Alpha1) require.NoError(t, err) - require.Equal(t, fromDef, fromPkg) + require.Contains(t, pkg.Build.Migrations, internalv1alpha1.ScriptsToActionsMigrated) + require.Equal(t, "echo hello", pkg.Components[0].Actions.OnCreate.Before[0].Cmd) +} + +func TestParseAsErrors(t *testing.T) { + t.Parallel() + ctx := context.Background() + + _, err := ParseAs(ctx, []byte(""), V1Beta1) + require.ErrorContains(t, err, "no package definition found") + + // A definition without a matching document errors rather than falling back. + alphaOnly := "apiVersion: zarf.dev/v1alpha1\nkind: ZarfPackageConfig\nmetadata:\n name: alpha\n" + _, err = ParseAs(ctx, []byte(alphaOnly), V1Beta1) + require.ErrorContains(t, err, `no "zarf.dev/v1beta1" document found`) } -func TestHandlerFor(t *testing.T) { +func TestParseDecodesV1Beta1ToGeneric(t *testing.T) { t.Parallel() + ctx := context.Background() + + beta := "apiVersion: zarf.dev/v1beta1\nkind: ZarfPackageConfig\nmetadata:\n name: beta\ncomponents:\n - name: c\n" + // ParseMultiDoc prefers the higher-priority v1beta1 document when both are present. + mixed := beta + "---\napiVersion: zarf.dev/v1alpha1\nkind: ZarfPackageConfig\nmetadata:\n name: alpha\ncomponents:\n - name: c\n" + pkg, err := ParseMultiDoc(ctx, []byte(mixed)) + require.NoError(t, err) + require.Equal(t, v1beta1.APIVersion, pkg.APIVersion) + require.Equal(t, "beta", pkg.Metadata.Name) - // Empty apiVersion and explicit v1alpha1 must resolve to the same handler. - emptyHandler, emptyOK := handlerFor("") + // With only a v1beta1 document, ParseMultiDoc decodes it into the generic representation. + pkg, err = ParseMultiDoc(ctx, []byte(beta)) + require.NoError(t, err) + require.Equal(t, v1beta1.APIVersion, pkg.APIVersion) + require.Equal(t, "beta", pkg.Metadata.Name) +} + +func TestDecoderFor(t *testing.T) { + t.Parallel() + + // Empty apiVersion and explicit v1alpha1 must resolve to the same decoder. + emptyDecoder, emptyOK := decoderFor("") require.True(t, emptyOK) - v1Handler, v1OK := handlerFor(v1alpha1.APIVersion) + v1Decoder, v1OK := decoderFor(v1alpha1.APIVersion) require.True(t, v1OK) - require.Equal(t, v1Handler.version, emptyHandler.version) - require.Equal(t, v1Handler.priority, emptyHandler.priority) + require.Equal(t, v1Decoder.version, emptyDecoder.version) + require.Equal(t, v1Decoder.priority, emptyDecoder.priority) - _, unknownOK := handlerFor("zarf.dev/v1beta999") + _, unknownOK := decoderFor("zarf.dev/v1beta999") require.False(t, unknownOK) // Duplicate priorities would make "latest" ambiguous. priorities := map[int]string{} - for _, h := range knownAPIVersions { - if existing, dup := priorities[h.priority]; dup { - t.Fatalf("duplicate priority %d shared by %q and %q", h.priority, existing, h.version) + for _, d := range knownDecoders { + if existing, dup := priorities[d.priority]; dup { + t.Fatalf("duplicate priority %d shared by %q and %q", d.priority, existing, d.version) } - priorities[h.priority] = h.version - } -} - -func TestMigrateDeprecated(t *testing.T) { - t.Parallel() - - pkg := v1alpha1.ZarfPackage{ - Components: []v1alpha1.ZarfComponent{ - { - DeprecatedScripts: v1alpha1.DeprecatedZarfComponentScripts{ - Retry: true, - Prepare: []string{"p"}, - Before: []string{"b"}, - After: []string{"a"}, - }, - Actions: v1alpha1.ZarfComponentActions{ - OnCreate: v1alpha1.ZarfComponentActionSet{ - After: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "after", - }, - }, - Before: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "before", - }, - }, - OnSuccess: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-success", - }, - }, - OnFailure: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-failure", - }, - }, - }, - OnDeploy: v1alpha1.ZarfComponentActionSet{ - After: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "after", - }, - }, - Before: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "before", - }, - }, - OnSuccess: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-success", - }, - }, - OnFailure: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-failure", - }, - }, - }, - OnRemove: v1alpha1.ZarfComponentActionSet{ - After: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "after", - }, - }, - Before: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "before", - }, - }, - OnSuccess: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-success", - }, - }, - OnFailure: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-failure", - }, - }, - }, - }, - }, - }, - } - migratedPkg, _ := migrateDeprecated(pkg) - - expectedPkg := v1alpha1.ZarfPackage{ - Build: v1alpha1.ZarfBuildData{ - Migrations: []string{ - ScriptsToActionsMigrated, - PluralizeSetVariable, - }, - }, - Components: []v1alpha1.ZarfComponent{ - { - DeprecatedScripts: v1alpha1.DeprecatedZarfComponentScripts{ - Retry: true, - Prepare: []string{"p"}, - Before: []string{"b"}, - After: []string{"a"}, - }, - Actions: v1alpha1.ZarfComponentActions{ - OnCreate: v1alpha1.ZarfComponentActionSet{ - Defaults: v1alpha1.ZarfComponentActionDefaults{ - Mute: true, - MaxRetries: math.MaxInt, - }, - Before: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "before", - SetVariables: []v1alpha1.Variable{ - { - Name: "before", - }, - }, - }, - { - Cmd: "p", - }, - }, - After: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "after", - SetVariables: []v1alpha1.Variable{ - { - Name: "after", - }, - }, - }, - }, - OnSuccess: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-success", - SetVariables: []v1alpha1.Variable{ - { - Name: "on-success", - }, - }, - }, - }, - OnFailure: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-failure", - SetVariables: []v1alpha1.Variable{ - { - Name: "on-failure", - }, - }, - }, - }, - }, - OnDeploy: v1alpha1.ZarfComponentActionSet{ - Defaults: v1alpha1.ZarfComponentActionDefaults{ - Mute: true, - MaxRetries: math.MaxInt, - }, - Before: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "before", - SetVariables: []v1alpha1.Variable{ - { - Name: "before", - }, - }, - }, - { - Cmd: "b", - }, - }, - After: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "after", - SetVariables: []v1alpha1.Variable{ - { - Name: "after", - }, - }, - }, - { - Cmd: "a", - }, - }, - OnSuccess: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-success", - SetVariables: []v1alpha1.Variable{ - { - Name: "on-success", - }, - }, - }, - }, - OnFailure: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-failure", - SetVariables: []v1alpha1.Variable{ - { - Name: "on-failure", - }, - }, - }, - }, - }, - OnRemove: v1alpha1.ZarfComponentActionSet{ - Before: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "before", - SetVariables: []v1alpha1.Variable{ - { - Name: "before", - }, - }, - }, - }, - After: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "after", - SetVariables: []v1alpha1.Variable{ - { - Name: "after", - }, - }, - }, - }, - OnSuccess: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-success", - SetVariables: []v1alpha1.Variable{ - { - Name: "on-success", - }, - }, - }, - }, - OnFailure: []v1alpha1.ZarfComponentAction{ - { - DeprecatedSetVariable: "on-failure", - SetVariables: []v1alpha1.Variable{ - { - Name: "on-failure", - }, - }, - }, - }, - }, - }, - }, - }, + priorities[d.priority] = d.version } - require.Equal(t, expectedPkg, migratedPkg) } diff --git a/src/pkg/lint/schema.go b/src/pkg/lint/schema.go index 46aab3b000..0e05eee7a9 100644 --- a/src/pkg/lint/schema.go +++ b/src/pkg/lint/schema.go @@ -36,6 +36,23 @@ func ValidatePackageSchemaAtPath(path string, setVariables map[string]string) ([ return getSchemaFindings(jsonSchema, untypedZarfPackage) } +// ValidatePackageSchemaAtPathV1Beta1 checks a v1beta1 Zarf package against the v1beta1 schema. +// If path is a directory, it will look for layout.ZarfYAML within it. +// If path is a file, it will use that file directly. +func ValidatePackageSchemaAtPathV1Beta1(path string) ([]PackageFinding, error) { + var untypedZarfPackage interface{} + + pkgPath, err := layout.ResolvePackagePath(path) + if err != nil { + return nil, fmt.Errorf("unable to access path %q: %w", path, err) + } + + if err := utils.ReadYaml(pkgPath.ManifestFile, &untypedZarfPackage); err != nil { + return nil, err + } + return getSchemaFindings(schema.GetV1Beta1Schema(), untypedZarfPackage) +} + func makeFieldPathYqCompat(field string) string { if field == "(root)" { return field diff --git a/src/pkg/packager/create.go b/src/pkg/packager/create.go index add1d80fee..785a90498c 100644 --- a/src/pkg/packager/create.go +++ b/src/pkg/packager/create.go @@ -67,6 +67,10 @@ func Create(ctx context.Context, packagePath string, output string, opts CreateO if err != nil { return "", err } + pkg, err := defined.AsV1alpha1() + if err != nil { + return "", err + } pkgPath, err := layout.ResolvePackagePath(packagePath) if err != nil { @@ -76,7 +80,7 @@ func Create(ctx context.Context, packagePath string, output string, opts CreateO var differentialPkg v1alpha1.ZarfPackage if opts.DifferentialPackagePath != "" { pkgLayout, err := LoadPackage(ctx, opts.DifferentialPackagePath, LoadOptions{ - Architecture: defined.Pkg.Metadata.Architecture, + Architecture: pkg.Metadata.Architecture, RemoteOptions: opts.RemoteOptions, LayerTypes: []zoci.LayerType{zoci.MetadataLayers}, OCIConcurrency: opts.OCIConcurrency, @@ -103,7 +107,7 @@ func Create(ctx context.Context, packagePath string, output string, opts CreateO WithBuildMachineInfo: opts.WithBuildMachineInfo, RemoteOptions: opts.RemoteOptions, } - pkgLayout, err := layout.AssemblePackage(ctx, defined.Pkg, pkgPath.BaseDir, defined.ImportedSchemas, assembleOpt) + pkgLayout, err := layout.AssemblePackage(ctx, pkg, pkgPath.BaseDir, defined.ImportedSchemas, assembleOpt) if err != nil { return "", err } diff --git a/src/pkg/packager/dev.go b/src/pkg/packager/dev.go index 3c02e1fb64..b1d3ec095d 100644 --- a/src/pkg/packager/dev.go +++ b/src/pkg/packager/dev.go @@ -82,22 +82,26 @@ func DevDeploy(ctx context.Context, packagePath string, opts DevDeployOptions) ( if err != nil { return err } + pkg, err := defined.AsV1alpha1() + if err != nil { + return err + } filter := filters.Combine( filters.ByLocalOS(runtime.GOOS), filters.ForDeploy(opts.OptionalComponents, false), ) - defined.Pkg.Components, err = filter.Apply(defined.Pkg) + pkg.Components, err = filter.Apply(pkg) if err != nil { return err } // If not building for airgap, strip out all images and repos if !opts.AirgapMode { - for idx := range defined.Pkg.Components { - defined.Pkg.Components[idx].Images = []string{} - defined.Pkg.Components[idx].ImageArchives = []v1alpha1.ImageArchive{} - defined.Pkg.Components[idx].Repos = []string{} + for idx := range pkg.Components { + pkg.Components[idx].Images = []string{} + pkg.Components[idx].ImageArchives = []v1alpha1.ImageArchive{} + pkg.Components[idx].Repos = []string{} } } @@ -108,7 +112,7 @@ func DevDeploy(ctx context.Context, packagePath string, opts DevDeployOptions) ( OCIConcurrency: opts.OCIConcurrency, CachePath: opts.CachePath, } - pkgLayout, err := layout.AssemblePackage(ctx, defined.Pkg, packagePath, defined.ImportedSchemas, createOpts) + pkgLayout, err := layout.AssemblePackage(ctx, pkg, packagePath, defined.ImportedSchemas, createOpts) if err != nil { return err } diff --git a/src/pkg/packager/find_images.go b/src/pkg/packager/find_images.go index 02ef7f8a72..c6e6c6a90e 100644 --- a/src/pkg/packager/find_images.go +++ b/src/pkg/packager/find_images.go @@ -113,12 +113,16 @@ func FindDefinitionImages(ctx context.Context, packagePath string, opts FindImag if err != nil { return nil, err } - imageScans, err := findImages(ctx, defined.Pkg, packagePath, opts) + pkg, err := defined.AsV1alpha1() + if err != nil { + return nil, err + } + imageScans, err := findImages(ctx, pkg, packagePath, opts) if err != nil { return nil, err } - return filterImagesFoundInArchives(ctx, defined.Pkg, packagePath, imageScans) + return filterImagesFoundInArchives(ctx, pkg, packagePath, imageScans) } // FindImages iterates over the manifests and charts within each component to find any container images @@ -141,8 +145,12 @@ func FindImages(ctx context.Context, packagePath string, opts FindImagesOptions) if err != nil { return nil, err } + pkg, err := defined.AsV1alpha1() + if err != nil { + return nil, err + } - return findImages(ctx, defined.Pkg, packagePath, opts) + return findImages(ctx, pkg, packagePath, opts) } // filterImagesFoundInArchives merges scan results with each component's imageArchives. diff --git a/src/pkg/packager/inspect.go b/src/pkg/packager/inspect.go index 735edd929c..3ac29a832f 100644 --- a/src/pkg/packager/inspect.go +++ b/src/pkg/packager/inspect.go @@ -295,7 +295,10 @@ func InspectDefinitionResources(ctx context.Context, packagePath string, opts In if err != nil { return nil, err } - pkg := defined.Pkg + pkg, err := defined.AsV1alpha1() + if err != nil { + return nil, err + } variableConfig, err := getPopulatedVariableConfig(ctx, pkg, opts.DeploySetVariables, opts.IsInteractive) if err != nil { return nil, err diff --git a/src/pkg/packager/layout/layout_test.go b/src/pkg/packager/layout/layout_test.go index b9da5d1a69..fa077fc704 100644 --- a/src/pkg/packager/layout/layout_test.go +++ b/src/pkg/packager/layout/layout_test.go @@ -25,9 +25,11 @@ func TestAssembleSkeleton(t *testing.T) { defined, err := load.PackageDefinition(ctx, "./testdata/zarf-skeleton-package", load.DefinitionOptions{}) require.NoError(t, err) + pkg, err := defined.AsV1alpha1() + require.NoError(t, err) opt := layout.AssembleSkeletonOptions{} - pkgLayout, err := layout.AssembleSkeleton(ctx, defined.Pkg, "./testdata/zarf-skeleton-package", defined.ImportedSchemas, opt) + pkgLayout, err := layout.AssembleSkeleton(ctx, pkg, "./testdata/zarf-skeleton-package", defined.ImportedSchemas, opt) require.NoError(t, err) b, err := os.ReadFile(filepath.Join(pkgLayout.DirPath(), "checksums.txt")) @@ -74,8 +76,10 @@ func TestGetSBOM(t *testing.T) { writePackageToDisk(t, pkg, tmpdir) defined, err := load.PackageDefinition(ctx, tmpdir, load.DefinitionOptions{}) require.NoError(t, err) + loadedPkg, err := defined.AsV1alpha1() + require.NoError(t, err) - pkgLayout, err := layout.AssemblePackage(ctx, defined.Pkg, tmpdir, nil, layout.AssembleOptions{}) + pkgLayout, err := layout.AssemblePackage(ctx, loadedPkg, tmpdir, nil, layout.AssembleOptions{}) require.NoError(t, err) // Ensure the SBOM does not exist @@ -165,13 +169,15 @@ func TestCreateAbsoluteSources(t *testing.T) { defined, err := load.PackageDefinition(ctx, tmpdir, load.DefinitionOptions{}) require.NoError(t, err) + loadedPkg, err := defined.AsV1alpha1() + require.NoError(t, err) var pkgLayout *layout.PackageLayout if tt.isSkeleton { - pkgLayout, err = layout.AssembleSkeleton(ctx, defined.Pkg, tmpdir, defined.ImportedSchemas, layout.AssembleSkeletonOptions{}) + pkgLayout, err = layout.AssembleSkeleton(ctx, loadedPkg, tmpdir, defined.ImportedSchemas, layout.AssembleSkeletonOptions{}) require.NoError(t, err) } else { - pkgLayout, err = layout.AssemblePackage(ctx, defined.Pkg, tmpdir, nil, layout.AssembleOptions{SkipSBOM: true}) + pkgLayout, err = layout.AssemblePackage(ctx, loadedPkg, tmpdir, nil, layout.AssembleOptions{SkipSBOM: true}) require.NoError(t, err) } docsDir := filepath.Join(tmpdir, "docs-dir") @@ -251,8 +257,10 @@ func TestCreateAbsolutePathImports(t *testing.T) { writePackageToDisk(t, childPkg, childDir) defined, err := load.PackageDefinition(ctx, tmpdir, load.DefinitionOptions{}) require.NoError(t, err) + loadedPkg, err := defined.AsV1alpha1() + require.NoError(t, err) // create the package - pkgLayout, err := layout.AssemblePackage(context.Background(), defined.Pkg, tmpdir, nil, layout.AssembleOptions{}) + pkgLayout, err := layout.AssemblePackage(context.Background(), loadedPkg, tmpdir, nil, layout.AssembleOptions{}) require.NoError(t, err) // Ensure the component has the correct file diff --git a/src/pkg/packager/layout/oci.go b/src/pkg/packager/layout/oci.go index d786e4e3d9..a8f46499f7 100644 --- a/src/pkg/packager/layout/oci.go +++ b/src/pkg/packager/layout/oci.go @@ -20,6 +20,7 @@ import ( "github.com/defenseunicorns/pkg/helpers/v2" godigest "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/zarf-dev/zarf/src/api/convert" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/internal/pkgcfg" "oras.land/oras-go/v2" @@ -177,10 +178,11 @@ func (p *PackageLayout) computeManifest(ctx context.Context) error { if err != nil { return fmt.Errorf("reading %s for manifest: %w", ZarfYAML, err) } - zarfPkg, err := pkgcfg.ParseMultiDoc(ctx, zarfYAMLBytes) + generic, err := pkgcfg.ParseMultiDoc(ctx, zarfYAMLBytes) if err != nil { return fmt.Errorf("parsing %s for manifest: %w", ZarfYAML, err) } + zarfPkg := convert.GenericToCanonicalAPIVersion(generic) configBytes, err := json.Marshal(zarfPkg) if err != nil { return err diff --git a/src/pkg/packager/layout/oci_test.go b/src/pkg/packager/layout/oci_test.go index b327dfca37..41b1521cb9 100644 --- a/src/pkg/packager/layout/oci_test.go +++ b/src/pkg/packager/layout/oci_test.go @@ -81,7 +81,7 @@ func TestDigest(t *testing.T) { p, _ := newTestLayout(t) d := p.Digest() - assert.Equal(t, "sha256:25242bc565875477a9f691d8ce135b433bb014340a46b87113d977f0c08bd728", d, "digest should match expected precomputed digest") + assert.Equal(t, "sha256:28999b2812b62c2df92f9eb90e48b0a467ba3f0aeeaae10702e438387aa12bd3", d, "digest should match expected precomputed digest") } func TestTotalSize(t *testing.T) { diff --git a/src/pkg/packager/layout/package.go b/src/pkg/packager/layout/package.go index 0e14e4479b..ae6f5091cf 100644 --- a/src/pkg/packager/layout/package.go +++ b/src/pkg/packager/layout/package.go @@ -17,6 +17,7 @@ import ( "github.com/defenseunicorns/pkg/helpers/v2" goyaml "github.com/goccy/go-yaml" + "github.com/zarf-dev/zarf/src/api/convert" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/internal/pkgcfg" @@ -110,10 +111,11 @@ func LoadFromDir(ctx context.Context, dirPath string, opts PackageLayoutOptions) if err != nil { return nil, err } - pkg, err := pkgcfg.ParseMultiDoc(ctx, b) + generic, err := pkgcfg.ParseMultiDoc(ctx, b) if err != nil { return nil, err } + pkg := convert.GenericToCanonicalAPIVersion(generic) pkg.Components, err = opts.Filter.Apply(pkg) if err != nil { return nil, err diff --git a/src/pkg/packager/lint.go b/src/pkg/packager/lint.go index 739553b1a1..b8d48df6f8 100644 --- a/src/pkg/packager/lint.go +++ b/src/pkg/packager/lint.go @@ -45,15 +45,19 @@ func Lint(ctx context.Context, packagePath string, opts LintOptions) error { if err != nil { return err } + pkg, err := defined.AsV1alpha1() + if err != nil { + return err + } findings := []lint.PackageFinding{} - for i, component := range defined.Pkg.Components { + for i, component := range pkg.Components { findings = append(findings, lint.CheckComponentValues(component, i)...) } if len(findings) == 0 { return nil } return &lint.LintError{ - PackageName: defined.Pkg.Metadata.Name, + PackageName: pkg.Metadata.Name, Findings: findings, } } diff --git a/src/pkg/packager/load/import.go b/src/pkg/packager/load/import.go index 2adc61a560..d478b1b056 100644 --- a/src/pkg/packager/load/import.go +++ b/src/pkg/packager/load/import.go @@ -101,7 +101,7 @@ func resolveImports(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath, if err != nil { return v1alpha1.ZarfPackage{}, nil, err } - importedPkg, err = pkgcfg.Parse(ctx, b) + importedPkg, err = pkgcfg.ParseAs(ctx, b, pkgcfg.V1Alpha1) if err != nil { return v1alpha1.ZarfPackage{}, nil, err } diff --git a/src/pkg/packager/load/import_test.go b/src/pkg/packager/load/import_test.go index b0df766a6e..431cc6b564 100644 --- a/src/pkg/packager/load/import_test.go +++ b/src/pkg/packager/load/import_test.go @@ -26,7 +26,7 @@ func TestResolveImportsCircular(t *testing.T) { b, err := os.ReadFile(filepath.Join("./testdata/import/circular/first", layout.ZarfYAML)) require.NoError(t, err) - pkg, err := pkgcfg.Parse(ctx, b) + pkg, err := pkgcfg.ParseAs(ctx, b, pkgcfg.V1Alpha1) require.NoError(t, err) _, _, err = resolveImports(ctx, pkg, "./testdata/import/circular/first", "", "", []string{}, "", false, types.RemoteOptions{}) @@ -112,7 +112,7 @@ func TestResolveImports(t *testing.T) { b, err := os.ReadFile(filepath.Join(tc.path, layout.ZarfYAML)) require.NoError(t, err) - pkg, err := pkgcfg.Parse(ctx, b) + pkg, err := pkgcfg.ParseAs(ctx, b, pkgcfg.V1Alpha1) require.NoError(t, err) resolvedPkg, _, err := resolveImports(ctx, pkg, tc.path, "", tc.flavor, []string{}, "", false, types.RemoteOptions{}) @@ -120,7 +120,7 @@ func TestResolveImports(t *testing.T) { b, err = os.ReadFile(filepath.Join(tc.path, "expected.yaml")) require.NoError(t, err) - expectedPkg, err := pkgcfg.Parse(ctx, b) + expectedPkg, err := pkgcfg.ParseAs(ctx, b, pkgcfg.V1Alpha1) require.NoError(t, err) require.Equal(t, expectedPkg, resolvedPkg) @@ -242,7 +242,7 @@ func TestResolveImportsValueMerge(t *testing.T) { b, err := os.ReadFile(filepath.Join(tc.path, layout.ZarfYAML)) require.NoError(t, err) - pkg, err := pkgcfg.Parse(ctx, b) + pkg, err := pkgcfg.ParseAs(ctx, b, pkgcfg.V1Alpha1) require.NoError(t, err) resolved, _, err := resolveImports(ctx, pkg, tc.path, "", "", []string{}, "", false, types.RemoteOptions{}) @@ -300,7 +300,7 @@ func TestResolveImportsSchemaCollection(t *testing.T) { b, err := os.ReadFile(filepath.Join(tc.path, layout.ZarfYAML)) require.NoError(t, err) - pkg, err := pkgcfg.Parse(ctx, b) + pkg, err := pkgcfg.ParseAs(ctx, b, pkgcfg.V1Alpha1) require.NoError(t, err) resolved, importedSchemas, err := resolveImports(ctx, pkg, tc.path, "", "", []string{}, "", false, types.RemoteOptions{}) diff --git a/src/pkg/packager/load/import_v1beta1.go b/src/pkg/packager/load/import_v1beta1.go new file mode 100644 index 0000000000..b145dfb3c0 --- /dev/null +++ b/src/pkg/packager/load/import_v1beta1.go @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package load + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + goyaml "github.com/goccy/go-yaml" + + "github.com/zarf-dev/zarf/src/api/v1beta1" + "github.com/zarf-dev/zarf/src/pkg/logger" + "github.com/zarf-dev/zarf/src/pkg/packager/layout" +) + +// importedValues collects the values files and schemas declared by imported component configs +// so they can be merged into the package definition once all imports are resolved. +type importedValues struct { + files []string + schemas []string +} + +// resolveImportsV1Beta1 resolves local component config imports into a v1beta1 package definition. +// Each package component may import one or more ZarfComponentConfig files; filtering compatible components also happens here +func resolveImportsV1Beta1(ctx context.Context, pkg v1beta1.Package, pkgPath layout.PackagePath, arch, flavor string) (v1beta1.Package, []string, error) { + l := logger.From(ctx) + start := time.Now() + l.Debug("start resolveImportsV1Beta1", "pkg", pkg.Metadata.Name, "arch", arch, "flavor", flavor) + + baseDir := pkgPath.BaseDir + + var components []v1beta1.Component + var vals importedValues + for _, component := range pkg.Components { + if !compatibleComponentV1Beta1(component.Selector, arch, flavor) { + continue + } + mergedSpec, compVals, err := resolveComponentSpecImports(ctx, component.ComponentSpec, baseDir, arch, flavor, []string{filepath.Clean(pkgPath.ManifestFile)}) + if err != nil { + return v1beta1.Package{}, nil, fmt.Errorf("component %q: %w", component.Name, err) + } + component.ComponentSpec = mergedSpec + components = append(components, component) + vals.files = append(vals.files, compVals.files...) + vals.schemas = append(vals.schemas, compVals.schemas...) + } + pkg.Components = components + + // Imported value files come first so the package's own files take precedence (later files win). + valuesFiles := append(vals.files, pkg.Values.Files...) + pkg.Values.Files = dedupePaths(valuesFiles) + + l.Debug("done resolveImportsV1Beta1", "pkg", pkg.Metadata.Name, "components", len(pkg.Components), "duration", time.Since(start)) + return pkg, dedupePaths(vals.schemas), nil +} + +// resolveComponentSpecImports merges any imported component configs into spec. spec is the override +// (head); the selected imported config is the base. Returned paths are relative to specDir. +func resolveComponentSpecImports(ctx context.Context, spec v1beta1.ComponentSpec, specDir, arch, flavor string, importStack []string) (v1beta1.ComponentSpec, importedValues, error) { + if err := validateComponentImportV1Beta1(spec.Import); err != nil { + return v1beta1.ComponentSpec{}, importedValues{}, err + } + if len(spec.Import.Local) == 0 { + return spec, importedValues{}, nil + } + + selected, err := selectImportVariant(spec.Import.Local, specDir, arch, flavor, importStack) + if err != nil { + return v1beta1.ComponentSpec{}, importedValues{}, err + } + + // Recurse into the selected config's own imports, then rebase its resolved spec to specDir. + baseSpec, baseVals, err := resolveComponentSpecImports(ctx, selected.config.Component, selected.dir, arch, flavor, append(importStack, selected.path)) + if err != nil { + return v1beta1.ComponentSpec{}, importedValues{}, err + } + + relDir := filepath.Dir(selected.entry.Path) + baseSpec = fixPathsV1Beta1(baseSpec, relDir) + + vals := importedValues{} + for _, f := range selected.config.Values.Files { + vals.files = append(vals.files, makePathRelativeTo(f, relDir)) + } + if selected.config.Values.Schema != "" { + vals.schemas = append(vals.schemas, makePathRelativeTo(selected.config.Values.Schema, relDir)) + } + for _, f := range baseVals.files { + vals.files = append(vals.files, makePathRelativeTo(f, relDir)) + } + for _, s := range baseVals.schemas { + vals.schemas = append(vals.schemas, makePathRelativeTo(s, relDir)) + } + + merged := mergeComponentSpec(baseSpec, spec) + merged.Import = v1beta1.ComponentImport{} + return merged, vals, nil +} + +// loadedComponentConfig pairs a parsed component config with where it was read from. +type loadedComponentConfig struct { + config v1beta1.ComponentConfig + entry v1beta1.ComponentImportLocal + dir string + path string +} + +// selectImportVariant loads every local import entry and selects the single one compatible with the +// active target. A single entry is always selected. When more than one entry is given they are treated +// as variants: exactly one must be compatible with the target. +func selectImportVariant(entries []v1beta1.ComponentImportLocal, specDir, arch, flavor string, importStack []string) (loadedComponentConfig, error) { + var loaded []loadedComponentConfig + for _, entry := range entries { + path := filepath.Clean(filepath.Join(specDir, entry.Path)) + for _, seen := range importStack { + if seen == path { + return loadedComponentConfig{}, fmt.Errorf("component config %s imported in cycle", filepath.ToSlash(path)) + } + } + config, err := readComponentConfig(path) + if err != nil { + return loadedComponentConfig{}, err + } + loaded = append(loaded, loadedComponentConfig{config: config, entry: entry, dir: filepath.Dir(path), path: path}) + } + + if len(loaded) == 1 { + return loaded[0], nil + } + + var compatible []loadedComponentConfig + for _, lc := range loaded { + if compatibleComponentV1Beta1(lc.config.Component.Selector, arch, flavor) { + compatible = append(compatible, lc) + } + } + switch len(compatible) { + case 0: + return loadedComponentConfig{}, fmt.Errorf("no imported component variant is compatible with the package target") + case 1: + return compatible[0], nil + default: + return loadedComponentConfig{}, fmt.Errorf("multiple imported component variants are compatible with the package target") + } +} + +// readComponentConfig reads a ZarfComponentConfig file directly. v1beta1 packages only ever import +// v1beta1 component configs, so the bytes are decoded into the native type without conversion. +func readComponentConfig(path string) (v1beta1.ComponentConfig, error) { + info, err := os.Stat(path) + if err != nil { + return v1beta1.ComponentConfig{}, fmt.Errorf("unable to access imported component config %q: %w", path, err) + } + if info.IsDir() { + return v1beta1.ComponentConfig{}, fmt.Errorf("import path %q is a directory; v1beta1 imports must reference a component config file", path) + } + b, err := os.ReadFile(path) + if err != nil { + return v1beta1.ComponentConfig{}, err + } + var config v1beta1.ComponentConfig + if err := goyaml.Unmarshal(b, &config); err != nil { + return v1beta1.ComponentConfig{}, fmt.Errorf("unable to parse imported component config %q: %w", path, err) + } + if config.Kind != "" && config.Kind != v1beta1.ZarfComponentConfig { + return v1beta1.ComponentConfig{}, fmt.Errorf("imported file %q is not a %s", path, v1beta1.ZarfComponentConfig) + } + return config, nil +} + +func validateComponentImportV1Beta1(imp v1beta1.ComponentImport) error { + if len(imp.Remote) > 0 { + return fmt.Errorf("remote component imports are not yet supported for v1beta1 packages") + } + for _, l := range imp.Local { + if l.Path == "" { + return fmt.Errorf("import entry is missing a path") + } + if filepath.IsAbs(l.Path) { + return fmt.Errorf("import path %q cannot be absolute", l.Path) + } + } + return nil +} + +// compatibleComponentV1Beta1 reports whether a component target matches the active architecture and flavor. +// OS targeting is a deploy-time filter and is not evaluated here. +func compatibleComponentV1Beta1(selector v1beta1.ComponentSelector, arch, flavor string) bool { + satisfiesArch := selector.Architecture == "" || selector.Architecture == arch + satisfiesFlavor := selector.Flavor == "" || selector.Flavor == flavor + return satisfiesArch && satisfiesFlavor +} + +func dedupePaths(paths []string) []string { + seen := map[string]bool{} + var out []string + for _, p := range paths { + norm := makePathRelativeTo(p, ".") + if seen[norm] { + continue + } + seen[norm] = true + out = append(out, norm) + } + return out +} diff --git a/src/pkg/packager/load/import_v1beta1_merge.go b/src/pkg/packager/load/import_v1beta1_merge.go new file mode 100644 index 0000000000..1fcd15bd83 --- /dev/null +++ b/src/pkg/packager/load/import_v1beta1_merge.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package load + +import "github.com/zarf-dev/zarf/src/api/v1beta1" + +// mergeComponentSpec overlays the head spec onto the base spec. The base is the imported component +// config; the head is the importing package component, which is authoritative on conflicts. +func mergeComponentSpec(base, head v1beta1.ComponentSpec) v1beta1.ComponentSpec { + merged := base + + if head.Target.OS != "" { + merged.Target.OS = head.Target.OS + } + if head.Selector.Architecture != "" { + merged.Selector.Architecture = head.Selector.Architecture + } + if head.Selector.Flavor != "" { + merged.Selector.Flavor = head.Selector.Flavor + } + if head.Service != "" { + merged.Service = head.Service + } + + merged.Files = append(merged.Files, head.Files...) + merged.ImageArchives = append(merged.ImageArchives, head.ImageArchives...) + merged.Repositories = append(merged.Repositories, head.Repositories...) + merged.StateAccess = append(merged.StateAccess, head.StateAccess...) + + merged.Images = mergeImages(merged.Images, head.Images) + merged.Charts = mergeCharts(merged.Charts, head.Charts) + merged.Manifests = mergeManifests(merged.Manifests, head.Manifests) + merged.Actions = mergeActions(merged.Actions, head.Actions) + + return merged +} + +// mergeImages merges images by name. The head value of source (and future fields) wins when set. +func mergeImages(base, head []v1beta1.Image) []v1beta1.Image { + out := append([]v1beta1.Image{}, base...) + for _, h := range head { + idx := indexByName(len(out), func(i int) string { return out[i].Name }, h.Name) + if idx == -1 { + out = append(out, h) + continue + } + if h.Source != "" { + out[idx].Source = h.Source + } + } + return out +} + +func mergeCharts(base, head []v1beta1.Chart) []v1beta1.Chart { + out := append([]v1beta1.Chart{}, base...) + for _, h := range head { + idx := indexByName(len(out), func(i int) string { return out[i].Name }, h.Name) + if idx == -1 { + out = append(out, h) + continue + } + c := out[idx] + if h.Namespace != "" { + c.Namespace = h.Namespace + } + if h.ReleaseName != "" { + c.ReleaseName = h.ReleaseName + } + if h.HelmRepository != nil { + c.HelmRepository = h.HelmRepository + } + if h.Git != nil { + c.Git = h.Git + } + if h.Local != nil { + c.Local = h.Local + } + if h.OCI != nil { + c.OCI = h.OCI + } + if h.ServerSideApply != "" { + c.ServerSideApply = h.ServerSideApply + } + if h.SkipWait { + c.SkipWait = true + } + if h.SkipSchemaValidation { + c.SkipSchemaValidation = true + } + c.ValuesFiles = append(c.ValuesFiles, h.ValuesFiles...) + c.Values = append(c.Values, h.Values...) + out[idx] = c + } + return out +} + +func mergeManifests(base, head []v1beta1.Manifest) []v1beta1.Manifest { + out := append([]v1beta1.Manifest{}, base...) + for _, h := range head { + idx := indexByName(len(out), func(i int) string { return out[i].Name }, h.Name) + if idx == -1 { + out = append(out, h) + continue + } + m := out[idx] + if h.Namespace != "" { + m.Namespace = h.Namespace + } + m.Files = append(m.Files, h.Files...) + if h.Kustomize != nil { + if m.Kustomize == nil { + m.Kustomize = h.Kustomize + } else { + m.Kustomize.Files = append(m.Kustomize.Files, h.Kustomize.Files...) + } + } + if h.ServerSideApply != "" { + m.ServerSideApply = h.ServerSideApply + } + if h.SkipWait { + m.SkipWait = true + } + if h.EnableTemplating { + m.EnableTemplating = true + } + out[idx] = m + } + return out +} + +func mergeActions(base, head v1beta1.ComponentActions) v1beta1.ComponentActions { + return v1beta1.ComponentActions{ + OnCreate: mergeActionSet(base.OnCreate, head.OnCreate), + OnDeploy: mergeActionSet(base.OnDeploy, head.OnDeploy), + OnRemove: mergeActionSet(base.OnRemove, head.OnRemove), + } +} + +func mergeActionSet(base, head v1beta1.ComponentActionSet) v1beta1.ComponentActionSet { + base.Defaults = head.Defaults + base.Before = append(base.Before, head.Before...) + base.OnSuccess = append(base.OnSuccess, head.OnSuccess...) + base.OnFailure = append(base.OnFailure, head.OnFailure...) + return base +} + +func indexByName(n int, nameAt func(int) string, name string) int { + for i := 0; i < n; i++ { + if nameAt(i) == name { + return i + } + } + return -1 +} + +// fixPathsV1Beta1 rebases a component spec's relative resource paths to be relative to the head node, +// where relativeToHead is the imported config's directory relative to the importing component. +func fixPathsV1Beta1(spec v1beta1.ComponentSpec, relativeToHead string) v1beta1.ComponentSpec { + for i := range spec.Files { + spec.Files[i].Source = makePathRelativeTo(spec.Files[i].Source, relativeToHead) + } + for i := range spec.ImageArchives { + spec.ImageArchives[i].Path = makePathRelativeTo(spec.ImageArchives[i].Path, relativeToHead) + } + for i := range spec.Charts { + if spec.Charts[i].Local != nil { + spec.Charts[i].Local.Path = makePathRelativeTo(spec.Charts[i].Local.Path, relativeToHead) + } + for j := range spec.Charts[i].ValuesFiles { + spec.Charts[i].ValuesFiles[j].Path = makePathRelativeTo(spec.Charts[i].ValuesFiles[j].Path, relativeToHead) + } + } + for i := range spec.Manifests { + for j := range spec.Manifests[i].Files { + spec.Manifests[i].Files[j] = makePathRelativeTo(spec.Manifests[i].Files[j], relativeToHead) + } + if spec.Manifests[i].Kustomize != nil { + for j := range spec.Manifests[i].Kustomize.Files { + spec.Manifests[i].Kustomize.Files[j] = makePathRelativeTo(spec.Manifests[i].Kustomize.Files[j], relativeToHead) + } + } + } + + defaultDir := spec.Actions.OnCreate.Defaults.Dir + spec.Actions.OnCreate.Before = fixActionPathsV1Beta1(spec.Actions.OnCreate.Before, defaultDir, relativeToHead) + spec.Actions.OnCreate.OnSuccess = fixActionPathsV1Beta1(spec.Actions.OnCreate.OnSuccess, defaultDir, relativeToHead) + spec.Actions.OnCreate.OnFailure = fixActionPathsV1Beta1(spec.Actions.OnCreate.OnFailure, defaultDir, relativeToHead) + + return spec +} + +func fixActionPathsV1Beta1(actions []v1beta1.ComponentAction, defaultDir, relativeToHead string) []v1beta1.ComponentAction { + for i := range actions { + var composed string + if actions[i].Dir != nil { + composed = makePathRelativeTo(*actions[i].Dir, relativeToHead) + } else { + composed = makePathRelativeTo(defaultDir, relativeToHead) + } + actions[i].Dir = &composed + } + return actions +} diff --git a/src/pkg/packager/load/import_v1beta1_test.go b/src/pkg/packager/load/import_v1beta1_test.go new file mode 100644 index 0000000000..e3f05e73b5 --- /dev/null +++ b/src/pkg/packager/load/import_v1beta1_test.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package load + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zarf-dev/zarf/src/api/v1beta1" + "github.com/zarf-dev/zarf/src/internal/pkgcfg" + "github.com/zarf-dev/zarf/src/pkg/packager/layout" + "github.com/zarf-dev/zarf/src/test/testutil" +) + +func mustPackagePath(t *testing.T, dir string) layout.PackagePath { + t.Helper() + pkgPath, err := layout.ResolvePackagePath(filepath.Join(dir, layout.ZarfYAML)) + require.NoError(t, err) + return pkgPath +} + +func loadV1Beta1Package(t *testing.T, dir string) v1beta1.Package { + t.Helper() + ctx := testutil.TestContext(t) + b, err := os.ReadFile(filepath.Join(dir, layout.ZarfYAML)) + require.NoError(t, err) + pkg, err := pkgcfg.ParseAs(ctx, b, pkgcfg.V1Beta1) + require.NoError(t, err) + return pkg +} + +func TestResolveImportsV1Beta1(t *testing.T) { + t.Parallel() + ctx := testutil.TestContext(t) + + t.Run("single local import rebases paths and collects values", func(t *testing.T) { + t.Parallel() + dir := filepath.Join("testdata", "import-v1beta1", "single") + pkg := loadV1Beta1Package(t, dir) + + resolved, schemas, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.NoError(t, err) + + require.Len(t, resolved.Components, 1) + comp := resolved.Components[0] + require.Equal(t, "logging", comp.Name) + require.Empty(t, comp.Import.Local) + + require.Len(t, comp.Charts, 1) + require.NotNil(t, comp.Charts[0].Local) + require.Equal(t, "components/loki-chart", comp.Charts[0].Local.Path) + require.Equal(t, []v1beta1.ValuesFile{{Path: "components/loki-values.yaml"}}, comp.Charts[0].ValuesFiles) + + require.Len(t, comp.Files, 1) + require.Equal(t, "components/motd.txt", comp.Files[0].Source) + + require.Equal(t, []v1beta1.Image{{Name: "grafana/loki:2.9.0"}}, comp.Images) + + require.Equal(t, []string{"components/logging-values.yaml"}, resolved.Values.Files) + require.Equal(t, []string{"components/logging.schema.json"}, schemas) + }) + + t.Run("non-importing components are preserved alongside an importing one", func(t *testing.T) { + t.Parallel() + dir := filepath.Join("testdata", "import-v1beta1", "mixed") + pkg := loadV1Beta1Package(t, dir) + + resolved, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.NoError(t, err) + + require.Len(t, resolved.Components, 3) + require.Equal(t, "first", resolved.Components[0].Name) + require.Equal(t, []v1beta1.Image{{Name: "alpine:3.20"}}, resolved.Components[0].Images) + require.Equal(t, "middle", resolved.Components[1].Name) + require.Equal(t, []v1beta1.Image{{Name: "nginx:1.27"}}, resolved.Components[1].Images) + require.Empty(t, resolved.Components[1].Import.Local) + require.Equal(t, "last", resolved.Components[2].Name) + require.Equal(t, []v1beta1.Image{{Name: "busybox:1.36"}}, resolved.Components[2].Images) + }) + + t.Run("nested imports merge and rebase transitively", func(t *testing.T) { + t.Parallel() + dir := filepath.Join("testdata", "import-v1beta1", "nested") + pkg := loadV1Beta1Package(t, dir) + + resolved, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.NoError(t, err) + + require.Len(t, resolved.Components, 1) + comp := resolved.Components[0] + require.Equal(t, "app", comp.Name) + + require.Len(t, comp.Charts, 1) + require.NotNil(t, comp.Charts[0].Local) + require.Equal(t, "components/app-chart", comp.Charts[0].Local.Path) + + require.Len(t, comp.Files, 1) + require.Equal(t, "components/base/base.txt", comp.Files[0].Source) + }) + + t.Run("cyclic imports error", func(t *testing.T) { + t.Parallel() + dir := filepath.Join("testdata", "import-v1beta1", "cycle") + pkg := loadV1Beta1Package(t, dir) + + _, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.ErrorContains(t, err, "cycle") + }) + + t.Run("variant selection picks the compatible flavor", func(t *testing.T) { + t.Parallel() + dir := filepath.Join("testdata", "import-v1beta1", "variants") + pkg := loadV1Beta1Package(t, dir) + + resolved, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "apache") + require.NoError(t, err) + + require.Len(t, resolved.Components, 1) + require.Equal(t, []v1beta1.Image{{Name: "httpd:2.4"}}, resolved.Components[0].Images) + }) + + t.Run("variant selection errors when no variant is compatible", func(t *testing.T) { + t.Parallel() + dir := filepath.Join("testdata", "import-v1beta1", "variants") + pkg := loadV1Beta1Package(t, dir) + + _, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.ErrorContains(t, err, "no imported component") + }) + + t.Run("package component overrides imported component", func(t *testing.T) { + t.Parallel() + dir := filepath.Join("testdata", "import-v1beta1", "merge") + pkg := loadV1Beta1Package(t, dir) + + resolved, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.NoError(t, err) + + comp := resolved.Components[0] + require.Equal(t, []v1beta1.Image{ + {Name: "redis:7", Source: "daemon"}, + {Name: "nginx:1.27"}, + }, comp.Images) + + require.Len(t, comp.Charts, 1) + require.Equal(t, "app", comp.Charts[0].Name) + require.Equal(t, "app", comp.Charts[0].Namespace) + require.Equal(t, "custom-release", comp.Charts[0].ReleaseName) + require.NotNil(t, comp.Charts[0].Local) + require.Equal(t, "components/app-chart", comp.Charts[0].Local.Path) + }) +} + +func TestResolveImportsV1Beta1Errors(t *testing.T) { + t.Parallel() + ctx := testutil.TestContext(t) + + writePkg := func(t *testing.T, dir, body string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, layout.ZarfYAML), []byte(body), 0o600)) + } + + t.Run("remote imports are not yet supported", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writePkg(t, dir, `apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: remote +components: + - name: remote + import: + remote: + - url: oci://example.com/component:1.0.0 +`) + pkg := loadV1Beta1Package(t, dir) + _, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.ErrorContains(t, err, "remote") + }) + + t.Run("missing import file errors", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + writePkg(t, dir, `apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: missing +components: + - name: missing + import: + local: + - path: does-not-exist.yaml +`) + pkg := loadV1Beta1Package(t, dir) + _, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.ErrorContains(t, err, "does-not-exist.yaml") + }) + + t.Run("directory import path errors", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "child"), 0o700)) + writePkg(t, dir, `apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: dir +components: + - name: dir + import: + local: + - path: child +`) + pkg := loadV1Beta1Package(t, dir) + _, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.Error(t, err) + }) + + t.Run("multiple compatible variants error", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.yaml"), []byte(`apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: web +component: {} +`), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.yaml"), []byte(`apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: web +component: {} +`), 0o600)) + writePkg(t, dir, `apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: ambiguous +components: + - name: web + import: + local: + - path: a.yaml + - path: b.yaml +`) + pkg := loadV1Beta1Package(t, dir) + _, _, err := resolveImportsV1Beta1(ctx, pkg, mustPackagePath(t, dir), "amd64", "") + require.ErrorContains(t, err, "multiple") + }) +} diff --git a/src/pkg/packager/load/load.go b/src/pkg/packager/load/load.go index 93d3fb806e..4ae1897007 100644 --- a/src/pkg/packager/load/load.go +++ b/src/pkg/packager/load/load.go @@ -11,10 +11,14 @@ import ( "path/filepath" "time" + "github.com/zarf-dev/zarf/src/api" "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/api/v1beta1" "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/config/lang" + internalTypes "github.com/zarf-dev/zarf/src/internal/api/types" internalv1alpha1 "github.com/zarf-dev/zarf/src/internal/api/v1alpha1" + internalv1beta1 "github.com/zarf-dev/zarf/src/internal/api/v1beta1" "github.com/zarf-dev/zarf/src/internal/pkgcfg" "github.com/zarf-dev/zarf/src/pkg/feature" "github.com/zarf-dev/zarf/src/pkg/interactive" @@ -48,10 +52,28 @@ type DefinitionOptions struct { // ImportedSchemas is transient assembly state — child schema paths collected during // import resolution that must be passed to AssemblePackage for merging. type DefinedPackage struct { - Pkg v1alpha1.ZarfPackage + // FIXME: we might want to put a loaded package here that adheres to the package accessor interface + pkg internalTypes.Package ImportedSchemas []string } +var _ api.PackageAccessor = DefinedPackage{} + +// AsV1alpha1 returns the package definition as a v1alpha1 ZarfPackage. +func (d DefinedPackage) AsV1alpha1() (v1alpha1.ZarfPackage, error) { + return internalv1alpha1.ConvertFromGeneric(d.pkg), nil +} + +// AsV1beta1 returns the package definition as a v1beta1 Package. +func (d DefinedPackage) AsV1beta1() (v1beta1.Package, error) { + return internalv1beta1.ConvertFromGeneric(d.pkg), nil +} + +// OriginalAPIVersion returns the apiVersion the package was authored in before any conversion. +func (d DefinedPackage) OriginalAPIVersion() string { + return d.pkg.Build.OriginalAPIVersion +} + // PackageDefinition returns a validated package definition after flavors, imports, variables, and values are applied. func PackageDefinition(ctx context.Context, packagePath string, opts DefinitionOptions) (DefinedPackage, error) { l := logger.From(ctx) @@ -71,11 +93,43 @@ func PackageDefinition(ctx context.Context, packagePath string, opts DefinitionO if err != nil { return DefinedPackage{}, err } - pkg, err := pkgcfg.Parse(ctx, b) + + version, err := pkgcfg.SelectVersion(ctx, b) if err != nil { return DefinedPackage{}, err } + + var defined DefinedPackage + switch version { + case v1beta1.APIVersion: + pkg, err := pkgcfg.ParseAs(ctx, b, pkgcfg.V1Beta1) + if err != nil { + return DefinedPackage{}, err + } + defined, err = v1beta1PackageDefinition(ctx, pkg, pkgPath, opts) + if err != nil { + return DefinedPackage{}, err + } + case v1alpha1.APIVersion: + pkg, err := pkgcfg.ParseAs(ctx, b, pkgcfg.V1Alpha1) + if err != nil { + return DefinedPackage{}, err + } + defined, err = v1alpha1PackageDefinition(ctx, pkg, pkgPath, opts) + if err != nil { + return DefinedPackage{}, err + } + default: + return DefinedPackage{}, fmt.Errorf("unrecognized API version") + } + + l.Debug("done layout.LoadPackage", "duration", time.Since(start)) + return defined, nil +} + +func v1alpha1PackageDefinition(ctx context.Context, pkg v1alpha1.ZarfPackage, pkgPath layout.PackagePath, opts DefinitionOptions) (DefinedPackage, error) { pkg.Metadata.Architecture = config.GetArch(pkg.Metadata.Architecture) + var err error opts.CachePath, err = utils.ResolveCachePath(opts.CachePath) if err != nil { return DefinedPackage{}, err @@ -97,12 +151,25 @@ func PackageDefinition(ctx context.Context, packagePath string, opts DefinitionO return DefinedPackage{}, err } } - err = validate(ctx, pkg, pkgPath.ManifestFile, opts.SetVariables, opts.Flavor, opts.SkipRequiredValues, opts.SkipValuesSchemaValidation) + if err := validate(ctx, pkg, pkgPath.ManifestFile, opts.SetVariables, opts.Flavor, opts.SkipRequiredValues, opts.SkipValuesSchemaValidation); err != nil { + return DefinedPackage{}, err + } + return DefinedPackage{pkg: internalv1alpha1.ConvertToGeneric(pkg), ImportedSchemas: importedSchemas}, nil +} + +func v1beta1PackageDefinition(ctx context.Context, pkg v1beta1.Package, pkgPath layout.PackagePath, opts DefinitionOptions) (DefinedPackage, error) { + pkg.Metadata.Architecture = config.GetArch(pkg.Metadata.Architecture) + + pkg, importedSchemas, err := resolveImportsV1Beta1(ctx, pkg, pkgPath, pkg.Metadata.Architecture, opts.Flavor) if err != nil { return DefinedPackage{}, err } - l.Debug("done layout.LoadPackage", "duration", time.Since(start)) - return DefinedPackage{Pkg: pkg, ImportedSchemas: importedSchemas}, nil + + if err := validateV1Beta1(ctx, pkg, pkgPath.ManifestFile, opts.Flavor); err != nil { + return DefinedPackage{}, err + } + + return DefinedPackage{pkg: internalv1beta1.ConvertToGeneric(pkg), ImportedSchemas: importedSchemas}, nil } func validate(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, setVariables map[string]string, flavor string, skipRequiredValues bool, skipSchemaValidation bool) error { @@ -149,6 +216,43 @@ func validate(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath string, return nil } +// validateV1Beta1 validates a v1beta1 package before it is converted down to v1alpha1. +func validateV1Beta1(ctx context.Context, pkg v1beta1.Package, packagePath string, flavor string) error { + l := logger.From(ctx) + start := time.Now() + l.Debug("start v1beta1 validate", + "pkg", pkg.Metadata.Name, + "packagePath", packagePath, + "flavor", flavor, + ) + + if !hasFlavoredComponentV1Beta1(pkg, flavor) { + l.Warn("flavor not used in package", "flavor", flavor) + } + if err := internalv1beta1.ValidatePackage(pkg); err != nil { + return fmt.Errorf("package validation failed: %w", err) + } + + findings, err := lint.ValidatePackageSchemaAtPathV1Beta1(packagePath) + if err != nil { + return fmt.Errorf("unable to check schema: %w", err) + } + if len(findings) != 0 { + return &lint.LintError{ + PackageName: pkg.Metadata.Name, + Findings: findings, + } + } + + l.Debug("done v1beta1 validate", + "pkg", pkg.Metadata.Name, + "path", packagePath, + "findings", findings, + "duration", time.Since(start), + ) + return nil +} + type validateValuesSchemaOptions struct { skipRequired bool } @@ -196,6 +300,15 @@ func hasFlavoredComponent(pkg v1alpha1.ZarfPackage, flavor string) bool { return false } +func hasFlavoredComponentV1Beta1(pkg v1beta1.Package, flavor string) bool { + for _, comp := range pkg.Components { + if comp.Selector.Flavor == flavor { + return true + } + } + return false +} + func fillActiveTemplate(ctx context.Context, pkg v1alpha1.ZarfPackage, setVariables map[string]string, isInteractive bool) (v1alpha1.ZarfPackage, []string, error) { templateMap := map[string]string{} warnings := []string{} diff --git a/src/pkg/packager/load/load_test.go b/src/pkg/packager/load/load_test.go index 39b7179a1d..3bb61ef4e2 100644 --- a/src/pkg/packager/load/load_test.go +++ b/src/pkg/packager/load/load_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/api/v1beta1" "github.com/zarf-dev/zarf/src/pkg/feature" "github.com/zarf-dev/zarf/src/test/testutil" ) @@ -156,6 +157,51 @@ func TestPackageDefinitionWithValuesSchema(t *testing.T) { } } +func TestV1Beta1PackageDefinition(t *testing.T) { + t.Parallel() + ctx := testutil.TestContext(t) + + t.Run("loads and validates, exposing both a v1alpha1 and a faithful v1beta1 view", func(t *testing.T) { + t.Parallel() + defined, err := PackageDefinition(ctx, filepath.Join("testdata", "v1beta1-package"), DefinitionOptions{}) + require.NoError(t, err) + require.Equal(t, v1beta1.APIVersion, defined.OriginalAPIVersion()) + + pkg, err := defined.AsV1alpha1() + require.NoError(t, err) + require.Equal(t, v1alpha1.APIVersion, pkg.APIVersion) + require.Equal(t, "beta-package", pkg.Metadata.Name) + require.NotEmpty(t, pkg.Metadata.Architecture) + require.Len(t, pkg.Components, 1) + require.Equal(t, "first", pkg.Components[0].Name) + require.Equal(t, []string{"nginx:1.27.0"}, pkg.Components[0].Images) + require.Equal(t, []string{"https://github.com/zarf-dev/zarf.git"}, pkg.Components[0].Repos) + require.Empty(t, defined.ImportedSchemas) + + // The v1beta1 view preserves fields with no v1alpha1 representation — here an image's source. + // Collapsing to v1alpha1 on load (the previous approach) dropped these. + betaPkg, err := defined.AsV1beta1() + require.NoError(t, err) + require.Equal(t, v1beta1.APIVersion, betaPkg.APIVersion) + require.Len(t, betaPkg.Components, 1) + require.Equal(t, "nginx:1.27.0", betaPkg.Components[0].Images[0].Name) + require.Equal(t, "daemon", betaPkg.Components[0].Images[0].Source) + }) + + t.Run("resolves a local component config import", func(t *testing.T) { + t.Parallel() + defined, err := PackageDefinition(ctx, filepath.Join("testdata", "v1beta1-with-import"), DefinitionOptions{}) + require.NoError(t, err) + + pkg, err := defined.AsV1alpha1() + require.NoError(t, err) + require.Equal(t, v1alpha1.APIVersion, pkg.APIVersion) + require.Len(t, pkg.Components, 1) + require.Equal(t, "imported", pkg.Components[0].Name) + require.Equal(t, []string{"nginx:1.27.0"}, pkg.Components[0].Images) + }) +} + func TestPackageDefinitionErrors(t *testing.T) { t.Parallel() ctx := testutil.TestContext(t) diff --git a/src/pkg/packager/load/testdata/import-v1beta1/cycle/a.yaml b/src/pkg/packager/load/testdata/import-v1beta1/cycle/a.yaml new file mode 100644 index 0000000000..abc1052fa4 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/cycle/a.yaml @@ -0,0 +1,8 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: a +component: + import: + local: + - path: b.yaml diff --git a/src/pkg/packager/load/testdata/import-v1beta1/cycle/b.yaml b/src/pkg/packager/load/testdata/import-v1beta1/cycle/b.yaml new file mode 100644 index 0000000000..bf7648ab0d --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/cycle/b.yaml @@ -0,0 +1,8 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: b +component: + import: + local: + - path: a.yaml diff --git a/src/pkg/packager/load/testdata/import-v1beta1/cycle/zarf.yaml b/src/pkg/packager/load/testdata/import-v1beta1/cycle/zarf.yaml new file mode 100644 index 0000000000..f801a40c13 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/cycle/zarf.yaml @@ -0,0 +1,9 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: cycle-import +components: + - name: a + import: + local: + - path: a.yaml diff --git a/src/pkg/packager/load/testdata/import-v1beta1/merge/components/app.yaml b/src/pkg/packager/load/testdata/import-v1beta1/merge/components/app.yaml new file mode 100644 index 0000000000..75315c2115 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/merge/components/app.yaml @@ -0,0 +1,14 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: app +component: + images: + - name: redis:7 + source: registry + - name: nginx:1.27 + charts: + - name: app + namespace: app + local: + path: app-chart diff --git a/src/pkg/packager/load/testdata/import-v1beta1/merge/zarf.yaml b/src/pkg/packager/load/testdata/import-v1beta1/merge/zarf.yaml new file mode 100644 index 0000000000..d3a18d95f6 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/merge/zarf.yaml @@ -0,0 +1,15 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: merge-import +components: + - name: app + images: + - name: redis:7 + source: daemon + charts: + - name: app + releaseName: custom-release + import: + local: + - path: components/app.yaml diff --git a/src/pkg/packager/load/testdata/import-v1beta1/mixed/components/middle.yaml b/src/pkg/packager/load/testdata/import-v1beta1/mixed/components/middle.yaml new file mode 100644 index 0000000000..076580ed92 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/mixed/components/middle.yaml @@ -0,0 +1,7 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: middle +component: + images: + - name: nginx:1.27 diff --git a/src/pkg/packager/load/testdata/import-v1beta1/mixed/zarf.yaml b/src/pkg/packager/load/testdata/import-v1beta1/mixed/zarf.yaml new file mode 100644 index 0000000000..7698227159 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/mixed/zarf.yaml @@ -0,0 +1,15 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: mixed-import +components: + - name: first + images: + - name: alpine:3.20 + - name: middle + import: + local: + - path: components/middle.yaml + - name: last + images: + - name: busybox:1.36 diff --git a/src/pkg/packager/load/testdata/import-v1beta1/nested/components/app.yaml b/src/pkg/packager/load/testdata/import-v1beta1/nested/components/app.yaml new file mode 100644 index 0000000000..df0dcb27e7 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/nested/components/app.yaml @@ -0,0 +1,13 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: app +component: + import: + local: + - path: base/base.yaml + charts: + - name: app-chart + namespace: app + local: + path: app-chart diff --git a/src/pkg/packager/load/testdata/import-v1beta1/nested/components/base/base.yaml b/src/pkg/packager/load/testdata/import-v1beta1/nested/components/base/base.yaml new file mode 100644 index 0000000000..9db7584098 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/nested/components/base/base.yaml @@ -0,0 +1,8 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: base +component: + files: + - source: base.txt + destination: /etc/base.txt diff --git a/src/pkg/packager/load/testdata/import-v1beta1/nested/zarf.yaml b/src/pkg/packager/load/testdata/import-v1beta1/nested/zarf.yaml new file mode 100644 index 0000000000..39ab5b9a7d --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/nested/zarf.yaml @@ -0,0 +1,9 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: nested-import +components: + - name: app + import: + local: + - path: components/app.yaml diff --git a/src/pkg/packager/load/testdata/import-v1beta1/single/components/logging.yaml b/src/pkg/packager/load/testdata/import-v1beta1/single/components/logging.yaml new file mode 100644 index 0000000000..16350d71b4 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/single/components/logging.yaml @@ -0,0 +1,21 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: logging +component: + charts: + - name: loki + namespace: logging + local: + path: loki-chart + valuesFiles: + - path: loki-values.yaml + files: + - source: motd.txt + destination: /etc/motd + images: + - name: grafana/loki:2.9.0 +values: + files: + - logging-values.yaml + schema: logging.schema.json diff --git a/src/pkg/packager/load/testdata/import-v1beta1/single/zarf.yaml b/src/pkg/packager/load/testdata/import-v1beta1/single/zarf.yaml new file mode 100644 index 0000000000..926419ea71 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/single/zarf.yaml @@ -0,0 +1,9 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: single-import +components: + - name: logging + import: + local: + - path: components/logging.yaml diff --git a/src/pkg/packager/load/testdata/import-v1beta1/variants/components/web-apache.yaml b/src/pkg/packager/load/testdata/import-v1beta1/variants/components/web-apache.yaml new file mode 100644 index 0000000000..d3d1a60b26 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/variants/components/web-apache.yaml @@ -0,0 +1,9 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: web +component: + selector: + flavor: apache + images: + - name: httpd:2.4 diff --git a/src/pkg/packager/load/testdata/import-v1beta1/variants/components/web-nginx.yaml b/src/pkg/packager/load/testdata/import-v1beta1/variants/components/web-nginx.yaml new file mode 100644 index 0000000000..1447acf129 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/variants/components/web-nginx.yaml @@ -0,0 +1,9 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: web +component: + selector: + flavor: nginx + images: + - name: nginx:1.27 diff --git a/src/pkg/packager/load/testdata/import-v1beta1/variants/zarf.yaml b/src/pkg/packager/load/testdata/import-v1beta1/variants/zarf.yaml new file mode 100644 index 0000000000..dbae84b6f8 --- /dev/null +++ b/src/pkg/packager/load/testdata/import-v1beta1/variants/zarf.yaml @@ -0,0 +1,10 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: variants-import +components: + - name: web + import: + local: + - path: components/web-apache.yaml + - path: components/web-nginx.yaml diff --git a/src/pkg/packager/load/testdata/v1beta1-package/zarf.yaml b/src/pkg/packager/load/testdata/v1beta1-package/zarf.yaml new file mode 100644 index 0000000000..86d80abbb5 --- /dev/null +++ b/src/pkg/packager/load/testdata/v1beta1-package/zarf.yaml @@ -0,0 +1,14 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: beta-package + description: a self-contained v1beta1 package + version: 0.0.1 +components: + - name: first + description: a component with an image and a repository + images: + - name: nginx:1.27.0 + source: daemon + repositories: + - url: https://github.com/zarf-dev/zarf.git diff --git a/src/pkg/packager/load/testdata/v1beta1-with-import/imported-component.yaml b/src/pkg/packager/load/testdata/v1beta1-with-import/imported-component.yaml new file mode 100644 index 0000000000..7ac5e28736 --- /dev/null +++ b/src/pkg/packager/load/testdata/v1beta1-with-import/imported-component.yaml @@ -0,0 +1,7 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfComponentConfig +metadata: + name: imported +component: + images: + - name: nginx:1.27.0 diff --git a/src/pkg/packager/load/testdata/v1beta1-with-import/zarf.yaml b/src/pkg/packager/load/testdata/v1beta1-with-import/zarf.yaml new file mode 100644 index 0000000000..03b7989df5 --- /dev/null +++ b/src/pkg/packager/load/testdata/v1beta1-with-import/zarf.yaml @@ -0,0 +1,9 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: beta-with-import +components: + - name: imported + import: + local: + - path: imported-component.yaml diff --git a/src/pkg/packager/publish.go b/src/pkg/packager/publish.go index 0be36efc0a..a1f9e45a66 100644 --- a/src/pkg/packager/publish.go +++ b/src/pkg/packager/publish.go @@ -236,7 +236,11 @@ func PublishSkeleton(ctx context.Context, path string, ref registry.Reference, o if err != nil { return registry.Reference{}, err } - for _, comp := range defined.Pkg.Components { + pkg, err := defined.AsV1alpha1() + if err != nil { + return registry.Reference{}, err + } + for _, comp := range pkg.Components { if comp.ImageArchives != nil { return registry.Reference{}, fmt.Errorf("cannot publish skeleton package with image archives") } @@ -248,7 +252,7 @@ func PublishSkeleton(ctx context.Context, path string, ref registry.Reference, o Flavor: opts.Flavor, WithBuildMachineInfo: opts.WithBuildMachineInfo, } - pkgLayout, err := layout.AssembleSkeleton(ctx, defined.Pkg, path, defined.ImportedSchemas, createOpts) + pkgLayout, err := layout.AssembleSkeleton(ctx, pkg, path, defined.ImportedSchemas, createOpts) if err != nil { return registry.Reference{}, fmt.Errorf("unable to create skeleton: %w", err) } diff --git a/src/pkg/packager/values_preflight_test.go b/src/pkg/packager/values_preflight_test.go index a6f4b705bf..0ba7b4b215 100644 --- a/src/pkg/packager/values_preflight_test.go +++ b/src/pkg/packager/values_preflight_test.go @@ -354,7 +354,9 @@ func assembleLayout(t *testing.T, srcDir string) *layout.PackageLayout { ctx := testutil.TestContext(t) defined, err := load.PackageDefinition(ctx, srcDir, load.DefinitionOptions{}) require.NoError(t, err) - pkgLayout, err := layout.AssemblePackage(ctx, defined.Pkg, srcDir, nil, layout.AssembleOptions{SkipSBOM: true}) + pkg, err := defined.AsV1alpha1() + require.NoError(t, err) + pkgLayout, err := layout.AssemblePackage(ctx, pkg, srcDir, nil, layout.AssembleOptions{SkipSBOM: true}) require.NoError(t, err) return pkgLayout } diff --git a/src/pkg/schema/schema.go b/src/pkg/schema/schema.go index eb82cd8a7f..271f721061 100644 --- a/src/pkg/schema/schema.go +++ b/src/pkg/schema/schema.go @@ -13,7 +13,15 @@ import ( //go:embed zarf-v1alpha1-schema.json var v1Alpha1Schema []byte +//go:embed zarf-v1beta1-package-schema.json +var v1Beta1Schema []byte + // GetV1Alpha1Schema returns the embedded JSON schema for the v1alpha1 Zarf package config func GetV1Alpha1Schema() []byte { return v1Alpha1Schema } + +// GetV1Beta1Schema returns the embedded JSON schema for the v1beta1 Zarf package config +func GetV1Beta1Schema() []byte { + return v1Beta1Schema +} diff --git a/src/pkg/zoci/fetch.go b/src/pkg/zoci/fetch.go index b7d6f47ca6..6aa2ad7b8c 100644 --- a/src/pkg/zoci/fetch.go +++ b/src/pkg/zoci/fetch.go @@ -11,6 +11,7 @@ import ( "github.com/defenseunicorns/pkg/oci" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zarf-dev/zarf/src/api/v1alpha1" + internalv1alpha1 "github.com/zarf-dev/zarf/src/internal/api/v1alpha1" "github.com/zarf-dev/zarf/src/internal/pkgcfg" "github.com/zarf-dev/zarf/src/pkg/packager/layout" ) @@ -29,7 +30,11 @@ func (r *Remote) FetchZarfYAML(ctx context.Context) (v1alpha1.ZarfPackage, error if err != nil { return v1alpha1.ZarfPackage{}, err } - return pkgcfg.ParseMultiDoc(ctx, b) + generic, err := pkgcfg.ParseMultiDoc(ctx, b) + if err != nil { + return v1alpha1.ZarfPackage{}, err + } + return internalv1alpha1.ConvertFromGeneric(generic), nil } // FetchImagesIndex fetches the images/index.json file from the remote repository. diff --git a/src/test/e2e/00_use_cli_test.go b/src/test/e2e/00_use_cli_test.go index 43da2794aa..26cc604abd 100644 --- a/src/test/e2e/00_use_cli_test.go +++ b/src/test/e2e/00_use_cli_test.go @@ -68,6 +68,17 @@ func TestUseCLI(t *testing.T) { require.Contains(t, stdOut, string(b)) }) + t.Run("zarf dev inspect definition v1beta1", func(t *testing.T) { + t.Parallel() + pathToPackage := filepath.Join("src", "test", "packages", "00-dev-inspect-definition-v1beta1") + + stdOut, _, err := e2e.Zarf(t, "dev", "inspect", "definition", pathToPackage, "--architecture=amd64") + require.NoError(t, err) + b, err := os.ReadFile(filepath.Join(pathToPackage, "expected-zarf.yaml")) + require.NoError(t, err) + require.Contains(t, stdOut, string(b)) + }) + t.Run("zarf dev sha256sum ", func(t *testing.T) { t.Parallel() diff --git a/src/test/packages/00-dev-inspect-definition-v1beta1/expected-zarf.yaml b/src/test/packages/00-dev-inspect-definition-v1beta1/expected-zarf.yaml new file mode 100644 index 0000000000..cb328359d3 --- /dev/null +++ b/src/test/packages/00-dev-inspect-definition-v1beta1/expected-zarf.yaml @@ -0,0 +1,11 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: test-inspect-definition-v1beta1 + description: a self-contained v1beta1 package + architecture: amd64 +components: +- name: image-test + description: this component is loaded and converted down to v1alpha1 + images: + - name: nginx:1.27.0 diff --git a/src/test/packages/00-dev-inspect-definition-v1beta1/zarf.yaml b/src/test/packages/00-dev-inspect-definition-v1beta1/zarf.yaml new file mode 100644 index 0000000000..a5243cfbe9 --- /dev/null +++ b/src/test/packages/00-dev-inspect-definition-v1beta1/zarf.yaml @@ -0,0 +1,10 @@ +apiVersion: zarf.dev/v1beta1 +kind: ZarfPackageConfig +metadata: + name: test-inspect-definition-v1beta1 + description: a self-contained v1beta1 package +components: + - name: image-test + description: this component is loaded and converted down to v1alpha1 + images: + - name: nginx:1.27.0