Skip to content

Commit 5740d84

Browse files
fix(oci): add initial ocispec
1 parent 46e5dbe commit 5740d84

File tree

2 files changed

+570
-0
lines changed

2 files changed

+570
-0
lines changed

api/internal/oci/repospec.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Copyright 2025 The Kubernetes Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package oci
5+
6+
import (
7+
"fmt"
8+
"path/filepath"
9+
"strings"
10+
"time"
11+
12+
"sigs.k8s.io/kustomize/kyaml/errors"
13+
"sigs.k8s.io/kustomize/kyaml/filesys"
14+
)
15+
16+
// Used as a temporary non-empty occupant of the pullDir
17+
// field, as something distinguishable from the empty string
18+
// in various outputs (especially tests). Not using an
19+
// actual directory name here, as that's a temporary directory
20+
// with a unique name that isn't created until pull time.
21+
const notPulled = filesys.ConfirmedDir("/notPulled")
22+
23+
// RepoSpec specifies an OCI repository and a tag
24+
// TODO: and path therein?
25+
type RepoSpec struct {
26+
// Raw, original spec, used to look for cycles.
27+
// TODO(monopole): Drop raw, use processed fields instead.
28+
raw string
29+
30+
// Host, e.g. ghcr.io/
31+
Host string
32+
33+
// RepoPath name (Remote repository),
34+
// e.g. kubernetes-sigs/kustomize
35+
RepoPath string
36+
37+
// Dir is where the manifest is pulled to.
38+
Dir filesys.ConfirmedDir
39+
40+
// Relative path in the repository, and in the pullDir,
41+
// to a Kustomization.
42+
KustRootPath string
43+
44+
// Tag reference.
45+
Tag string
46+
47+
// Digest
48+
Digest string
49+
50+
// Timeout is the maximum duration allowed for execing git commands.
51+
Timeout time.Duration
52+
}
53+
54+
// RepoSpec returns a string suitable for pulling with tools like oras.land, eg "oras pull {spec}".
55+
// Note that this doesn't work with oci-layout hosts, as it requires a separate cli flag.
56+
func (x *RepoSpec) PullSpec() string {
57+
pullSpec := x.Host + x.RepoPath
58+
59+
if x.Tag != "" {
60+
pullSpec += tagSeparator + x.Tag
61+
}
62+
63+
if x.Digest != "" {
64+
pullSpec += digestSeparator + x.Digest
65+
}
66+
67+
return pullSpec
68+
}
69+
70+
func (x *RepoSpec) PullDir() filesys.ConfirmedDir {
71+
return x.Dir
72+
}
73+
74+
func (x *RepoSpec) Raw() string {
75+
return x.raw
76+
}
77+
78+
func (x *RepoSpec) AbsPath() string {
79+
return x.Dir.Join(x.KustRootPath)
80+
}
81+
82+
func (x *RepoSpec) Cleaner(fSys filesys.FileSystem) func() error {
83+
return func() error { return fSys.RemoveAll(x.Dir.String()) }
84+
}
85+
86+
const (
87+
tagSeparator = ":"
88+
digestSeparator = "@"
89+
pathSeparator = "/" // do not use filepath.Separator, as this is a URL
90+
rootSeparator = "//"
91+
)
92+
93+
// NewRepoSpecFromURL parses OCI reference paths.
94+
// From strings like oci://ghcr.io/someOrg/someRepository:someTag or
95+
// oci://ghcr.io/someOrg/someRepository:sha256:<digest>, extract
96+
// the different parts of URL, set into a RepoSpec object and return RepoSpec object.
97+
// It MUST return an error if the input is not an oci-like URL, as this is used by some code paths
98+
// to distinguish between local and remote paths.
99+
//
100+
// In particular, NewManifestSpecFromURL separates the URL used to pull the manifest from the
101+
// elements Kustomize uses for other purposes (e.g. query params that turn into args, and
102+
// the path to the kustomization root within the repo).
103+
func NewRepoSpecFromURL(n string) (*RepoSpec, error) {
104+
repoSpec := &RepoSpec{raw: n, Dir: notPulled, Timeout: defaultTimeout}
105+
if filepath.IsAbs(n) {
106+
return nil, fmt.Errorf("uri looks like abs path: %s", n)
107+
}
108+
109+
var err error
110+
111+
// Parse the host (e.g. scheme, domain) segment.
112+
repoSpec.Host, n, err = extractHost(n)
113+
if err != nil {
114+
return nil, err
115+
}
116+
117+
repoSpec.KustRootPath, n, err = extractRoot(n)
118+
if err != nil {
119+
return nil, err
120+
}
121+
122+
repoSpec.Digest, n, err = extractDigest(n)
123+
if err != nil {
124+
return nil, err
125+
}
126+
127+
repoSpec.Tag, repoSpec.RepoPath, err = extractTag(n)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
if len(repoSpec.RepoPath) == 0 {
133+
return nil, errors.Errorf("failed to parse repo path segment")
134+
}
135+
136+
return repoSpec, nil
137+
}
138+
139+
func extractRoot(n string) (string, string, error) {
140+
if rootIndex := strings.LastIndex(n, rootSeparator); rootIndex >= 0 {
141+
root := n[rootIndex+len(rootSeparator):]
142+
143+
if len(root) == 0 {
144+
return "", "", errors.Errorf("failed to parse root path segment")
145+
}
146+
if kustRootPathExitsRepo(root) {
147+
return "", "", errors.Errorf("root path exits repo")
148+
}
149+
150+
return root, n[:rootIndex], nil
151+
}
152+
153+
return "", n, nil
154+
}
155+
156+
func kustRootPathExitsRepo(kustRootPath string) bool {
157+
cleanedPath := filepath.Clean(strings.TrimPrefix(kustRootPath, string(filepath.Separator)))
158+
pathElements := strings.Split(cleanedPath, string(filepath.Separator))
159+
return len(pathElements) > 0 &&
160+
pathElements[0] == filesys.ParentDir
161+
}
162+
163+
// Arbitrary, but non-infinite, timeout for running commands.
164+
const defaultTimeout = 27 * time.Second
165+
166+
func extractDigest(n string) (string, string, error) {
167+
if digestIndex := strings.LastIndex(n, digestSeparator); digestIndex >= 0 {
168+
digest := n[digestIndex+len(digestSeparator):]
169+
// Digest is at least 8 characters, but we don't validate the entire schema here
170+
if len(digest) < 8 {
171+
return "", "", errors.Errorf("failed to parse digest")
172+
}
173+
174+
return digest, n[:digestIndex], nil
175+
}
176+
177+
// No digest
178+
return "", n, nil
179+
}
180+
181+
func extractTag(n string) (string, string, error) {
182+
if tagIndex := strings.LastIndex(n, tagSeparator); tagIndex >= 0 {
183+
tag := n[tagIndex+len(tagSeparator):]
184+
185+
if len(tag) == 0 {
186+
return "", "", errors.Errorf("failed to parse tag segment")
187+
}
188+
189+
return tag, n[:tagIndex], nil
190+
}
191+
192+
return "", n, nil
193+
}
194+
195+
func extractHost(n string) (string, string, error) {
196+
scheme, n, err := extractScheme(n)
197+
198+
if err != nil {
199+
return "", "", err
200+
}
201+
202+
// Now that we have extracted a valid scheme, we can parse host itself.
203+
204+
// The oci layout specifies a path to a local oci layout directory or archive.
205+
// Everything after the scheme is actually part of that path.
206+
if scheme == ociLayoutScheme {
207+
return ociLayoutScheme, n, nil
208+
}
209+
210+
var host, rest = n, ""
211+
if sepIndex := findPathSeparator(n); sepIndex >= 0 {
212+
host, rest = n[:sepIndex+1], n[sepIndex+1:]
213+
}
214+
215+
// Host is required, so do not concat the scheme and username if we didn't find one.
216+
if host == "" {
217+
return "", "", errors.Errorf("failed to parse host segment")
218+
}
219+
return host, rest, nil
220+
}
221+
222+
const ociScheme = "oci://"
223+
const ociLayoutScheme = "oci-layout://"
224+
225+
func extractScheme(s string) (string, string, error) {
226+
for _, prefix := range []string{ociScheme, ociLayoutScheme} {
227+
if rest, found := trimPrefixIgnoreCase(s, prefix); found {
228+
return prefix, rest, nil
229+
}
230+
}
231+
return "", "", fmt.Errorf("unsupported scheme")
232+
}
233+
234+
// trimPrefixIgnoreCase returns the rest of s and true if prefix, ignoring case, prefixes s.
235+
// Otherwise, trimPrefixIgnoreCase returns s and false.
236+
func trimPrefixIgnoreCase(s, prefix string) (string, bool) {
237+
if len(prefix) <= len(s) && strings.ToLower(s[:len(prefix)]) == prefix {
238+
return s[len(prefix):], true
239+
}
240+
return s, false
241+
}
242+
243+
func findPathSeparator(hostPath string) int {
244+
sepIndex := strings.Index(hostPath, pathSeparator)
245+
return sepIndex
246+
}

0 commit comments

Comments
 (0)