Skip to content

Commit b53540d

Browse files
auficlaude
andauthored
Update transform pkg for kustomize and multistage (#135)
* Phase 1: Add Kustomize foundations to crane-lib - Add TransformArtifact, PatchTarget, IgnoredOperation, ResourceGroup types - Add Kustomize serialization package (serializer, naming, kustomization) - Add resource grouping logic for multi-doc YAML generation - Add WhiteoutReport and IgnoredPatchReport structures - Comprehensive unit tests for all new functionality Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Remove reports from crane-lib transform package Reports functionality removed to simplify implementation. Core kustomize, grouping, and types functionality preserved. All tests still passing. * Ensure groupMap stable order Signed-off-by: Marek Aufart <maufart@redhat.com> * Update separator in matcher and cleanup Signed-off-by: Marek Aufart <maufart@redhat.com> * Skip empty object after unmarshal Signed-off-by: Marek Aufart <maufart@redhat.com> * Update resource file perms Signed-off-by: Marek Aufart <maufart@redhat.com> --------- Signed-off-by: Marek Aufart <maufart@redhat.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 536e4ec commit b53540d

10 files changed

Lines changed: 1516 additions & 0 deletions

File tree

transform/grouping.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package transform
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"sort"
8+
9+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
10+
"sigs.k8s.io/yaml"
11+
)
12+
13+
// GroupResourcesByType groups resources by their type (kind + group)
14+
// Resources are grouped into ResourceGroup structures for multi-doc YAML file generation
15+
func GroupResourcesByType(resources []unstructured.Unstructured) []ResourceGroup {
16+
// Use a map to collect resources by type key
17+
groupMap := make(map[string][]unstructured.Unstructured)
18+
19+
// Group resources
20+
for _, resource := range resources {
21+
typeKey := GetResourceTypeKey(resource)
22+
groupMap[typeKey] = append(groupMap[typeKey], resource)
23+
}
24+
25+
// Convert map to slice of ResourceGroup
26+
groups := make([]ResourceGroup, 0, len(groupMap))
27+
for typeKey, resources := range groupMap {
28+
groups = append(groups, ResourceGroup{
29+
TypeKey: typeKey,
30+
Resources: resources,
31+
})
32+
}
33+
34+
// Sort groups by TypeKey for deterministic output
35+
sort.Slice(groups, func(i, j int) bool {
36+
return groups[i].TypeKey < groups[j].TypeKey
37+
})
38+
39+
return groups
40+
}
41+
42+
// WriteResourceTypeFile writes a group of resources to a multi-doc YAML file
43+
// Resources are separated by "---" separator as per YAML multi-doc format
44+
func WriteResourceTypeFile(filename string, resources []unstructured.Unstructured) error {
45+
if len(resources) == 0 {
46+
// Don't create empty files
47+
return nil
48+
}
49+
50+
var buf bytes.Buffer
51+
52+
for i, resource := range resources {
53+
// Add YAML document separator before each resource (except the first)
54+
if i > 0 {
55+
buf.WriteString("---\n")
56+
}
57+
58+
// Marshal resource to YAML
59+
yamlBytes, err := yaml.Marshal(resource.Object)
60+
if err != nil {
61+
return fmt.Errorf("failed to marshal resource %s/%s to YAML: %w",
62+
resource.GetNamespace(), resource.GetName(), err)
63+
}
64+
65+
buf.Write(yamlBytes)
66+
}
67+
68+
// Write to file
69+
if err := os.WriteFile(filename, buf.Bytes(), 0600); err != nil {
70+
return fmt.Errorf("failed to write resource type file %s: %w", filename, err)
71+
}
72+
73+
return nil
74+
}
75+
76+
// ReadResourceTypeFile reads a multi-doc YAML file and returns individual resources
77+
// This is useful for reading output from previous stages in multi-stage pipeline
78+
func ReadResourceTypeFile(filename string) ([]unstructured.Unstructured, error) {
79+
// Read file content
80+
data, err := os.ReadFile(filename)
81+
if err != nil {
82+
return nil, fmt.Errorf("failed to read file %s: %w", filename, err)
83+
}
84+
85+
// Split by YAML document separator
86+
docs := splitYAMLDocuments(data)
87+
88+
resources := make([]unstructured.Unstructured, 0, len(docs))
89+
90+
for i, doc := range docs {
91+
// Skip empty documents
92+
if len(bytes.TrimSpace(doc)) == 0 {
93+
continue
94+
}
95+
96+
// Parse YAML to unstructured
97+
var obj map[string]interface{}
98+
if err := yaml.Unmarshal(doc, &obj); err != nil {
99+
return nil, fmt.Errorf("failed to unmarshal document %d in %s: %w", i, filename, err)
100+
}
101+
102+
// Skip nil or empty objects (e.g., YAML null or separator-only documents)
103+
if len(obj) == 0 {
104+
continue
105+
}
106+
107+
resource := unstructured.Unstructured{Object: obj}
108+
resources = append(resources, resource)
109+
}
110+
111+
return resources, nil
112+
}
113+
114+
// splitYAMLDocuments splits a multi-doc YAML file into individual documents
115+
func splitYAMLDocuments(data []byte) [][]byte {
116+
// Split by "---" separator
117+
separator := []byte("\n---\n")
118+
altSeparator := []byte("\n---") // Handle end-of-file case
119+
altSeparator2 := []byte("---\n") // Handle start-of-file case
120+
121+
var docs [][]byte
122+
remaining := data
123+
124+
for len(remaining) > 0 {
125+
// Find next separator
126+
idx := bytes.Index(remaining, separator)
127+
matchedSeparator := separator
128+
129+
if idx == -1 {
130+
// No more separators - check for alternative patterns
131+
idx = bytes.Index(remaining, altSeparator)
132+
if idx == -1 {
133+
// Check if it starts with separator
134+
if bytes.HasPrefix(remaining, altSeparator2) {
135+
remaining = remaining[4:] // Skip "---\n"
136+
continue
137+
}
138+
// This is the last document
139+
docs = append(docs, remaining)
140+
break
141+
}
142+
matchedSeparator = altSeparator
143+
}
144+
145+
// Extract document before separator
146+
doc := remaining[:idx]
147+
if len(bytes.TrimSpace(doc)) > 0 {
148+
docs = append(docs, doc)
149+
}
150+
151+
// Move past separator
152+
if idx+len(matchedSeparator) <= len(remaining) {
153+
remaining = remaining[idx+len(matchedSeparator):]
154+
} else {
155+
break
156+
}
157+
}
158+
159+
return docs
160+
}

transform/grouping_test.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package transform
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
11+
)
12+
13+
func TestGroupResourcesByType(t *testing.T) {
14+
resources := []unstructured.Unstructured{
15+
{
16+
Object: map[string]interface{}{
17+
"apiVersion": "v1",
18+
"kind": "Service",
19+
"metadata": map[string]interface{}{
20+
"name": "service-1",
21+
},
22+
},
23+
},
24+
{
25+
Object: map[string]interface{}{
26+
"apiVersion": "apps/v1",
27+
"kind": "Deployment",
28+
"metadata": map[string]interface{}{
29+
"name": "deployment-1",
30+
},
31+
},
32+
},
33+
{
34+
Object: map[string]interface{}{
35+
"apiVersion": "v1",
36+
"kind": "Service",
37+
"metadata": map[string]interface{}{
38+
"name": "service-2",
39+
},
40+
},
41+
},
42+
{
43+
Object: map[string]interface{}{
44+
"apiVersion": "route.openshift.io/v1",
45+
"kind": "Route",
46+
"metadata": map[string]interface{}{
47+
"name": "route-1",
48+
},
49+
},
50+
},
51+
}
52+
53+
groups := GroupResourcesByType(resources)
54+
55+
// Should have 3 groups: Service, Deployment.apps, Route.route.openshift.io
56+
assert.Len(t, groups, 3)
57+
58+
// Check that each group has the correct number of resources
59+
groupCounts := make(map[string]int)
60+
for _, group := range groups {
61+
groupCounts[group.TypeKey] = len(group.Resources)
62+
}
63+
64+
assert.Equal(t, 2, groupCounts["Service"], "Service group should have 2 resources")
65+
assert.Equal(t, 1, groupCounts["Deployment.apps"], "Deployment group should have 1 resource")
66+
assert.Equal(t, 1, groupCounts["Route.route.openshift.io"], "Route group should have 1 resource")
67+
}
68+
69+
func TestWriteResourceTypeFile(t *testing.T) {
70+
// Create a temporary directory for test files
71+
tempDir := t.TempDir()
72+
73+
resources := []unstructured.Unstructured{
74+
{
75+
Object: map[string]interface{}{
76+
"apiVersion": "v1",
77+
"kind": "Service",
78+
"metadata": map[string]interface{}{
79+
"name": "service-1",
80+
"namespace": "default",
81+
},
82+
"spec": map[string]interface{}{
83+
"type": "ClusterIP",
84+
},
85+
},
86+
},
87+
{
88+
Object: map[string]interface{}{
89+
"apiVersion": "v1",
90+
"kind": "Service",
91+
"metadata": map[string]interface{}{
92+
"name": "service-2",
93+
"namespace": "default",
94+
},
95+
"spec": map[string]interface{}{
96+
"type": "NodePort",
97+
},
98+
},
99+
},
100+
}
101+
102+
filename := filepath.Join(tempDir, "service.yaml")
103+
104+
// Write file
105+
err := WriteResourceTypeFile(filename, resources)
106+
require.NoError(t, err)
107+
108+
// Verify file exists
109+
_, err = os.Stat(filename)
110+
require.NoError(t, err)
111+
112+
// Read file content
113+
content, err := os.ReadFile(filename)
114+
require.NoError(t, err)
115+
116+
// Verify content contains both resources separated by ---
117+
contentStr := string(content)
118+
assert.Contains(t, contentStr, "service-1")
119+
assert.Contains(t, contentStr, "service-2")
120+
assert.Contains(t, contentStr, "---")
121+
}
122+
123+
func TestWriteResourceTypeFileEmpty(t *testing.T) {
124+
// Create a temporary directory for test files
125+
tempDir := t.TempDir()
126+
127+
filename := filepath.Join(tempDir, "empty.yaml")
128+
129+
// Write empty resource list
130+
err := WriteResourceTypeFile(filename, []unstructured.Unstructured{})
131+
require.NoError(t, err)
132+
133+
// Verify file was NOT created (we don't create empty files)
134+
_, err = os.Stat(filename)
135+
assert.True(t, os.IsNotExist(err), "empty resource file should not be created")
136+
}
137+
138+
func TestReadResourceTypeFile(t *testing.T) {
139+
// Create a temporary directory for test files
140+
tempDir := t.TempDir()
141+
142+
// Create a multi-doc YAML file
143+
content := `apiVersion: v1
144+
kind: Service
145+
metadata:
146+
name: service-1
147+
namespace: default
148+
spec:
149+
type: ClusterIP
150+
---
151+
apiVersion: v1
152+
kind: Service
153+
metadata:
154+
name: service-2
155+
namespace: default
156+
spec:
157+
type: NodePort
158+
`
159+
160+
filename := filepath.Join(tempDir, "service.yaml")
161+
err := os.WriteFile(filename, []byte(content), 0644)
162+
require.NoError(t, err)
163+
164+
// Read resources
165+
resources, err := ReadResourceTypeFile(filename)
166+
require.NoError(t, err)
167+
168+
// Verify we got 2 resources
169+
assert.Len(t, resources, 2)
170+
171+
// Verify first resource
172+
assert.Equal(t, "Service", resources[0].GetKind())
173+
assert.Equal(t, "service-1", resources[0].GetName())
174+
assert.Equal(t, "default", resources[0].GetNamespace())
175+
176+
// Verify second resource
177+
assert.Equal(t, "Service", resources[1].GetKind())
178+
assert.Equal(t, "service-2", resources[1].GetName())
179+
assert.Equal(t, "default", resources[1].GetNamespace())
180+
}
181+
182+
func TestSplitYAMLDocuments(t *testing.T) {
183+
tests := []struct {
184+
name string
185+
input string
186+
expectedCount int
187+
}{
188+
{
189+
name: "two documents with separator",
190+
input: `apiVersion: v1
191+
kind: Service
192+
---
193+
apiVersion: v1
194+
kind: ConfigMap`,
195+
expectedCount: 2,
196+
},
197+
{
198+
name: "single document",
199+
input: `apiVersion: v1
200+
kind: Service`,
201+
expectedCount: 1,
202+
},
203+
{
204+
name: "three documents",
205+
input: `apiVersion: v1
206+
kind: Service
207+
---
208+
apiVersion: v1
209+
kind: ConfigMap
210+
---
211+
apiVersion: apps/v1
212+
kind: Deployment`,
213+
expectedCount: 3,
214+
},
215+
{
216+
name: "empty input",
217+
input: "",
218+
expectedCount: 0,
219+
},
220+
}
221+
222+
for _, tt := range tests {
223+
t.Run(tt.name, func(t *testing.T) {
224+
docs := splitYAMLDocuments([]byte(tt.input))
225+
226+
// Filter out empty documents
227+
nonEmptyDocs := 0
228+
for _, doc := range docs {
229+
if len(doc) > 0 {
230+
nonEmptyDocs++
231+
}
232+
}
233+
234+
assert.Equal(t, tt.expectedCount, nonEmptyDocs)
235+
})
236+
}
237+
}

0 commit comments

Comments
 (0)