From fe134c3bdf370c6a2fab04094dc9e626b304606c Mon Sep 17 00:00:00 2001 From: Elisa Date: Wed, 5 Aug 2026 15:37:15 +0200 Subject: [PATCH] fix(ocm-kit): resolve relativeOciReference resources in helmvalues Signed-off-by: Elisa --- ocm-kit/helmvalues/helmvalues_test.go | 75 +++++++++++++++++++ ocm-kit/helmvalues/ociref.go | 75 +++++++++++++++++-- ocm-kit/helmvalues/ociref_test.go | 102 ++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 8 deletions(-) diff --git a/ocm-kit/helmvalues/helmvalues_test.go b/ocm-kit/helmvalues/helmvalues_test.go index 0087c91..abd2e70 100644 --- a/ocm-kit/helmvalues/helmvalues_test.go +++ b/ocm-kit/helmvalues/helmvalues_test.go @@ -479,3 +479,78 @@ func TestGetRenderingInput(t *testing.T) { t.Errorf("OCIResources[rel] = %#v, want %#v", gotRel, wantRel) } } + +// TestGetRenderingInput_RelativeOciReference tests correct handling for +// mirrored components: after `ocm transfer`, resources carry a +// relativeOciReference access. Before this was handled they were silently +// dropped from OCIResources, so templates rendered an empty repository and tag +// and the resulting workload got an unusable image reference. +func TestGetRenderingInput_RelativeOciReference(t *testing.T) { + relRes := descriptor.Resource{ + ElementMeta: descriptor.ElementMeta{ + ObjectMeta: descriptor.ObjectMeta{Name: "nginx-image"}, + }, + Access: &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"type":"relativeOciReference/v1","reference":"my-components/linuxserver/nginx:1.28.3"}`), + }, + } + + desc := mkDescriptor("acme.org/app", "1.0.0", relRes) + + input, err := GetRenderingInput(desc, "127.0.0.1:5000/my-components") + if err != nil { + t.Fatalf("GetRenderingInput() error = %v", err) + } + + got, ok := input.OCIResources["nginx-image"] + if !ok { + t.Fatalf("OCIResources missing key %q — relativeOciReference resource was dropped", "nginx-image") + } + + want := ImageReference{ + Host: "127.0.0.1:5000", + Repository: "my-components/linuxserver/nginx", + Tag: "1.28.3", + } + if got != want { + t.Errorf("OCIResources[nginx-image] = %#v, want %#v", got, want) + } +} + +// TestRenderRelativeOciReference renders the template, asserting the +// output is a fully qualified image +func TestRenderRelativeOciReference(t *testing.T) { + relRes := descriptor.Resource{ + ElementMeta: descriptor.ElementMeta{ + ObjectMeta: descriptor.ObjectMeta{Name: "nginx-image"}, + }, + Access: &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"type":"relativeOciReference/v1","reference":"my-components/linuxserver/nginx:1.28.3"}`), + }, + } + + input, err := GetRenderingInput(mkDescriptor("acme.org/app", "1.0.0", relRes), "127.0.0.1:5000/my-components") + if err != nil { + t.Fatalf("GetRenderingInput() error = %v", err) + } + + tmpl := &HelmValuesTemplate{ + ResourceName: "values", + TemplateContent: `{{- $nginx := index .OCIResources "nginx-image" }} +image: + repository: {{ $nginx.Host }}/{{ $nginx.Repository }} + tag: {{ $nginx.Tag }}`, + } + + out, err := Render(tmpl, input, WithYAMLValidation()) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + + const want = "\nimage:\n repository: 127.0.0.1:5000/my-components/linuxserver/nginx\n tag: 1.28.3" + if out != want { + t.Errorf("Render() =\n%q\nwant\n%q", out, want) + } +} diff --git a/ocm-kit/helmvalues/ociref.go b/ocm-kit/helmvalues/ociref.go index d3e4878..8d4c2ca 100644 --- a/ocm-kit/helmvalues/ociref.go +++ b/ocm-kit/helmvalues/ociref.go @@ -87,24 +87,46 @@ func isLocalBlobTypeName(name string) bool { } } +// relativeOCIReferenceType is the access type name for an artifact stored in the +// same OCI registry as the component version referencing it. +const relativeOCIReferenceType = "relativeOciReference" + +// isRelativeOCIReferenceTypeName reports whether a runtime type name identifies +// the registry-relative OCI access. +func isRelativeOCIReferenceTypeName(name string) bool { + return name == relativeOCIReferenceType +} + +// relativeOCIReferenceAccess mirrors the relativeOciReference access spec +// payload. Reference is an OCI repository name plus version, and carries no +// registry host. +type relativeOCIReferenceAccess struct { + Reference string `json:"reference"` +} + // ResourceOCIReference returns the absolute OCI image reference for a resource // backed by OCI content. ok is false when the resource has no resolvable OCI // reference: a non-OCI access, or a component-local blob that exists only by // digest with no repository path to build from. // -// A resource's access is either an OCIImage, which carries an absolute image -// reference directly, or a LocalBlob for component-local content. A LocalBlob -// resolves via its optional GlobalAccess (an absolute OCIImage or OCIImageLayer -// reference) or, failing that, its ReferenceName — a repository-relative path -// carrying no registry host. +// A resource's access is one of three forms: +// +// - OCIImage, which carries an absolute image reference directly. +// - LocalBlob, for component-local content. It resolves via its optional +// GlobalAccess (an absolute OCIImage or OCIImageLayer reference) or, failing +// that, its ReferenceName — a path relative to the repository base URL. +// - relativeOciReference, for an artifact in the same registry as the +// component version. Its Reference already includes the namespace, so it is +// relative to the registry HOST rather than the repository base URL. This is +// the form `ocm transfer` produces, so it is what mirrored components carry. // // The access may be a concrete typed value or, when read back from a repository, // an un-decoded *runtime.Raw; both forms are handled. // // repoBaseURL is the "/" the repository was opened with (e.g. -// "127.0.0.1:5000/my-components"). A host-less ReferenceName is prefixed with it -// to form a full absolute reference; references that already carry a registry -// host are returned unchanged. +// "127.0.0.1:5000/my-components"), and supplies whichever part of the prefix the +// access form needs: the whole base URL for a LocalBlob ReferenceName, only the +// registry host for a relativeOciReference. func ResourceOCIReference(res descriptor.Resource, repoBaseURL string) (ref string, ok bool, err error) { switch a := res.Access.(type) { case *ociaccessv1.OCIImage: @@ -138,6 +160,12 @@ func ResourceOCIReference(res descriptor.Resource, repoBaseURL string) (ref stri return "", false, fmt.Errorf("failed to decode LocalBlob access: %w", err) } return localBlobReference(&lb, repoBaseURL) + case isRelativeOCIReferenceTypeName(a.Name): + var rel relativeOCIReferenceAccess + if err := json.Unmarshal(a.Data, &rel); err != nil { + return "", false, fmt.Errorf("failed to decode relativeOciReference access: %w", err) + } + return registryRelativeReference(rel.Reference, repoBaseURL) default: return "", false, nil } @@ -220,6 +248,37 @@ func absoluteReference(candidate, repoBaseURL string) string { return repoBaseURL + "/" + candidate } +// registryRelativeReference reconstructs a FULL, host-qualified OCI reference +// from a relativeOciReference. +// +// Unlike absoluteReference this applies no isRegistryHost heuristic. The access +// type is host-less by definition and the heuristic would misfire on the common case of +// a namespace whose first segment contains a dot +func registryRelativeReference(reference, repoBaseURL string) (ref string, ok bool, err error) { + if reference == "" { + return "", false, nil + } + host := registryHostOf(repoBaseURL) + if host == "" { + return reference, true, nil + } + + return host + "/" + reference, true, nil +} + +// registryHostOf extracts the registry host from a repository base URL, which +// may carry a scheme and a namespace path (e.g. "https://127.0.0.1:5000/ns/sub" +// yields "127.0.0.1:5000"). It returns "" for an empty input. +func registryHostOf(repoBaseURL string) string { + rest := repoBaseURL + if _, after, found := strings.Cut(rest, "://"); found { + rest = after + } + host, _, _ := strings.Cut(rest, "/") + + return host +} + // isRegistryHost reports whether a leading reference segment is a registry host // rather than a repository namespace segment, using the standard OCI/Docker // heuristic: a host contains a "." or a ":" (port), or is exactly "localhost". diff --git a/ocm-kit/helmvalues/ociref_test.go b/ocm-kit/helmvalues/ociref_test.go index a188a5c..74c906a 100644 --- a/ocm-kit/helmvalues/ociref_test.go +++ b/ocm-kit/helmvalues/ociref_test.go @@ -257,3 +257,105 @@ func TestResourceOCIReference_LocalBlobReferenceNameAlreadyAbsolute(t *testing.T require.True(t, ok) require.Equal(t, "ghcr.io/acme/app:v1", ref) } + +// TestResourceOCIReference_RelativeOciReference covers the access type that +// `ocm transfer` produces: an artifact stored in the same registry as the +// component version. Its reference already contains the namespace, so only the +// registry host is prefixed +func TestResourceOCIReference_RelativeOciReference(t *testing.T) { + raw := &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"type":"relativeOciReference/v1","reference":"my-components/linuxserver/nginx:1.28.3"}`), + } + + ref, ok, err := ResourceOCIReference(descriptor.Resource{Access: raw}, "127.0.0.1:5000/my-components") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "127.0.0.1:5000/my-components/linuxserver/nginx:1.28.3", ref) +} + +// TestResourceOCIReference_RelativeOciReferenceWithDigest ensures a +// digest-pinned reference survives intact. +func TestResourceOCIReference_RelativeOciReferenceWithDigest(t *testing.T) { + raw := &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"type":"relativeOciReference/v1","reference":"my-components/linuxserver/nginx:1.28.3@sha256:eca40c5bede627b5557701bde4b7588a98c07f644221b804b0a9350298400042"}`), + } + + ref, ok, err := ResourceOCIReference(descriptor.Resource{Access: raw}, "127.0.0.1:5000/my-components") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, + "127.0.0.1:5000/my-components/linuxserver/nginx:1.28.3@sha256:eca40c5bede627b5557701bde4b7588a98c07f644221b804b0a9350298400042", + ref) +} + +// TestResourceOCIReference_RelativeOciReferenceDottedNamespace guards the reason +// this path does not reuse absoluteReference's isRegistryHost heuristic; a +// namespace whose first segment contains a dot must still be prefixed, not +// mistaken for a registry host. +func TestResourceOCIReference_RelativeOciReferenceDottedNamespace(t *testing.T) { + raw := &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"type":"relativeOciReference/v1","reference":"my.company/demo/chart:0.1.0"}`), + } + + ref, ok, err := ResourceOCIReference(descriptor.Resource{Access: raw}, "127.0.0.1:5000") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "127.0.0.1:5000/my.company/demo/chart:0.1.0", ref) +} + +// TestResourceOCIReference_RelativeOciReferenceSchemedBaseURL ensures a base URL +// carrying a scheme contributes only its host. +func TestResourceOCIReference_RelativeOciReferenceSchemedBaseURL(t *testing.T) { + raw := &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"type":"relativeOciReference/v1","reference":"my-components/acme/app:v1"}`), + } + + ref, ok, err := ResourceOCIReference(descriptor.Resource{Access: raw}, "https://127.0.0.1:5000/my-components") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "127.0.0.1:5000/my-components/acme/app:v1", ref) +} + +// TestResourceOCIReference_RelativeOciReferenceNoBaseURL leaves the reference +// untouched when there is no repository base URL to draw a host from. +func TestResourceOCIReference_RelativeOciReferenceNoBaseURL(t *testing.T) { + raw := &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"type":"relativeOciReference/v1","reference":"my-components/acme/app:v1"}`), + } + + ref, ok, err := ResourceOCIReference(descriptor.Resource{Access: raw}, "") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "my-components/acme/app:v1", ref) +} + +// TestResourceOCIReference_RelativeOciReferenceEmpty treats an empty reference as +// unresolvable rather than emitting a bare host. +func TestResourceOCIReference_RelativeOciReferenceEmpty(t *testing.T) { + raw := &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"type":"relativeOciReference/v1","reference":""}`), + } + + _, ok, err := ResourceOCIReference(descriptor.Resource{Access: raw}, "127.0.0.1:5000/my-components") + require.NoError(t, err) + require.False(t, ok) +} + +// TestResourceOCIReference_RelativeOciReferenceMalformed surfaces a decode +// failure rather than silently skipping the resource. +func TestResourceOCIReference_RelativeOciReferenceMalformed(t *testing.T) { + raw := &runtime.Raw{ + Type: runtime.Type{Name: "relativeOciReference", Version: "v1"}, + Data: []byte(`{"reference":`), + } + + _, ok, err := ResourceOCIReference(descriptor.Resource{Access: raw}, "127.0.0.1:5000/my-components") + require.Error(t, err) + require.False(t, ok) +}