diff --git a/cmd/xprin-helpers/claimtoxr/cmd.go b/cmd/xprin-helpers/claimtoxr/cmd.go index 02445b9..452641b 100644 --- a/cmd/xprin-helpers/claimtoxr/cmd.go +++ b/cmd/xprin-helpers/claimtoxr/cmd.go @@ -37,9 +37,11 @@ type Cmd struct { InputFile string `arg:"" default:"-" help:"The Claim YAML file to be converted. If not specified or '-', stdin will be used." optional:"" predictor:"file" type:"path"` // Flags. - OutputFile string `help:"The file to write the generated XR YAML to. If not specified, stdout will be used." placeholder:"PATH" predictor:"file" short:"o" type:"path"` - Kind string `help:"The kind to use for the XR. If not specified, 'X' will be prepended to the Claim's kind (e.g. Infra -> XInfra)." placeholder:"KIND" type:"string"` - Direct bool `help:"Create a direct XR without Claim references and suffix." name:"direct" negatable:""` + OutputFile string `help:"The file to write the generated XR YAML to. If not specified, stdout will be used." placeholder:"PATH" predictor:"file" short:"o" type:"path"` + Name string `help:"The name to use for the XR. If empty, defaults to the Claim's name (direct mode) or the Claim's name with a random suffix (non-direct)." placeholder:"NAME" type:"string"` + Kind string `help:"The kind to use for the XR. If not specified, 'X' will be prepended to the Claim's kind (e.g. Infra -> XInfra)." placeholder:"KIND" type:"string"` + Direct bool `help:"Create a direct XR without Claim references and suffix." name:"direct" negatable:""` + GenUID bool `help:"Set a fresh random metadata.uid on the generated XR." name:"gen-uid"` fs afero.Fs } @@ -50,26 +52,32 @@ func (c *Cmd) Help() string { Convert a Crossplane Claim YAML file to a Crossplane Composite Resource (XR) format. This command will: -- Read the Claim from the provided YAML file -- Create an XR with the same spec as the Claim -- Set appropriate API version and kind for the XR -- Set up the Claim reference in the XR (unless --direct is used) -- Copy any composition selector + - Read the Claim from the provided YAML file + - Create an XR with the same spec as the Claim + - Set appropriate API version and kind for the XR + - Set up the Claim reference in the XR (unless --direct is used) + - Copy any composition selector Examples: # Convert claim.yaml to XR format and write to stdout (kind will be 'X' + Claim's kind) xprin-helpers convert-claim-to-xr claim.yaml - # Convert claim.yaml to XR format with a specific kind - xprin-helpers convert-claim-to-xr claim.yaml --kind MyCompositeResource - # Convert claim.yaml to XR format and write to xr.yaml xprin-helpers convert-claim-to-xr claim.yaml -o xr.yaml + # Convert claim.yaml using an explicit XR name (overrides the default suffix or claim name) + xprin-helpers convert-claim-to-xr claim.yaml --name my-xr + + # Convert claim.yaml to XR format with a specific kind + xprin-helpers convert-claim-to-xr claim.yaml --kind MyCompositeResource + # Convert claim.yaml to a directly created XR (no Claim references, no name suffix) xprin-helpers convert-claim-to-xr claim.yaml --direct + # Convert claim.yaml and assign a fresh random metadata.uid to the XR + xprin-helpers convert-claim-to-xr claim.yaml --gen-uid + # Convert Claim from stdin to XR format cat claim.yaml | xprin-helpers convert-claim-to-xr - ` @@ -94,7 +102,12 @@ func (c *Cmd) Run(k *kong.Context) error { } // Convert to XR - xr, err := ConvertClaimToXR(claim, c.Kind, c.Direct) + xr, err := ConvertClaimToXR(claim, Options{ + Name: c.Name, + Kind: c.Kind, + Direct: c.Direct, + GenerateUID: c.GenUID, + }) if err != nil { return errors.Wrap(err, "failed to convert Claim to XR") } diff --git a/cmd/xprin-helpers/claimtoxr/converter.go b/cmd/xprin-helpers/claimtoxr/converter.go index 4faa460..1885f77 100644 --- a/cmd/xprin-helpers/claimtoxr/converter.go +++ b/cmd/xprin-helpers/claimtoxr/converter.go @@ -19,6 +19,7 @@ package claimtoxr import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/uuid" "k8s.io/apiserver/pkg/storage/names" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -41,8 +42,28 @@ const ( labelComposite = "crossplane.io/composite" ) +// Options configures ConvertClaimToXR. +type Options struct { + // Name is the XR name. Empty falls back to: + // - claim.Name when Direct is true + // - claim.Name with a random suffix when Direct is false + // A non-empty Name overrides both fallbacks. + Name string + + // Kind is the XR kind. Empty defaults to "X" + claim.Kind. + Kind string + + // Direct controls XR linkage to the claim: + // - true: no spec.claimRef; no claim-name/claim-namespace labels + // - false: spec.claimRef is set; claim-name/claim-namespace labels added + Direct bool + + // GenerateUID, when true, sets metadata.uid to a fresh random UUID. + GenerateUID bool +} + // ConvertClaimToXR converts a Crossplane Claim to a Composite Resource (XR). -func ConvertClaimToXR(claim *unstructured.Unstructured, kind string, direct bool) (*unstructured.Unstructured, error) { +func ConvertClaimToXR(claim *unstructured.Unstructured, opts Options) (*composite.Unstructured, error) { if claim == nil { return nil, errors.New(errNilInput) } @@ -92,7 +113,8 @@ func ConvertClaimToXR(claim *unstructured.Unstructured, kind string, direct bool return nil, errors.Wrap(err, "failed to set apiVersion") } - // Set XR kind - either from flag or by prepending X to Claim's kind + // Set XR kind - either from opts or by prepending X to Claim's kind + kind := opts.Kind if kind == "" { kind = "X" + claimKind } @@ -113,7 +135,7 @@ func ConvertClaimToXR(claim *unstructured.Unstructured, kind string, direct bool xrName := claimName - if !direct { + if !opts.Direct { xrName = names.SimpleNameGenerator.GenerateName(claimName + "-") labels[labelClaimName] = claim.GetName() @@ -128,6 +150,11 @@ func ConvertClaimToXR(claim *unstructured.Unstructured, kind string, direct bool } } + // Explicit Name overrides both Direct's claim-name default and the generated suffix. + if opts.Name != "" { + xrName = opts.Name + } + if err := xrPaved.SetString("metadata.name", xrName); err != nil { return nil, errors.Wrap(err, "failed to set name") } @@ -140,6 +167,9 @@ func ConvertClaimToXR(claim *unstructured.Unstructured, kind string, direct bool } } - // Convert from composite.Unstructured to unstructured.Unstructured - return &unstructured.Unstructured{Object: xr.UnstructuredContent()}, nil + if opts.GenerateUID { + xr.SetUID(uuid.NewUUID()) + } + + return xr, nil } diff --git a/cmd/xprin-helpers/claimtoxr/converter_test.go b/cmd/xprin-helpers/claimtoxr/converter_test.go index b7f3275..d8f5e53 100644 --- a/cmd/xprin-helpers/claimtoxr/converter_test.go +++ b/cmd/xprin-helpers/claimtoxr/converter_test.go @@ -212,15 +212,15 @@ func generateExpectedXR(claim *unstructured.Unstructured, kind string, direct bo func TestConvertClaimToXR(t *testing.T) { type args struct { - claim *unstructured.Unstructured - kind string - direct bool + claim *unstructured.Unstructured + opts Options } type want struct { - xr *unstructured.Unstructured - err error - nameLen int // Length of the generated name, for validation of suffix length + xr *unstructured.Unstructured + err error + nameLen int // Length of the generated name, for validation of suffix length + expectUID bool // Whether the result should have a non-empty metadata.uid } cases := map[string]struct { @@ -231,9 +231,8 @@ func TestConvertClaimToXR(t *testing.T) { "NilClaim": { reason: "Should return error when Claim is nil", args: args{ - claim: nil, - kind: "", - direct: false, + claim: nil, + opts: Options{}, }, want: want{ xr: nil, @@ -243,9 +242,8 @@ func TestConvertClaimToXR(t *testing.T) { "EmptyObject": { reason: "Should return error when Claim object is nil", args: args{ - claim: &unstructured.Unstructured{}, - kind: "", - direct: false, + claim: &unstructured.Unstructured{}, + opts: Options{}, }, want: want{ xr: nil, @@ -255,9 +253,8 @@ func TestConvertClaimToXR(t *testing.T) { "Direct": { reason: "Should keep original name when Direct is true", args: args{ - claim: testClaim, - kind: "", - direct: true, + claim: testClaim, + opts: Options{Direct: true}, }, want: want{ xr: generateExpectedXR(testClaim, "", true), @@ -268,9 +265,8 @@ func TestConvertClaimToXR(t *testing.T) { "NotDirect": { reason: "Should append random suffix when Direct is false", args: args{ - claim: testClaim, - kind: "", - direct: false, + claim: testClaim, + opts: Options{}, }, want: want{ xr: generateExpectedXR(testClaim, "", false), @@ -281,9 +277,8 @@ func TestConvertClaimToXR(t *testing.T) { "ErrNoAPIVersion": { reason: "Should return error when Claim has no apiVersion", args: args{ - claim: generateTestClaim(withoutMandatoryField("apiVersion")), - kind: "", - direct: false, + claim: generateTestClaim(withoutMandatoryField("apiVersion")), + opts: Options{}, }, want: want{ xr: nil, @@ -293,9 +288,8 @@ func TestConvertClaimToXR(t *testing.T) { "InvalidAPIVersion": { reason: "Should return error for invalid API version", args: args{ - claim: testClaimInvalidAPIVersion, - kind: "", - direct: false, + claim: testClaimInvalidAPIVersion, + opts: Options{}, }, want: want{ xr: nil, @@ -305,9 +299,8 @@ func TestConvertClaimToXR(t *testing.T) { "ErrNoKind": { reason: "Should return error when Claim has no kind", args: args{ - claim: generateTestClaim(withoutMandatoryField("kind")), - kind: "", - direct: false, + claim: generateTestClaim(withoutMandatoryField("kind")), + opts: Options{}, }, want: want{ xr: nil, @@ -317,9 +310,8 @@ func TestConvertClaimToXR(t *testing.T) { "ErrNoSpec": { reason: "Should return error when Claim has no spec", args: args{ - claim: generateTestClaim(withoutMandatoryField("spec")), - kind: "", - direct: false, + claim: generateTestClaim(withoutMandatoryField("spec")), + opts: Options{}, }, want: want{ xr: nil, @@ -329,9 +321,8 @@ func TestConvertClaimToXR(t *testing.T) { "PreservesComplexSpec": { reason: "Should preserve complex spec fields and their native types when converting from Claim to XR", args: args{ - claim: testClaimWithComplexSpec, - kind: "", - direct: true, + claim: testClaimWithComplexSpec, + opts: Options{Direct: true}, }, want: want{ err: nil, @@ -369,9 +360,8 @@ func TestConvertClaimToXR(t *testing.T) { "StandardLabelsWithoutExistingLabels": { reason: "Should add standard Crossplane labels when no other labels exist", args: args{ - claim: testClaim, - kind: "", - direct: false, + claim: testClaim, + opts: Options{}, }, want: want{ err: nil, @@ -388,8 +378,7 @@ func TestConvertClaimToXR(t *testing.T) { labelClaimName: "old-value", }), ), - kind: "", - direct: false, + opts: Options{}, }, want: want{ err: nil, @@ -420,9 +409,8 @@ func TestConvertClaimToXR(t *testing.T) { "WithAnnotations": { reason: "Should properly copy annotations from Claim to XR", args: args{ - claim: testClaimWithAnnotations, - kind: "", - direct: true, + claim: testClaimWithAnnotations, + opts: Options{Direct: true}, }, want: want{ err: nil, @@ -444,9 +432,8 @@ func TestConvertClaimToXR(t *testing.T) { "NoNamespaceInXR": { reason: "Should not include namespace in XR metadata as XRs are cluster-scoped", args: args{ - claim: testClaim, - kind: "", - direct: false, + claim: testClaim, + opts: Options{}, }, want: want{ err: nil, @@ -456,9 +443,8 @@ func TestConvertClaimToXR(t *testing.T) { "CustomKindFlag": { reason: "Should use provided kind instead of deriving from Claim kind", args: args{ - claim: testClaim, - kind: "CustomKind", - direct: false, + claim: testClaim, + opts: Options{Kind: "CustomKind"}, }, want: want{ xr: generateExpectedXR(testClaim, "CustomKind", false), @@ -468,9 +454,8 @@ func TestConvertClaimToXR(t *testing.T) { "DirectCustomKindFlag": { reason: "Direct XR should use provided kind instead of deriving from Claim kind", args: args{ - claim: testClaim, - kind: "CustomKind", - direct: true, + claim: testClaim, + opts: Options{Kind: "CustomKind", Direct: true}, }, want: want{ xr: generateExpectedXR(testClaim, "CustomKind", true), @@ -480,9 +465,8 @@ func TestConvertClaimToXR(t *testing.T) { "DirectNoLabels": { reason: "Direct XR should have no Crossplane labels", args: args{ - claim: testClaim, - kind: "", - direct: true, + claim: testClaim, + opts: Options{Direct: true}, }, want: want{ xr: &unstructured.Unstructured{ @@ -502,9 +486,8 @@ func TestConvertClaimToXR(t *testing.T) { "DirectWithExistingLabels": { reason: "Direct XR should keep existing labels but not add Crossplane labels", args: args{ - claim: testClaimWithLabels, - kind: "", - direct: true, + claim: testClaimWithLabels, + opts: Options{Direct: true}, }, want: want{ xr: &unstructured.Unstructured{ @@ -527,9 +510,8 @@ func TestConvertClaimToXR(t *testing.T) { "LabelsShouldMatchGeneratedName": { reason: "Labels should match the generated name in XR", args: args{ - claim: testClaim, - kind: "", - direct: false, + claim: testClaim, + opts: Options{}, }, want: want{ xr: generateExpectedXR(testClaim, "", false), @@ -540,9 +522,8 @@ func TestConvertClaimToXR(t *testing.T) { "CopyAllSpecFields": { reason: "Should copy all spec fields from Claim to XR, preserving types", args: args{ - claim: testClaimWithMultiFieldSpec, - kind: "", - direct: true, + claim: testClaimWithMultiFieldSpec, + opts: Options{Direct: true}, }, want: want{ err: nil, @@ -569,11 +550,77 @@ func TestConvertClaimToXR(t *testing.T) { }, }, }, + "ExplicitNameOverridesDirectDefault": { + reason: "Explicit Name should override the Direct-mode claim-name default", + args: args{ + claim: testClaim, + opts: Options{Name: "my-xr", Direct: true}, + }, + want: want{ + err: nil, + nameLen: len("my-xr"), + xr: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "example.org/v1alpha1", + "kind": "XTestApp", + "metadata": map[string]any{ + "name": "my-xr", + }, + "spec": map[string]any{}, + }, + }, + }, + }, + "ExplicitNameOverridesGeneratedSuffix": { + reason: "Explicit Name should override the non-Direct random-suffix name; claimRef and labels remain", + args: args{ + claim: testClaim, + opts: Options{Name: "my-xr"}, + }, + want: want{ + err: nil, + nameLen: len("my-xr"), + xr: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "example.org/v1alpha1", + "kind": "XTestApp", + "metadata": map[string]any{ + "name": "my-xr", + "labels": map[string]any{ + labelClaimName: "test-app", + labelClaimNamespace: "myclaims", + }, + }, + "spec": map[string]any{ + "claimRef": map[string]any{ + "apiVersion": "example.org/v1alpha1", + "kind": "TestApp", + "name": "test-app", + "namespace": "myclaims", + }, + }, + }, + }, + }, + }, + "GenerateUID": { + reason: "GenerateUID should set a non-empty metadata.uid", + args: args{ + claim: testClaim, + opts: Options{Direct: true, GenerateUID: true}, + }, + want: want{ + err: nil, + nameLen: len("test-app"), + expectUID: true, + // xr left nil; UID is randomly generated so we validate via expectUID and ignore the rest. + }, + }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - got, err := ConvertClaimToXR(tc.args.claim, tc.args.kind, tc.args.direct) + got, err := ConvertClaimToXR(tc.args.claim, tc.args.opts) if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { t.Errorf("\n%s\nConvertClaimToXR(...): -want error, +got error:\n%s", tc.reason, diff) @@ -586,7 +633,7 @@ func TestConvertClaimToXR(t *testing.T) { return false } // Ignore generated name suffixes and composite label values - if key == "name" && v.(string) != "test-app" { + if key == "name" && v.(string) != "test-app" && v.(string) != "my-xr" { return true } @@ -594,10 +641,18 @@ func TestConvertClaimToXR(t *testing.T) { return true } + // UID is random; compare separately via expectUID. + if key == "uid" { + return true + } + return false }) - if diff := cmp.Diff(tc.want.xr, got, opt); diff != "" { - t.Errorf("\n%s\nConvertClaimToXR(...): -want, +got:\n%s", tc.reason, diff) + if tc.want.xr != nil { + // got is *composite.Unstructured; compare against the want *unstructured.Unstructured by Object map. + if diff := cmp.Diff(tc.want.xr.Object, got.UnstructuredContent(), opt); diff != "" { + t.Errorf("\n%s\nConvertClaimToXR(...): -want, +got:\n%s", tc.reason, diff) + } } // Verify name length if specified in test case @@ -615,6 +670,13 @@ func TestConvertClaimToXR(t *testing.T) { t.Errorf("\n%s\nName length mismatch: want %d, got %d", tc.reason, tc.want.nameLen, len(gotName)) } } + + // Verify UID was set when expected + if tc.want.expectUID { + if got == nil || got.GetUID() == "" { + t.Errorf("\n%s\nExpected non-empty metadata.uid, got empty", tc.reason) + } + } }) } } diff --git a/docs/xprin-helpers.md b/docs/xprin-helpers.md index 601ebdf..4d3d386 100644 --- a/docs/xprin-helpers.md +++ b/docs/xprin-helpers.md @@ -30,7 +30,7 @@ Converts Crossplane Claims to XRs so they can be used with `crossplane render`. **Key features:** - Automatic kind conversion (Claim → XClaim) - Optional direct XR creation (no Claim references) -- Custom kind support +- Custom kind, name, and `metadata.uid` support - Integration with `crossplane render` [📖 Full Documentation](xprin-helpers/convert-claim-to-xr.md) diff --git a/docs/xprin-helpers/convert-claim-to-xr.md b/docs/xprin-helpers/convert-claim-to-xr.md index a2a5364..c69d3c9 100644 --- a/docs/xprin-helpers/convert-claim-to-xr.md +++ b/docs/xprin-helpers/convert-claim-to-xr.md @@ -14,8 +14,10 @@ See [Installation](../xprin-helpers.md#installation). | Option | Description | |--------|-------------| +| `--name=NAME` | Custom name for the XR. Overrides the default behavior (Claim name in direct mode, Claim name + random suffix in non-direct mode) | | `--kind=KIND` | Custom kind for the XR (default: "X" + Claim kind) | | `--direct` | Create direct XR without Claim references | +| `--gen-uid` | Set a fresh random `metadata.uid` on the generated XR | | `-o, --output-file=PATH` | Output file (default: stdout) | | `--version` | Print version information | @@ -35,15 +37,21 @@ The last two show the relation between the Claim and the XR. # Convert claim.yaml to XR format and write to stdout (kind will be 'X' + Claim's kind) xprin-helpers convert-claim-to-xr claim.yaml -# Convert claim.yaml to XR format with a specific kind -xprin-helpers convert-claim-to-xr claim.yaml --kind MyCompositeResource - # Convert claim.yaml to XR format and write to xr.yaml xprin-helpers convert-claim-to-xr claim.yaml -o xr.yaml +# Convert claim.yaml using an explicit XR name (overrides the default suffix or claim name) +xprin-helpers convert-claim-to-xr claim.yaml --name my-xr + +# Convert claim.yaml to XR format with a specific kind +xprin-helpers convert-claim-to-xr claim.yaml --kind MyCompositeResource + # Convert claim.yaml to a directly created XR (no Claim references, no name suffix) xprin-helpers convert-claim-to-xr claim.yaml --direct +# Convert claim.yaml and assign a fresh random metadata.uid to the XR +xprin-helpers convert-claim-to-xr claim.yaml --gen-uid + # Convert Claim from stdin to XR format cat claim.yaml | xprin-helpers convert-claim-to-xr - diff --git a/internal/testexecution/runner/patches.go b/internal/testexecution/runner/patches.go index 2b491ca..b17b6da 100644 --- a/internal/testexecution/runner/patches.go +++ b/internal/testexecution/runner/patches.go @@ -115,7 +115,7 @@ func (r *Runner) convertClaimToXR(claimPath, outputPath string) (string, error) utils.DebugPrintf("Converting Claim to XR\n") } - xr, err := claimtoxr.ConvertClaimToXR(claim, "", false) + xr, err := claimtoxr.ConvertClaimToXR(claim, claimtoxr.Options{}) if err != nil { return "", fmt.Errorf("failed to convert claim to XR: %w", err) }