From 06808888e1c5000a6459c87dc3fa5702e5afed3f Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 6 Aug 2026 18:51:22 +0300 Subject: [PATCH 01/28] fetcher --- pkg/fetcher/extract.go | 111 ++++++++++++++ pkg/fetcher/fetcher.go | 66 ++++++++ pkg/fetcher/http.go | 105 +++++++++++++ pkg/fetcher/local.go | 70 +++++++++ pkg/fetcher/lock.go | 66 ++++++++ pkg/fetcher/npm.go | 307 +++++++++++++++++++++++++++++++++++++ pkg/fetcher/npm_extract.go | 84 ++++++++++ pkg/fetcher/oci.go | 267 ++++++++++++++++++++++++++++++++ 8 files changed, 1076 insertions(+) create mode 100644 pkg/fetcher/extract.go create mode 100644 pkg/fetcher/fetcher.go create mode 100644 pkg/fetcher/http.go create mode 100644 pkg/fetcher/local.go create mode 100644 pkg/fetcher/lock.go create mode 100644 pkg/fetcher/npm.go create mode 100644 pkg/fetcher/npm_extract.go create mode 100644 pkg/fetcher/oci.go diff --git a/pkg/fetcher/extract.go b/pkg/fetcher/extract.go new file mode 100644 index 000000000..5a597f07a --- /dev/null +++ b/pkg/fetcher/extract.go @@ -0,0 +1,111 @@ +package fetcher + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// DefaultMaxEntrySize is the default maximum file size for extraction (1GB) +const DefaultMaxEntrySize = 1 << 30 + +// getMaxEntrySize returns the maximum entry size from MAX_ENTRY_SIZE env var or default +func getMaxEntrySize() int64 { + if val := os.Getenv("MAX_ENTRY_SIZE"); val != "" { + if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 { + return size + } + } + return DefaultMaxEntrySize +} + +// extractTarGz extracts a gzipped tarball from a reader to destDir +func extractTarGz(r io.Reader, destDir string) error { + gzr, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + + return extractTar(gzr, destDir) +} + +// extractTarGzBytes extracts a gzipped tarball from bytes to destDir +func extractTarGzBytes(data []byte, destDir string) error { + return extractTarGz(bytes.NewReader(data), destDir) +} + +// extractTar extracts a tarball from a reader to destDir +func extractTar(r io.Reader, destDir string) error { + tr := tar.NewReader(r) + + // Ensure destDir exists + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + target := filepath.Join(destDir, header.Name) + + // Security: prevent path traversal + if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("invalid tar path: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + if err := extractFile(tr, target, header.Mode); err != nil { + return err + } + case tar.TypeSymlink: + // Handle symlinks - validate they don't escape destDir + linkTarget := filepath.Join(filepath.Dir(target), header.Linkname) + if !strings.HasPrefix(filepath.Clean(linkTarget), filepath.Clean(destDir)) { + return fmt.Errorf("symlink escapes destination: %s -> %s", header.Name, header.Linkname) + } + if err := os.Symlink(header.Linkname, target); err != nil { + return err + } + } + } + return nil +} + +// extractFile extracts a single file from tar reader +func extractFile(tr *tar.Reader, path string, mode int64) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(mode)) + if err != nil { + return err + } + defer f.Close() + + // Limit copy size to prevent decompression bombs + // Uses MAX_ENTRY_SIZE env var or defaults to 1GB + maxSize := getMaxEntrySize() + _, err = io.CopyN(f, tr, maxSize) + if err == io.EOF { + return nil + } + return err +} diff --git a/pkg/fetcher/fetcher.go b/pkg/fetcher/fetcher.go new file mode 100644 index 000000000..a5215159a --- /dev/null +++ b/pkg/fetcher/fetcher.go @@ -0,0 +1,66 @@ +package fetcher + +import ( + "context" + "fmt" + "strings" +) + +// Fetcher downloads plugins/artifacts from various sources +type Fetcher struct { + oci *OCIFetcher + http *HTTPFetcher + npm *NPMFetcher +} + +// FetcherOption configures the Fetcher +type FetcherOption func(*Fetcher) + +// New creates a new Fetcher with the given options +func New(opts ...FetcherOption) *Fetcher { + f := &Fetcher{ + oci: NewOCIFetcher(), + http: NewHTTPFetcher(), + npm: NewNPMFetcher(), + } + for _, opt := range opts { + opt(f) + } + return f +} + +// WithOCIOptions configures the OCI fetcher with the given options +func WithOCIOptions(opts ...OCIOption) FetcherOption { + return func(f *Fetcher) { + f.oci = NewOCIFetcher(opts...) + } +} + +// WithNPMOptions configures the NPM fetcher with the given options +func WithNPMOptions(opts ...NPMOption) FetcherOption { + return func(f *Fetcher) { + f.npm = NewNPMFetcher(opts...) + } +} + +// Fetch downloads from a URL and extracts to destDir +func (f *Fetcher) Fetch(ctx context.Context, url string, destDir string) error { + return f.FetchWithIntegrity(ctx, url, destDir, "") +} + +// FetchWithIntegrity downloads from a URL with optional integrity verification +func (f *Fetcher) FetchWithIntegrity(ctx context.Context, url string, destDir string, integrity string) error { + switch { + case strings.HasPrefix(url, "oci://"): + // OCI uses digest in URL for verification, integrity param ignored + return f.oci.Fetch(ctx, strings.TrimPrefix(url, "oci://"), destDir) + case strings.HasPrefix(url, "https://"), strings.HasPrefix(url, "http://"): + return f.http.FetchWithIntegrity(ctx, url, destDir, integrity) + case strings.HasPrefix(url, "@") || strings.Contains(url, "@npm:"): + return f.npm.FetchWithIntegrity(ctx, url, destDir, integrity) + case strings.HasPrefix(url, "file:"): + return copyLocal(strings.TrimPrefix(url, "file:"), destDir) + default: + return fmt.Errorf("unsupported URL scheme: %s", url) + } +} diff --git a/pkg/fetcher/http.go b/pkg/fetcher/http.go new file mode 100644 index 000000000..23fa9106f --- /dev/null +++ b/pkg/fetcher/http.go @@ -0,0 +1,105 @@ +package fetcher + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// HTTPFetcher downloads plugins from HTTP/HTTPS URLs +type HTTPFetcher struct { + client *http.Client +} + +// NewHTTPFetcher creates a new HTTP fetcher +func NewHTTPFetcher() *HTTPFetcher { + return &HTTPFetcher{client: http.DefaultClient} +} + +// Fetch downloads from HTTP URL and extracts to destDir +func (f *HTTPFetcher) Fetch(ctx context.Context, url string, destDir string) error { + return f.FetchWithIntegrity(ctx, url, destDir, "") +} + +// FetchWithIntegrity downloads from HTTP URL with optional integrity verification +func (f *HTTPFetcher) FetchWithIntegrity(ctx context.Context, url string, destDir string, integrity string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := f.client.Do(req) + if err != nil { + return fmt.Errorf("failed to download %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + + // Create dest directory + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + // Detect if it's a tarball and extract, or save directly + contentType := resp.Header.Get("Content-Type") + if isTarball(url, contentType) { + // Read body into memory for integrity check + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + // Verify integrity if provided + if integrity != "" { + if err := verifyIntegrity(data, integrity); err != nil { + return fmt.Errorf("integrity verification failed: %w", err) + } + } + + return extractTarGzBytes(data, destDir) + } + + // Save as single file + filename := filepath.Base(url) + return saveFile(resp.Body, filepath.Join(destDir, filename)) +} + +// isTarball checks if the URL or content type indicates a tarball +func isTarball(url, contentType string) bool { + // Check URL extension + lowerURL := strings.ToLower(url) + if strings.HasSuffix(lowerURL, ".tar.gz") || + strings.HasSuffix(lowerURL, ".tgz") || + strings.HasSuffix(lowerURL, ".tar") { + return true + } + + // Check content type + if strings.Contains(contentType, "application/gzip") || + strings.Contains(contentType, "application/x-gzip") || + strings.Contains(contentType, "application/x-tar") || + strings.Contains(contentType, "application/x-compressed-tar") { + return true + } + + return false +} + +// saveFile saves reader content to a file +func saveFile(r io.Reader, path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, r) + return err +} diff --git a/pkg/fetcher/local.go b/pkg/fetcher/local.go new file mode 100644 index 000000000..43f8d8678 --- /dev/null +++ b/pkg/fetcher/local.go @@ -0,0 +1,70 @@ +package fetcher + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// copyLocal copies a local file or directory to destDir +func copyLocal(src string, destDir string) error { + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("failed to stat %s: %w", src, err) + } + + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + if info.IsDir() { + return copyDir(src, destDir) + } + return copyFile(src, filepath.Join(destDir, filepath.Base(src))) +} + +// copyDir recursively copies a directory +func copyDir(src, dest string) error { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + destPath := filepath.Join(dest, entry.Name()) + + if entry.IsDir() { + if err := os.MkdirAll(destPath, 0755); err != nil { + return err + } + if err := copyDir(srcPath, destPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, destPath); err != nil { + return err + } + } + } + return nil +} + +// copyFile copies a single file +func copyFile(src, dest string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := os.Create(dest) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + return err +} diff --git a/pkg/fetcher/lock.go b/pkg/fetcher/lock.go new file mode 100644 index 000000000..d1f9b4f48 --- /dev/null +++ b/pkg/fetcher/lock.go @@ -0,0 +1,66 @@ +package fetcher + +import ( + "fmt" + "os" + "syscall" + "time" +) + +// FileLock provides file-based locking using flock() syscall. +// This works correctly across containers sharing the same filesystem (PVC). +type FileLock struct { + path string + file *os.File +} + +// NewFileLock creates a new file lock +func NewFileLock(path string) *FileLock { + return &FileLock{path: path} +} + +// Acquire attempts to acquire an exclusive lock with a timeout. +// Uses flock() which is automatically released if the process dies. +func (l *FileLock) Acquire(timeout time.Duration) error { + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("failed to open lock file: %w", err) + } + l.file = f + + // Try non-blocking lock first + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return nil // Got the lock immediately + } + + // Lock is held by another process, wait with timeout + deadline := time.Now().Add(timeout) + for { + if time.Now().After(deadline) { + l.file.Close() + l.file = nil + return fmt.Errorf("timeout waiting for lock %s", l.path) + } + + time.Sleep(100 * time.Millisecond) + + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return nil // Got the lock + } + } +} + +// Release releases the lock +func (l *FileLock) Release() error { + if l.file == nil { + return nil + } + + // Unlock and close + syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) + err := l.file.Close() + l.file = nil + return err +} diff --git a/pkg/fetcher/npm.go b/pkg/fetcher/npm.go new file mode 100644 index 000000000..33f727c61 --- /dev/null +++ b/pkg/fetcher/npm.go @@ -0,0 +1,307 @@ +package fetcher + +import ( + "bufio" + "context" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/json" + "fmt" + "hash" + "io" + "net/http" + "os" + "strings" +) + +// NPMFetcher downloads plugins from NPM registry (no Node.js required) +type NPMFetcher struct { + registryURL string + authToken string + skipIntegrityCheck bool + client *http.Client +} + +// NPMOption configures the NPM fetcher +type NPMOption func(*NPMFetcher) + +// NewNPMFetcher creates a new NPM fetcher +func NewNPMFetcher(opts ...NPMOption) *NPMFetcher { + f := &NPMFetcher{ + registryURL: "https://registry.npmjs.org", + client: http.DefaultClient, + } + for _, opt := range opts { + opt(f) + } + return f +} + +// WithNPMRegistry sets the NPM registry URL +func WithNPMRegistry(url string) NPMOption { + return func(f *NPMFetcher) { + if url != "" { + f.registryURL = strings.TrimSuffix(url, "/") + } + } +} + +// WithNPMAuthToken sets the NPM auth token +func WithNPMAuthToken(token string) NPMOption { + return func(f *NPMFetcher) { + f.authToken = token + } +} + +// WithNPMConfigFile reads registry and auth from .npmrc file +func WithNPMConfigFile(path string) NPMOption { + return func(f *NPMFetcher) { + if path == "" { + return + } + registry, token := parseNPMRC(path) + if registry != "" { + f.registryURL = strings.TrimSuffix(registry, "/") + } + if token != "" { + f.authToken = token + } + } +} + +// WithSkipIntegrityCheck skips integrity verification +func WithSkipIntegrityCheck() NPMOption { + return func(f *NPMFetcher) { + f.skipIntegrityCheck = true + } +} + +// parseNPMRC parses .npmrc file for registry and auth token +func parseNPMRC(path string) (registry, authToken string) { + file, err := os.Open(path) + if err != nil { + return "", "" + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // registry=https://registry.npmjs.org + if strings.HasPrefix(line, "registry") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + registry = strings.Trim(strings.TrimSpace(parts[1]), "\"'") + } + } + + // //registry.npmjs.org/:_authToken=xxx + if strings.Contains(line, ":_authToken=") { + parts := strings.SplitN(line, ":_authToken=", 2) + if len(parts) == 2 { + authToken = strings.Trim(strings.TrimSpace(parts[1]), "\"'") + } + } + } + return registry, authToken +} + +// Fetch downloads NPM package and extracts to destDir +// Supports formats: @scope/package@version, package@version +func (f *NPMFetcher) Fetch(ctx context.Context, pkg string, destDir string) error { + return f.FetchWithIntegrity(ctx, pkg, destDir, "") +} + +// FetchWithIntegrity downloads NPM package with optional integrity verification +func (f *NPMFetcher) FetchWithIntegrity(ctx context.Context, pkg string, destDir string, userIntegrity string) error { + // Parse package name and version + name, version := parseNPMPackage(pkg) + + // If version is "latest", resolve it first + if version == "latest" { + resolvedVersion, err := f.resolveLatestVersion(ctx, name) + if err != nil { + return err + } + version = resolvedVersion + } + + // Get version-specific metadata from registry (includes integrity) + tarballURL, registryIntegrity, err := f.getVersionMetadata(ctx, name, version) + if err != nil { + return err + } + + // Use user-provided integrity if available, otherwise use registry integrity + integrity := userIntegrity + if integrity == "" { + integrity = registryIntegrity + } + + // Download tarball + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tarballURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + if f.authToken != "" { + req.Header.Set("Authorization", "Bearer "+f.authToken) + } + + resp, err := f.client.Do(req) + if err != nil { + return fmt.Errorf("failed to download %s: %w", tarballURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d for %s", resp.StatusCode, tarballURL) + } + + // Read body into memory for integrity check + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read tarball: %w", err) + } + + // Verify integrity if not skipped + if !f.skipIntegrityCheck && integrity != "" { + if err := verifyIntegrity(data, integrity); err != nil { + return fmt.Errorf("integrity verification failed: %w", err) + } + } + + // Extract tarball (npm tarballs have "package/" prefix) + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + return extractTarGzWithStripPrefix(data, destDir, "package/") +} + +// resolveLatestVersion gets the latest version from the full package document +func (f *NPMFetcher) resolveLatestVersion(ctx context.Context, name string) (string, error) { + metaURL := fmt.Sprintf("%s/%s", f.registryURL, name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + if f.authToken != "" { + req.Header.Set("Authorization", "Bearer "+f.authToken) + } + + resp, err := f.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch NPM metadata for %s: %w", name, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("NPM registry returned %d for %s", resp.StatusCode, name) + } + + var meta struct { + DistTags struct { + Latest string `json:"latest"` + } `json:"dist-tags"` + } + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return "", fmt.Errorf("failed to parse NPM metadata: %w", err) + } + + if meta.DistTags.Latest == "" { + return "", fmt.Errorf("no latest version found for %s", name) + } + + return meta.DistTags.Latest, nil +} + +// getVersionMetadata gets the tarball URL and integrity for a specific version +func (f *NPMFetcher) getVersionMetadata(ctx context.Context, name, version string) (tarballURL, integrity string, err error) { + // Use version-specific endpoint to avoid grepping through full package doc + versionURL := fmt.Sprintf("%s/%s/%s", f.registryURL, name, version) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil) + if err != nil { + return "", "", err + } + req.Header.Set("Accept", "application/json") + if f.authToken != "" { + req.Header.Set("Authorization", "Bearer "+f.authToken) + } + + resp, err := f.client.Do(req) + if err != nil { + return "", "", fmt.Errorf("failed to fetch NPM version metadata for %s@%s: %w", name, version, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("NPM registry returned %d for %s@%s", resp.StatusCode, name, version) + } + + var versionMeta struct { + Dist struct { + Tarball string `json:"tarball"` + Integrity string `json:"integrity"` + } `json:"dist"` + } + if err := json.NewDecoder(resp.Body).Decode(&versionMeta); err != nil { + return "", "", fmt.Errorf("failed to parse NPM version metadata: %w", err) + } + + if versionMeta.Dist.Tarball == "" { + return "", "", fmt.Errorf("no tarball URL found for %s@%s", name, version) + } + + return versionMeta.Dist.Tarball, versionMeta.Dist.Integrity, nil +} + +// verifyIntegrity verifies data against an SRI hash (sha256-..., sha384-..., sha512-...) +func verifyIntegrity(data []byte, integrity string) error { + parts := strings.SplitN(integrity, "-", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid integrity format: %s", integrity) + } + + algorithm := parts[0] + expected := parts[1] + + var h hash.Hash + switch algorithm { + case "sha256": + h = sha256.New() + case "sha384": + h = sha512.New384() + case "sha512": + h = sha512.New() + default: + return fmt.Errorf("unsupported integrity algorithm: %s", algorithm) + } + + h.Write(data) + computed := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + if computed != expected { + return fmt.Errorf("integrity mismatch: expected %s, got %s", expected, computed) + } + + return nil +} + +// parseNPMPackage parses package name and version from various formats +func parseNPMPackage(pkg string) (name, version string) { + // Handle @npm: prefix + pkg = strings.TrimPrefix(pkg, "@npm:") + + // Handle @scope/package@version or package@version + // Find last @ that separates version (not the scope @) + if idx := strings.LastIndex(pkg, "@"); idx > 0 { + return pkg[:idx], pkg[idx+1:] + } + return pkg, "latest" +} diff --git a/pkg/fetcher/npm_extract.go b/pkg/fetcher/npm_extract.go new file mode 100644 index 000000000..eea06b67e --- /dev/null +++ b/pkg/fetcher/npm_extract.go @@ -0,0 +1,84 @@ +package fetcher + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// extractTarGzWithStripPrefix extracts a gzipped tarball, stripping a path prefix from entries +// This is useful for npm tarballs which have "package/" prefix +func extractTarGzWithStripPrefix(data []byte, destDir, stripPrefix string) error { + gzr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + + return extractTarWithStripPrefix(gzr, destDir, stripPrefix) +} + +// extractTarWithStripPrefix extracts a tarball, stripping a path prefix from entries +func extractTarWithStripPrefix(r io.Reader, destDir, stripPrefix string) error { + tr := tar.NewReader(r) + + // Ensure destDir exists + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + // Strip prefix from name + name := header.Name + if stripPrefix != "" && strings.HasPrefix(name, stripPrefix) { + name = strings.TrimPrefix(name, stripPrefix) + } + if name == "" || name == "." { + continue + } + + target := filepath.Join(destDir, name) + + // Security: prevent path traversal + if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("invalid tar path: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + if err := extractFile(tr, target, header.Mode); err != nil { + return err + } + case tar.TypeSymlink: + // Handle symlinks - validate they don't escape destDir + linkTarget := filepath.Join(filepath.Dir(target), header.Linkname) + if !strings.HasPrefix(filepath.Clean(linkTarget), filepath.Clean(destDir)) { + return fmt.Errorf("symlink escapes destination: %s -> %s", header.Name, header.Linkname) + } + if err := os.Symlink(header.Linkname, target); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/fetcher/oci.go b/pkg/fetcher/oci.go new file mode 100644 index 000000000..35d3599a8 --- /dev/null +++ b/pkg/fetcher/oci.go @@ -0,0 +1,267 @@ +package fetcher + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// PluginAnnotation is the annotation key that identifies valid plugin artifacts +const PluginAnnotation = "io.backstage.dynamic-packages" + +// OCIFetcher fetches artifacts from OCI registries +type OCIFetcher struct { + transport http.RoundTripper + keychain authn.Keychain + validatePlugin bool // if true, validate io.backstage.dynamic-packages annotation +} + +// OCIOption configures the OCIFetcher +type OCIOption func(*OCIFetcher) + +// WithInsecure disables TLS certificate verification +func WithInsecure() OCIOption { + return func(c *OCIFetcher) { + c.transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + } + } +} + +// WithCACert adds a custom CA certificate +func WithCACert(caCert []byte) OCIOption { + return func(c *OCIFetcher) { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caCert) + c.transport = &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool}, + } + } +} + +// WithDockerConfig sets authentication from dockerconfigjson +func WithDockerConfig(dockerConfig []byte) OCIOption { + return func(c *OCIFetcher) { + kc, err := newDockerConfigKeychain(dockerConfig) + if err == nil { + c.keychain = kc + } + } +} + +// dockerConfigKeychain implements authn.Keychain using dockerconfigjson +type dockerConfigKeychain struct { + auths map[string]dockerAuthConfig +} + +type dockerAuthConfig struct { + Auth string `json:"auth"` + Username string `json:"username"` + Password string `json:"password"` +} + +type dockerConfig struct { + Auths map[string]dockerAuthConfig `json:"auths"` +} + +func newDockerConfigKeychain(data []byte) (*dockerConfigKeychain, error) { + var cfg dockerConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &dockerConfigKeychain{auths: cfg.Auths}, nil +} + +func (k *dockerConfigKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) { + registry := target.RegistryStr() + + // Try exact match first + if auth, ok := k.auths[registry]; ok { + return authToAuthenticator(auth) + } + + // Try with https:// prefix + if auth, ok := k.auths["https://"+registry]; ok { + return authToAuthenticator(auth) + } + + // Try with /v1/ suffix (Docker Hub style) + if auth, ok := k.auths["https://"+registry+"/v1/"]; ok { + return authToAuthenticator(auth) + } + + return authn.Anonymous, nil +} + +func authToAuthenticator(auth dockerAuthConfig) (authn.Authenticator, error) { + if auth.Username != "" && auth.Password != "" { + return authn.FromConfig(authn.AuthConfig{ + Username: auth.Username, + Password: auth.Password, + }), nil + } + + if auth.Auth != "" { + decoded, err := base64.StdEncoding.DecodeString(auth.Auth) + if err != nil { + return nil, err + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + return authn.FromConfig(authn.AuthConfig{ + Username: parts[0], + Password: parts[1], + }), nil + } + } + + return authn.Anonymous, nil +} + +// WithPluginValidation enables plugin artifact validation. +// When enabled, Fetch will verify the io.backstage.dynamic-packages annotation exists. +// Use this for plugin artifacts; skip for catalog index images. +func WithPluginValidation() OCIOption { + return func(c *OCIFetcher) { + c.validatePlugin = true + } +} + +// NewOCIFetcher creates a new OCI fetcher +func NewOCIFetcher(opts ...OCIOption) *OCIFetcher { + c := &OCIFetcher{ + transport: http.DefaultTransport, + keychain: authn.DefaultKeychain, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// FetchResult contains the result of fetching an artifact +type FetchResult struct { + // Digest is the artifact digest (sha256:...) + Digest string + + // Content is the extracted artifact content + Content []byte + + // MediaType of the artifact + MediaType string +} + +// Fetch downloads an OCI artifact and extracts it to destDir +func (c *OCIFetcher) Fetch(ctx context.Context, ref string, destDir string) error { + result, err := c.FetchContent(ctx, ref) + if err != nil { + return err + } + + // Extract tarball content to destDir + return extractTarGzBytes(result.Content, destDir) +} + +// FetchContent downloads an OCI artifact and returns its content +func (c *OCIFetcher) FetchContent(ctx context.Context, ref string) (*FetchResult, error) { + // 1. Parse reference + imgRef, err := name.ParseReference(ref) + if err != nil { + return nil, fmt.Errorf("invalid OCI reference %q: %w", ref, err) + } + + // 2. Configure remote options + remoteOpts := []remote.Option{ + remote.WithContext(ctx), + remote.WithAuthFromKeychain(c.keychain), + } + if c.transport != nil { + remoteOpts = append(remoteOpts, remote.WithTransport(c.transport)) + } + + // 3. Fetch artifact + desc, err := remote.Get(imgRef, remoteOpts...) + if err != nil { + return nil, fmt.Errorf("failed to fetch %q: %w", ref, err) + } + + // 4. Handle image vs artifact + img, err := desc.Image() + if err != nil { + return nil, fmt.Errorf("failed to get image: %w", err) + } + + // 5. Validate plugin artifact (optional, for plugins only) + if c.validatePlugin { + if err := validatePluginArtifact(img); err != nil { + return nil, fmt.Errorf("validation failed for %q: %w", ref, err) + } + } + + // 6. Extract content from first layer + layers, err := img.Layers() + if err != nil { + return nil, fmt.Errorf("failed to get layers: %w", err) + } + if len(layers) == 0 { + return nil, fmt.Errorf("artifact has no layers") + } + + reader, err := layers[0].Uncompressed() + if err != nil { + return nil, fmt.Errorf("failed to uncompress layer: %w", err) + } + defer reader.Close() + + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read layer: %w", err) + } + + digest, err := img.Digest() + if err != nil { + return nil, fmt.Errorf("failed to get digest: %w", err) + } + + return &FetchResult{ + Digest: digest.String(), + Content: content, + }, nil +} + +// validatePluginArtifact checks that the image has the required plugin annotation. +// Returns nil if valid, error if the annotation is missing. +func validatePluginArtifact(img v1.Image) error { + manifest, err := img.Manifest() + if err != nil { + return fmt.Errorf("failed to get manifest: %w", err) + } + + // Check manifest annotations + if manifest.Annotations != nil { + if _, ok := manifest.Annotations[PluginAnnotation]; ok { + return nil + } + } + + // Check config labels (some builders put annotations there) + configFile, err := img.ConfigFile() + if err == nil && configFile != nil && configFile.Config.Labels != nil { + if _, ok := configFile.Config.Labels[PluginAnnotation]; ok { + return nil + } + } + + return fmt.Errorf("not a valid plugin artifact (missing %s annotation)", PluginAnnotation) +} From 1329517dba2aa8639364fe029ccb3a80ed2b23a5 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 6 Aug 2026 18:52:44 +0300 Subject: [PATCH 02/28] fetcher --- cmd/plugin-fetch/main.go | 372 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 cmd/plugin-fetch/main.go diff --git a/cmd/plugin-fetch/main.go b/cmd/plugin-fetch/main.go new file mode 100644 index 000000000..3d13a61c1 --- /dev/null +++ b/cmd/plugin-fetch/main.go @@ -0,0 +1,372 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/redhat-developer/rhdh-operator/pkg/fetcher" + "github.com/redhat-developer/rhdh-operator/pkg/utils" +) + +// Environment variables +const ( + InputFileEnvVar = "INPUT_FILE" // Package list file (default: /input/packages.txt) + OutputDirEnvVar = "OUTPUT_DIR" // Output directory (default: /dynamic-plugins-root) + DockerConfigEnvVar = "DOCKER_CONFIG" // Path to dockerconfigjson file + CAFileEnvVar = "CA_FILE" // Path to CA certificate file + InsecureEnvVar = "INSECURE" // Skip TLS verification (true/false) + ValidatePluginsEnvVar = "VALIDATE_PLUGINS" // Validate plugin artifacts (default: true) + ParallelEnvVar = "PARALLEL" // Number of parallel downloads (default: 4) + LockFileEnvVar = "LOCK_FILE" // Lock file path (default: OUTPUT_DIR/install-dynamic-plugins.lock) + TerminationLogEnvVar = "TERMINATION_LOG" // Kubernetes termination log + + // Catalog index for Extensions UI + CatalogIndexImageEnvVar = "CATALOG_INDEX_IMAGE" // OCI image containing catalog entities + CatalogEntitiesExtractDirEnvVar = "CATALOG_ENTITIES_EXTRACT_DIR" // Where to extract catalog entities (default: /tmp/extensions) + + // NPM configuration + SkipIntegrityCheckEnvVar = "SKIP_INTEGRITY_CHECK" // Skip integrity verification (default: false) + NPMRegistryEnvVar = "NPM_REGISTRY" // NPM registry URL (default: https://registry.npmjs.org) + NPMAuthTokenEnvVar = "NPM_AUTH_TOKEN" // NPM auth token + NPMConfigEnvVar = "NPM_CONFIG_USERCONFIG" // Path to .npmrc file +) + +// Package represents a plugin package with optional integrity hash +type Package struct { + URL string + Integrity string +} + +func main() { + // Read configuration from environment + inputFile := utils.StringEnvVar(InputFileEnvVar, "/input/packages.txt") + outputDir := utils.StringEnvVar(OutputDirEnvVar, "/dynamic-plugins-root") + dockerConfig := utils.StringEnvVar(DockerConfigEnvVar, "") + caFile := utils.StringEnvVar(CAFileEnvVar, "") + insecure := utils.BoolEnvVar(InsecureEnvVar, false) + validate := utils.BoolEnvVar(ValidatePluginsEnvVar, true) + parallel := utils.IntEnvVar(ParallelEnvVar, 4) + lockFile := utils.StringEnvVar(LockFileEnvVar, filepath.Join(outputDir, "install-dynamic-plugins.lock")) + termLog := utils.StringEnvVar(TerminationLogEnvVar, "/dev/termination-log") + + // Catalog index settings + catalogIndexImage := utils.StringEnvVar(CatalogIndexImageEnvVar, "") + catalogEntitiesDir := utils.StringEnvVar(CatalogEntitiesExtractDirEnvVar, "/tmp/extensions") + + // NPM settings + skipIntegrityCheck := utils.BoolEnvVar(SkipIntegrityCheckEnvVar, false) + npmRegistry := utils.StringEnvVar(NPMRegistryEnvVar, "") + npmAuthToken := utils.StringEnvVar(NPMAuthTokenEnvVar, "") + npmConfigFile := utils.StringEnvVar(NPMConfigEnvVar, "") + + // Handle signals + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-sigCh + cancel() + }() + + // Ensure output directory exists (needed for lock file) + if err := os.MkdirAll(outputDir, 0755); err != nil { + fatal(termLog, "Failed to create output directory: %v", err) + } + + // Acquire lock + lock := fetcher.NewFileLock(lockFile) + if err := lock.Acquire(5 * time.Minute); err != nil { + fatal(termLog, "Failed to acquire lock: %v", err) + } + defer func() { + if err := lock.Release(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to release lock: %v\n", err) + } + }() + + // Build OCI options + var ociOpts []fetcher.OCIOption + if insecure { + ociOpts = append(ociOpts, fetcher.WithInsecure()) + } + if caFile != "" { + caCert, err := os.ReadFile(caFile) + if err != nil { + fatal(termLog, "Error reading CA file: %v", err) + } + ociOpts = append(ociOpts, fetcher.WithCACert(caCert)) + } + if dockerConfig != "" { + secret, err := os.ReadFile(dockerConfig) + if err != nil { + fatal(termLog, "Error reading docker config: %v", err) + } + ociOpts = append(ociOpts, fetcher.WithDockerConfig(secret)) + } + if validate { + ociOpts = append(ociOpts, fetcher.WithPluginValidation()) + } + + // Build NPM options + var npmOpts []fetcher.NPMOption + if npmRegistry != "" { + npmOpts = append(npmOpts, fetcher.WithNPMRegistry(npmRegistry)) + } + if npmAuthToken != "" { + npmOpts = append(npmOpts, fetcher.WithNPMAuthToken(npmAuthToken)) + } + if npmConfigFile != "" { + npmOpts = append(npmOpts, fetcher.WithNPMConfigFile(npmConfigFile)) + } + if skipIntegrityCheck { + npmOpts = append(npmOpts, fetcher.WithSkipIntegrityCheck()) + } + + // Create fetcher + f := fetcher.New( + fetcher.WithOCIOptions(ociOpts...), + fetcher.WithNPMOptions(npmOpts...), + ) + + // Extract catalog entities from catalog index image (for Extensions UI) + if catalogIndexImage != "" { + fmt.Printf("=== Extracting catalog entities from %s ===\n", catalogIndexImage) + if err := extractCatalogEntities(ctx, f, catalogIndexImage, catalogEntitiesDir, ociOpts); err != nil { + fmt.Fprintf(os.Stderr, "WARNING: Failed to extract catalog index: %v\n", err) + } + } else { + fmt.Println("=== CATALOG_INDEX_IMAGE not set, skipping catalog entities extraction") + } + + // Read packages from input file + packages, err := readPackages(inputFile) + if err != nil { + fatal(termLog, "Error reading input file: %v", err) + } + + if len(packages) == 0 { + fmt.Println("No packages to install") + return + } + + fmt.Printf("=== Downloading %d plugins to %s (%d parallel) ===\n\n", len(packages), outputDir, parallel) + + // Process packages in parallel + if err := processParallel(ctx, f, packages, outputDir, parallel); err != nil { + fatal(termLog, "Error installing plugins: %v", err) + } + + fmt.Printf("\n=== Complete ===\n") + fmt.Printf("Successfully installed %d plugins\n", len(packages)) +} + +// extractCatalogEntities extracts catalog-entities from an OCI image +func extractCatalogEntities(ctx context.Context, f *fetcher.Fetcher, image, destDir string, ociOpts []fetcher.OCIOption) error { + // Create OCI fetcher without plugin validation (catalog index is not a plugin) + ociFetcher := fetcher.NewOCIFetcher(ociOpts...) + + tmpDir, err := os.MkdirTemp("", "catalog-index-") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Fetch and extract OCI image + if err := ociFetcher.Fetch(ctx, strings.TrimPrefix(image, "oci://"), tmpDir); err != nil { + return fmt.Errorf("failed to fetch catalog index: %w", err) + } + + // Look for catalog-entities/extensions or catalog-entities + var entitiesSrc string + if info, err := os.Stat(filepath.Join(tmpDir, "catalog-entities", "extensions")); err == nil && info.IsDir() { + entitiesSrc = filepath.Join(tmpDir, "catalog-entities", "extensions") + } else if info, err := os.Stat(filepath.Join(tmpDir, "catalog-entities")); err == nil && info.IsDir() { + entitiesSrc = filepath.Join(tmpDir, "catalog-entities") + } + + if entitiesSrc == "" { + return fmt.Errorf("no catalog-entities found in %s", image) + } + + // Copy to destination + entitiesDest := filepath.Join(destDir, "catalog-entities") + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + os.RemoveAll(entitiesDest) // Remove old if exists + + if err := copyDir(entitiesSrc, entitiesDest); err != nil { + return fmt.Errorf("failed to copy catalog entities: %w", err) + } + + // Count YAML files + count := 0 + filepath.Walk(entitiesDest, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() && (strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")) { + count++ + } + return nil + }) + + fmt.Printf("Catalog entities extracted to %s (%d files)\n", entitiesDest, count) + return nil +} + +// copyDir recursively copies a directory +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, 0755) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dstPath, data, info.Mode()) + }) +} + +func readPackages(path string) ([]Package, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var packages []Package + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Handle "url [integrity]" format + parts := strings.Fields(line) + pkg := Package{URL: parts[0]} + if len(parts) > 1 { + pkg.Integrity = parts[1] + } + packages = append(packages, pkg) + } + return packages, scanner.Err() +} + +func processParallel(ctx context.Context, f *fetcher.Fetcher, packages []Package, outputDir string, parallel int) error { + sem := make(chan struct{}, parallel) + var wg sync.WaitGroup + var firstErr error + var errOnce sync.Once + + for _, pkg := range packages { + select { + case <-ctx.Done(): + return ctx.Err() + case sem <- struct{}{}: + } + + wg.Add(1) + go func(pkg Package) { + defer wg.Done() + defer func() { <-sem }() + + name := pluginName(pkg.URL) + destDir := filepath.Join(outputDir, name) + + // Skip if already exists + if info, err := os.Stat(destDir); err == nil && info.IsDir() { + entries, _ := os.ReadDir(destDir) + if len(entries) > 0 { + fmt.Printf("[SKIP] %s (exists)\n", name) + return + } + } + + fmt.Printf("[DOWN] %s\n", pkg.URL) + + if err := f.FetchWithIntegrity(ctx, pkg.URL, destDir, pkg.Integrity); err != nil { + fmt.Fprintf(os.Stderr, "[FAIL] %s: %v\n", name, err) + errOnce.Do(func() { + firstErr = fmt.Errorf("%s: %w", name, err) + }) + return + } + fmt.Printf("[DONE] %s\n", name) + }(pkg) + } + + wg.Wait() + return firstErr +} + +// pluginName extracts a directory name from various URL formats +func pluginName(url string) string { + // Remove scheme prefixes + url = strings.TrimPrefix(url, "oci://") + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + url = strings.TrimPrefix(url, "file://") + url = strings.TrimPrefix(url, "file:") + url = strings.TrimPrefix(url, "@npm:") + + // Remove digest/tag suffix + if idx := strings.Index(url, "@sha256:"); idx > 0 { + url = url[:idx] + } + if idx := strings.LastIndex(url, "@"); idx > 0 { + // For npm packages like @backstage/plugin-foo@1.0.0 + // Keep @backstage/plugin-foo, remove @1.0.0 + beforeAt := url[:idx] + if !strings.HasPrefix(beforeAt, "@") || strings.Contains(beforeAt[1:], "/") { + url = beforeAt + } + } + if idx := strings.LastIndex(url, ":"); idx > 0 { + // Remove tag like :latest or :v1.0.0 + url = url[:idx] + } + + // Get the last path component + name := filepath.Base(url) + + // Clean up common suffixes + name = strings.TrimSuffix(name, ".tgz") + name = strings.TrimSuffix(name, ".tar.gz") + name = strings.TrimSuffix(name, ".tar") + + return name +} + +func writeTerminationLog(path, msg string) { + if len(msg) > 4096 { + msg = msg[:4096] + } + _ = os.WriteFile(path, []byte(msg), 0644) +} + +func fatal(termLog, format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + writeTerminationLog(termLog, msg) + fmt.Fprintln(os.Stderr, msg) + os.Exit(1) +} From d8dcc1fd5a85dcfe808047f0d4806e60191fd5b3 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 10 Aug 2026 16:28:58 +0300 Subject: [PATCH 03/28] deps --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- go.mod | 8 ++++++++ go.sum | 18 ++++++++++++++++-- pkg/utils/pod-mutator.go | 16 ---------------- pkg/utils/utils.go | 16 ++++++++++++++++ 9 files changed, 45 insertions(+), 23 deletions(-) delete mode 100644 pkg/utils/pod-mutator.go diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9b7acdd38..ca4d7a346 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index c3ecd6b25..1357c08bd 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index b315b789e..6613a8896 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index c9c126a0f..9c86c46ee 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 0e0086d44..1ee510162 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha5 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/go.mod b/go.mod index 7756b5c5a..df9578a4f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 toolchain go1.26.5 require ( + github.com/google/go-containerregistry v0.21.9 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/openshift/api v0.0.0-20260724095150-18550f1a6d13 @@ -34,6 +35,8 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/cli v29.6.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -68,17 +71,21 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.19.1 // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/openshift/library-go v0.0.0-20260213153706-03f1709971c5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect @@ -115,6 +122,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/apiserver v0.35.4 // indirect k8s.io/component-base v0.35.4 // indirect k8s.io/kube-openapi v0.0.0-20260519202549-bbf5c5577288 // indirect diff --git a/go.sum b/go.sum index 19e69a33e..c7689307f 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= @@ -94,6 +98,8 @@ github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4y github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs= +github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -111,8 +117,8 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -141,6 +147,10 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/openshift/api v0.0.0-20260724095150-18550f1a6d13 h1:xDz0RoMp1GYVmA93Hht8CQxN8rFAADif8H6CtKkHEYk= github.com/openshift/api v0.0.0-20260724095150-18550f1a6d13/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e h1:k89oIo2EjX0PRSdi1kesktCyWp50SC9WwKurvupvRGs= @@ -165,6 +175,8 @@ github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlT github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -270,6 +282,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= k8s.io/apiextensions-apiserver v0.35.4 h1:HeP+Upp7ItdvnyGmub0yoix+2z5+ev4M5cE5TCgtOUU= diff --git a/pkg/utils/pod-mutator.go b/pkg/utils/pod-mutator.go deleted file mode 100644 index 2b4380bd7..000000000 --- a/pkg/utils/pod-mutator.go +++ /dev/null @@ -1,16 +0,0 @@ -package utils - -import ( - corev1 "k8s.io/api/core/v1" -) - -// sets pullSecret for Pod -func SetImagePullSecrets(podSpec *corev1.PodSpec, pullSecrets []string) { - if pullSecrets == nil { - return - } - podSpec.ImagePullSecrets = []corev1.LocalObjectReference{} - for _, ps := range pullSecrets { - podSpec.ImagePullSecrets = append(podSpec.ImagePullSecrets, corev1.LocalObjectReference{Name: ps}) - } -} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index e40ffd733..35288cb24 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -257,6 +257,22 @@ func BoolEnvVar(envvar string, def bool) bool { return def } +func IntEnvVar(envvar string, def int) int { + if envValue, ok := os.LookupEnv(envvar); ok { + if ret, err := strconv.Atoi(envValue); err == nil { + return ret + } + } + return def +} + +func StringEnvVar(envvar string, def string) string { + if envValue, ok := os.LookupEnv(envvar); ok { + return envValue + } + return def +} + // ParseCommaSeparatedString parses a comma-separated string into a slice of strings. // It trims whitespace from each part and ignores empty parts. func ParseCommaSeparated(input string) []string { From 47b948abeff169720c126cb3c2c71f6f7f92f505 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 20 Aug 2026 11:16:26 +0300 Subject: [PATCH 04/28] devhub catalog support --- Makefile | 45 +++-- api/current-types.go | 6 + api/v1alpha5/backstage_types.go | 8 +- api/v1alpha5/devhubplugincatalog_types.go | 61 +++++++ api/v1alpha5/zz_generated.deepcopy.go | 100 +++++++++++ ...kstage-operator.clusterserviceversion.yaml | 52 +++++- .../rhdh-default-config_v1_configmap.yaml | 4 +- cmd/main.go | 27 +++ cmd/plugin-fetch/{main.go => main.go.1} | 0 config/crd/kustomization.yaml | 1 + config/manager/deployment.yaml | 15 +- .../profile/rhdh/default-config/catalog.yaml | 8 + .../rhdh/default-config/deployment.yaml | 2 +- config/profile/rhdh/kustomization.yaml | 5 +- .../rhdh/patches/deployment-patch.yaml | 10 +- config/rbac/role.yaml | 8 + dist/rhdh/install.yaml | 159 +++++++++++++++++- go.sum | 52 +++--- internal/controller/backstage_controller.go | 32 ++++ .../devhubplugincatalog_controller.go | 136 +++++++++++++++ pkg/catalog/processor.go | 150 +++++++++++++++++ pkg/fetcher/extract.go | 5 + pkg/fetcher/{fetcher.go => fetcher.go.1} | 0 pkg/fetcher/{http.go => http.go.1} | 0 pkg/fetcher/{local.go => local.go.1} | 0 pkg/fetcher/{lock.go => lock.go.1} | 0 pkg/fetcher/{npm.go => npm.go.1} | 0 .../{npm_extract.go => npm_extract.go.1} | 0 pkg/fetcher/oci.go | 6 +- pkg/model/deployment.go | 5 +- pkg/model/deployment_test.go | 2 +- pkg/model/dynamic-plugins.go | 14 +- pkg/model/model_tests.go | 2 +- plugin-installer/README.md | 2 +- 34 files changed, 826 insertions(+), 91 deletions(-) create mode 100644 api/v1alpha5/devhubplugincatalog_types.go rename cmd/plugin-fetch/{main.go => main.go.1} (100%) create mode 100644 config/profile/rhdh/default-config/catalog.yaml create mode 100644 internal/controller/devhubplugincatalog_controller.go create mode 100644 pkg/catalog/processor.go rename pkg/fetcher/{fetcher.go => fetcher.go.1} (100%) rename pkg/fetcher/{http.go => http.go.1} (100%) rename pkg/fetcher/{local.go => local.go.1} (100%) rename pkg/fetcher/{lock.go => lock.go.1} (100%) rename pkg/fetcher/{npm.go => npm.go.1} (100%) rename pkg/fetcher/{npm_extract.go => npm_extract.go.1} (100%) diff --git a/Makefile b/Makefile index 94d992541..5d7858a37 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,8 @@ PROFILE ?= rhdh # Enable operator dynamic plugins processing (default: true) OPERATOR_DP_PROCESSING ?= true # Install dynamic plugins image (required when OPERATOR_DP_PROCESSING=true) -# INSTALL_DP_IMAGE ?= quay.io/rhdh-community/plugin-installer:next -INSTALL_DP_IMAGE ?= quay.io/gazarenk/rhdh-plugin-installer:next +# RELATED_IMAGE_plugin_installer ?= quay.io/rhdh-community/plugin-installer:next +RELATED_IMAGE_plugin_installer ?= quay.io/gazarenk/rhdh-plugin-installer:next PROFILE_SHORT := $(shell echo $(PROFILE) | cut -d. -f1) # VERSION defines the project version for the bundle. @@ -34,15 +34,15 @@ IMAGE_TAG_VERSION = $(VERSION) ifeq ($(PROFILE), rhdh) # Profile-specific settings - ifeq ($(DEFAULT_VERSION), true) - # Keep the same major version as VERSION (community and RHDH are aligned). - # Strip the patch for IMAGE_TAG_VERSION (x.y.z => x.y) to match downstream - # quay.io/rhdh image tags. Only if VERSION was not explicitly overridden. - IMAGE_TAG_VERSION := $(shell echo $(VERSION) | cut -d. -f1,2) - endif +# ifeq ($(DEFAULT_VERSION), true) +# # Keep the same major version as VERSION (community and RHDH are aligned). +# # Strip the patch for IMAGE_TAG_VERSION (x.y.z => x.y) to match downstream +# # quay.io/rhdh image tags. Only if VERSION was not explicitly overridden. +# IMAGE_TAG_VERSION := $(shell echo $(VERSION) | cut -d. -f1,2) +# endif # IMAGE_TAG_BASE ?= registry.redhat.io/rhdh/rhdh-rhel9-operator - IMAGE_TAG_BASE ?= quay.io/rhdh/rhdh-rhel9-operator + IMAGE_TAG_BASE ?= quay.io/rhdh-community/operator DEFAULT_CHANNEL ?= fast CHANNELS ?= fast,fast-\$${CI_X_VERSION}.\$${CI_Y_VERSION} BUNDLE_METADATA_PACKAGE_NAME ?= rhdh @@ -171,12 +171,12 @@ fmt: goimports ## Format the code using goimports .PHONY: test test: manifests generate fmt vet setup-envtest $(LOCALBIN) ## Run tests. We need LOCALBIN=$(LOCALBIN) to get correct default-config path @OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) ./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) - OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) INSTALL_DP_IMAGE=$(INSTALL_DP_IMAGE) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $(PKGS) -coverprofile cover.out + OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $(PKGS) -coverprofile cover.out .PHONY: integration-test integration-test: ginkgo manifests generate fmt vet envtest $(LOCALBIN) ## Run integration_tests. We need LOCALBIN=$(LOCALBIN) to get correct default-config path @OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) ./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) - OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) INSTALL_DP_IMAGE=$(INSTALL_DP_IMAGE) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -v -r $(ARGS) integration_tests + OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -v -r $(ARGS) integration_tests # After this time, Ginkgo will emit progress reports, so we can get visibility into long-running tests. POLL_PROGRESS_INTERVAL := 600s @@ -236,7 +236,7 @@ build: manifests generate fmt vet ## Build manager binary. .PHONY: run run: manifests generate fmt vet $(LOCALBIN) ## Run a controller from your host. @OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) ./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) - OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) INSTALL_DP_IMAGE=$(INSTALL_DP_IMAGE) go run -C $(LOCALBIN) ../cmd/main.go $(ARGS) + OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) go run -C $(LOCALBIN) ../cmd/main.go $(ARGS) .PHONY: local-dynamic-plugins local-dynamic-plugins: ## Generate local-test dynamic-plugins.yaml from catalog-index image for local testing @@ -261,15 +261,15 @@ image-push: ## Push container image with the manager. .PHONY: install-dp-build install-dp-build: ## Build the plugin installer image (skopeo variant, single platform) - $(CONTAINER_TOOL) build --platform $(PLATFORM) -t $(INSTALL_DP_IMAGE) --label $(LABEL) -f plugin-installer/Dockerfile.skopeo . + $(CONTAINER_TOOL) build --platform $(PLATFORM) -t $(RELATED_IMAGE_plugin_installer) --label $(LABEL) -f plugin-installer/Dockerfile.skopeo . .PHONY: install-dp-buildx install-dp-buildx: ## Build and push multiplatform plugin installer image (skopeo variant) - $(CONTAINER_TOOL) buildx build --push --platform=$(MIN_PLATFORMS) -t $(INSTALL_DP_IMAGE) --label $(LABEL) -f plugin-installer/Dockerfile.skopeo . + $(CONTAINER_TOOL) buildx build --push --platform=$(MIN_PLATFORMS) -t $(RELATED_IMAGE_plugin_installer) --label $(LABEL) -f plugin-installer/Dockerfile.skopeo . .PHONY: install-dp-push install-dp-push: ## Push the plugin installer image - $(CONTAINER_TOOL) push $(INSTALL_DP_IMAGE) + $(CONTAINER_TOOL) push $(RELATED_IMAGE_plugin_installer) # PLATFORMS defines the target platforms for the manager image be built to provide support to multiple # architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: @@ -300,6 +300,9 @@ build-installer: manifests generate kustomize ## Generate a consolidated YAML wi mkdir -p dist/$(PROFILE) cd config/profile/$(PROFILE) && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/profile/$(PROFILE) > dist/$(PROFILE)/install.yaml + @sed $(SED_I) 's|quay.io/rhdh/plugin-catalog-index:next|$(CATALOG_INDEX_IMAGE)|g' dist/$(PROFILE)/install.yaml + @sed $(SED_I) 's|{{OPERATOR_DP_PROCESSING}}|$(OPERATOR_DP_PROCESSING)|g' dist/$(PROFILE)/install.yaml + @sed $(SED_I) 's|{{RELATED_IMAGE_plugin_installer}}|$(RELATED_IMAGE_plugin_installer)|g' dist/$(PROFILE)/install.yaml @echo "Generated operator installer manifest: dist/$(PROFILE)/install.yaml" .PHONY: deployment-manifest @@ -328,7 +331,11 @@ bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metada --input-dir ./config/manifests/$(PROFILE)/ \ --output-dir ./config/manifests/$(PROFILE)/ cd config/profile/$(PROFILE) && $(KUSTOMIZE) edit set image controller=$(IMG) - $(KUSTOMIZE) build config/manifests/$(PROFILE) | $(OPERATOR_SDK) generate bundle --kustomize-dir config/manifests/$(PROFILE) $(BUNDLE_GEN_FLAGS) + $(KUSTOMIZE) build config/manifests/$(PROFILE) | \ + sed 's|quay.io/rhdh/plugin-catalog-index:next|$(CATALOG_INDEX_IMAGE)|g' | \ + sed 's|{{OPERATOR_DP_PROCESSING}}|$(OPERATOR_DP_PROCESSING)|g' | \ + sed 's|{{RELATED_IMAGE_plugin_installer}}|$(RELATED_IMAGE_plugin_installer)|g' | \ + $(OPERATOR_SDK) generate bundle --kustomize-dir config/manifests/$(PROFILE) $(BUNDLE_GEN_FLAGS) @mv -f bundle.Dockerfile ./bundle/$(PROFILE)/bundle.Dockerfile @sed $(SED_I) 's/backstage-operator.v$(VERSION)/$(PROFILE_SHORT)-operator.v$(VERSION)/g' ./bundle/$(PROFILE)/manifests/backstage-operator.clusterserviceversion.yaml @sed $(SED_I) 's/backstage-operator/$(BUNDLE_METADATA_PACKAGE_NAME)/g' ./bundle/$(PROFILE)/metadata/annotations.yaml @@ -389,7 +396,11 @@ uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified .PHONY: deploy deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. cd config/profile/$(PROFILE) && $(KUSTOMIZE) edit set image controller=${IMG} - $(KUSTOMIZE) build config/profile/$(PROFILE) | $(KUBECTL) apply -f - + $(KUSTOMIZE) build config/profile/$(PROFILE) | \ + sed 's|quay.io/rhdh/plugin-catalog-index:next|$(CATALOG_INDEX_IMAGE)|g' | \ + sed 's|{{OPERATOR_DP_PROCESSING}}|$(OPERATOR_DP_PROCESSING)|g' | \ + sed 's|{{RELATED_IMAGE_plugin_installer}}|$(RELATED_IMAGE_plugin_installer)|g' | \ + $(KUBECTL) apply -f - .PHONY: undeploy undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. diff --git a/api/current-types.go b/api/current-types.go index 37183fbf0..4ce70f52e 100644 --- a/api/current-types.go +++ b/api/current-types.go @@ -45,6 +45,12 @@ type ( // Other types TLS = bsv1.TLS + + // DevHubPluginCatalog types + DevHubPluginCatalog = bsv1.DevHubPluginCatalog + DevHubPluginCatalogSpec = bsv1.DevHubPluginCatalogSpec + DevHubPluginCatalogList = bsv1.DevHubPluginCatalogList + CatalogSource = bsv1.CatalogSource ) // Condition constants diff --git a/api/v1alpha5/backstage_types.go b/api/v1alpha5/backstage_types.go index ee9a0ed44..04671fdd5 100644 --- a/api/v1alpha5/backstage_types.go +++ b/api/v1alpha5/backstage_types.go @@ -366,10 +366,6 @@ type Flavour struct { Enabled bool `json:"enabled,omitempty"` } -func init() { - SchemeBuilder.Register(&Backstage{}, &BackstageList{}) -} - // IsLocalDbEnabled returns true if Local database is configured and enabled func (s *BackstageSpec) IsLocalDbEnabled() bool { if s.Database == nil { @@ -396,3 +392,7 @@ func (s *BackstageSpec) IsAuthSecretSpecified() bool { func (s *BackstageSpec) IsMonitoringEnabled() bool { return s.Monitoring.Enabled } + +func init() { + SchemeBuilder.Register(&Backstage{}, &BackstageList{}) +} diff --git a/api/v1alpha5/devhubplugincatalog_types.go b/api/v1alpha5/devhubplugincatalog_types.go new file mode 100644 index 000000000..a746801a0 --- /dev/null +++ b/api/v1alpha5/devhubplugincatalog_types.go @@ -0,0 +1,61 @@ +package v1alpha5 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DevHubPluginCatalogSpec defines the desired state of DevHubPluginCatalog +type DevHubPluginCatalogSpec struct { + // Source defines where to fetch the catalog from + // +kubebuilder:validation:Required + Source CatalogSource `json:"source"` +} + +// CatalogSource defines the source of the plugin catalog +type CatalogSource struct { + // Ref is the OCI artifact reference containing the catalog + // Example: registry.redhat.io/rhdh/plugin-catalog:1.5 + // +kubebuilder:validation:Required + Ref string `json:"ref"` + + // PullSecret is a reference to a Secret containing registry credentials + // +optional + PullSecret *corev1.LocalObjectReference `json:"pullSecret,omitempty"` + + // CertificateAuthority is a reference to a ConfigMap key containing CA certificate + // +optional + CertificateAuthority *corev1.ConfigMapKeySelector `json:"certificateAuthority,omitempty"` + + // SkipTLSVerify skips TLS certificate verification (not recommended for production) + // +optional + // +kubebuilder:default=false + SkipTLSVerify bool `json:"skipTLSVerify,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster,shortName=dhpc +// +kubebuilder:printcolumn:name="Ref",type=string,JSONPath=`.spec.source.ref` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// DevHubPluginCatalog is the Schema for the devhubplugincatalogs API. +// It defines a source for plugin catalogs that can be fetched from OCI registries. +type DevHubPluginCatalog struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DevHubPluginCatalogSpec `json:"spec,omitempty"` +} + +// +kubebuilder:object:root=true + +// DevHubPluginCatalogList contains a list of DevHubPluginCatalog +type DevHubPluginCatalogList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DevHubPluginCatalog `json:"items"` +} + +func init() { + SchemeBuilder.Register(&DevHubPluginCatalog{}, &DevHubPluginCatalogList{}) +} diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 1ee510162..7355223e3 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -5,6 +5,7 @@ package v1alpha5 import ( + corev1 "k8s.io/api/core/v1" v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" @@ -213,6 +214,31 @@ func (in *BackstageStatus) DeepCopy() *BackstageStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CatalogSource) DeepCopyInto(out *CatalogSource) { + *out = *in + if in.PullSecret != nil { + in, out := &in.PullSecret, &out.PullSecret + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.CertificateAuthority != nil { + in, out := &in.CertificateAuthority, &out.CertificateAuthority + *out = new(corev1.ConfigMapKeySelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CatalogSource. +func (in *CatalogSource) DeepCopy() *CatalogSource { + if in == nil { + return nil + } + out := new(CatalogSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Database) DeepCopyInto(out *Database) { *out = *in @@ -233,6 +259,80 @@ func (in *Database) DeepCopy() *Database { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DevHubPluginCatalog) DeepCopyInto(out *DevHubPluginCatalog) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DevHubPluginCatalog. +func (in *DevHubPluginCatalog) DeepCopy() *DevHubPluginCatalog { + if in == nil { + return nil + } + out := new(DevHubPluginCatalog) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DevHubPluginCatalog) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DevHubPluginCatalogList) DeepCopyInto(out *DevHubPluginCatalogList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DevHubPluginCatalog, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DevHubPluginCatalogList. +func (in *DevHubPluginCatalogList) DeepCopy() *DevHubPluginCatalogList { + if in == nil { + return nil + } + out := new(DevHubPluginCatalogList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DevHubPluginCatalogList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DevHubPluginCatalogSpec) DeepCopyInto(out *DevHubPluginCatalogSpec) { + *out = *in + in.Source.DeepCopyInto(&out.Source) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DevHubPluginCatalogSpec. +func (in *DevHubPluginCatalogSpec) DeepCopy() *DevHubPluginCatalogSpec { + if in == nil { + return nil + } + out := new(DevHubPluginCatalogSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Env) DeepCopyInto(out *Env) { *out = *in diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 4a429fab3..bf3c52839 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -23,13 +23,26 @@ metadata: }, "name": "developer-hub" } + }, + { + "apiVersion": "rhdh.redhat.com/v1alpha5", + "kind": "DevHubPluginCatalog", + "metadata": { + "name": "rhdh-primary-catalog", + "namespace": "rhdh-operator" + }, + "spec": { + "source": { + "ref": "quay.io/rhdh/plugin-catalog-index:2.0.0" + } + } } ] capabilities: Seamless Upgrades categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-06T10:03:16Z" + createdAt: "2026-08-20T07:51:12Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed @@ -78,6 +91,9 @@ spec: kind: Backstage name: backstages.rhdh.redhat.com version: v1alpha5 + - kind: DevHubPluginCatalog + name: devhubplugincatalogs.rhdh.redhat.com + version: v1alpha5 description: | Red Hat Developer Hub is an enterprise-grade platform for building developer portals, containing a supported and opinionated framework. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed internal developer portal for adopters who are just starting out. By implementing a unified and open platform designed to maximize developer skills, ease onboarding, and increase development productivity, focus can be centered on what really matters: writing great code. Red Hat Developer Hub also offers Software Templates to simplify the development process, which can reduce friction and frustration for development teams, boosting their productivity and increasing an organization's competitive advantage. @@ -194,6 +210,14 @@ spec: - get - patch - update + - apiGroups: + - rhdh.redhat.com + resources: + - devhubplugincatalogs + verbs: + - get + - list + - watch - apiGroups: - route.openshift.io resources: @@ -313,17 +337,23 @@ spec: env: - name: OPERATOR_NAME value: rhdh-operator - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name + - name: OPERATOR_DP_PROCESSING + value: "true" - name: RELATED_IMAGE_postgresql value: quay.io/fedora/postgresql-15:latest - name: RELATED_IMAGE_backstage value: quay.io/rhdh-community/rhdh:next - - name: INSTALL_DP_IMAGE - value: quay.io/gazarenk/install-plugins:skopeo - image: quay.io/rhdh/rhdh-rhel9-operator:2.0 + - name: RELATED_IMAGE_plugin_installer + value: quay.io/gazarenk/rhdh-plugin-installer:next + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + image: quay.io/rhdh-community/operator:2.0.0 livenessProbe: httpGet: path: /healthz @@ -365,6 +395,8 @@ spec: name: default-config - mountPath: /plugin-deps name: plugin-deps + - mountPath: /tmp + name: tmp securityContext: runAsNonRoot: true serviceAccountName: rhdh-controller-manager @@ -383,6 +415,8 @@ spec: name: rhdh-plugin-deps optional: true name: plugin-deps + - emptyDir: {} + name: tmp permissions: - rules: - apiGroups: @@ -454,5 +488,7 @@ spec: name: postgresql - image: quay.io/rhdh-community/rhdh:next name: backstage + - image: quay.io/gazarenk/rhdh-plugin-installer:next + name: plugin-installer replaces: rhdh-operator.v1.10.0 version: 2.0.0 diff --git a/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml b/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml index 113291749..b989180b4 100644 --- a/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml +++ b/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml @@ -210,7 +210,7 @@ data: - ./install-dynamic-plugins.sh - /dynamic-plugins-root # image will be replaced by the value of the `RELATED_IMAGE_backstage` env var, if set - # image will be replaced with INSTALL_DP_IMAGE if OPERATOR_DP_PROCESSING==true + # image will be replaced with RELATED_IMAGE_plugin_installer if OPERATOR_DP_PROCESSING==true image: quay.io/rhdh-community/rhdh:next imagePullPolicy: IfNotPresent securityContext: @@ -229,7 +229,7 @@ data: value: "40000000" # CATALOG_INDEX_IMAGE will be replaced by the value of the `RELATED_IMAGE_catalog_index` env var, if set - name: CATALOG_INDEX_IMAGE - value: "quay.io/rhdh/plugin-catalog-index:next" + value: "quay.io/rhdh/plugin-catalog-index:2.0.0" - name: CATALOG_ENTITIES_EXTRACT_DIR value: '/extensions' - name: INPUT_FILE diff --git a/cmd/main.go b/cmd/main.go index 6896b7643..080886657 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -33,6 +33,7 @@ import ( bsv1 "github.com/redhat-developer/rhdh-operator/api/v1alpha5" "github.com/redhat-developer/rhdh-operator/internal/controller" + "github.com/redhat-developer/rhdh-operator/pkg/catalog" "github.com/redhat-developer/rhdh-operator/pkg/utils" configv1 "github.com/openshift/api/config/v1" @@ -292,6 +293,18 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Backstage") os.Exit(1) } + + // Setup DevHubPluginCatalog controller + operatorNamespace := getOperatorNamespace() + if err = (&controller.DevHubPluginCatalogReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + OperatorNamespace: operatorNamespace, + Processor: catalog.NewProcessor(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "DevHubPluginCatalog") + os.Exit(1) + } // +kubebuilder:scaffold:builder if plf.IsOpenshift() { @@ -387,3 +400,17 @@ func setupTLSProfileWatcher( }, }).SetupWithManager(mgr) } + +// getOperatorNamespace returns the namespace the operator is running in +func getOperatorNamespace() string { + // Check env var first (set via downward API in deployment) + if ns := os.Getenv("POD_NAMESPACE"); ns != "" { + return ns + } + // Fall back to service account namespace file (auto-mounted by Kubernetes) + if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil { + return string(data) + } + // Default for local development + return "rhdh-operator" +} diff --git a/cmd/plugin-fetch/main.go b/cmd/plugin-fetch/main.go.1 similarity index 100% rename from cmd/plugin-fetch/main.go rename to cmd/plugin-fetch/main.go.1 diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 4d391c457..ddccc5e05 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/rhdh.redhat.com_backstages.yaml +- bases/rhdh.redhat.com_devhubplugincatalogs.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/manager/deployment.yaml b/config/manager/deployment.yaml index 1ca60434c..f2c272269 100644 --- a/config/manager/deployment.yaml +++ b/config/manager/deployment.yaml @@ -55,6 +55,15 @@ spec: - --leader-elect image: controller:latest name: manager + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false @@ -91,6 +100,8 @@ spec: name: default-config - mountPath: /plugin-deps name: plugin-deps + - mountPath: /tmp + name: tmp serviceAccountName: controller-manager terminationGracePeriodSeconds: 10 @@ -101,4 +112,6 @@ spec: - name: plugin-deps configMap: name: plugin-deps - optional: true \ No newline at end of file + optional: true + - name: tmp + emptyDir: {} \ No newline at end of file diff --git a/config/profile/rhdh/default-config/catalog.yaml b/config/profile/rhdh/default-config/catalog.yaml new file mode 100644 index 000000000..46e3b11d8 --- /dev/null +++ b/config/profile/rhdh/default-config/catalog.yaml @@ -0,0 +1,8 @@ +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: primary-catalog +spec: + source: + # Default value, patched with CATALOG_INDEX_IMAGE from Makefile at build time + ref: quay.io/rhdh/plugin-catalog-index:next diff --git a/config/profile/rhdh/default-config/deployment.yaml b/config/profile/rhdh/default-config/deployment.yaml index 325a2c1d8..5b5c7fa3a 100644 --- a/config/profile/rhdh/default-config/deployment.yaml +++ b/config/profile/rhdh/default-config/deployment.yaml @@ -52,7 +52,7 @@ spec: - ./install-dynamic-plugins.sh - /dynamic-plugins-root # image will be replaced by the value of the `RELATED_IMAGE_backstage` env var, if set - # image will be replaced with INSTALL_DP_IMAGE if OPERATOR_DP_PROCESSING==true + # image will be replaced with RELATED_IMAGE_plugin_installer if OPERATOR_DP_PROCESSING==true image: quay.io/rhdh-community/rhdh:next imagePullPolicy: IfNotPresent securityContext: diff --git a/config/profile/rhdh/kustomization.yaml b/config/profile/rhdh/kustomization.yaml index e5dbde1e0..360ee8942 100644 --- a/config/profile/rhdh/kustomization.yaml +++ b/config/profile/rhdh/kustomization.yaml @@ -11,11 +11,12 @@ resources: - ../../manager - namespace.yaml - plugin-rbac +- default-config/catalog.yaml images: - name: controller - newName: quay.io/rhdh/rhdh-rhel9-operator - newTag: "2.0" + newName: quay.io/rhdh-community/operator + newTag: 2.0.0 patches: - path: patches/deployment-patch.yaml diff --git a/config/profile/rhdh/patches/deployment-patch.yaml b/config/profile/rhdh/patches/deployment-patch.yaml index 3ddaa47e1..b653afa18 100644 --- a/config/profile/rhdh/patches/deployment-patch.yaml +++ b/config/profile/rhdh/patches/deployment-patch.yaml @@ -20,16 +20,14 @@ spec: env: - name: OPERATOR_NAME value: rhdh-operator - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name + - name: OPERATOR_DP_PROCESSING + value: "{{OPERATOR_DP_PROCESSING}}" - name: RELATED_IMAGE_postgresql value: quay.io/fedora/postgresql-15:latest - name: RELATED_IMAGE_backstage value: quay.io/rhdh-community/rhdh:next - - name: INSTALL_DP_IMAGE - value: quay.io/gazarenk/install-plugins:skopeo + - name: RELATED_IMAGE_plugin_installer + value: "{{RELATED_IMAGE_plugin_installer}}" volumeMounts: - mountPath: /default-config/flavours/lightspeed name: flavour-lightspeed-config diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index fb0109902..97a7ea076 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -96,6 +96,14 @@ rules: - get - patch - update +- apiGroups: + - rhdh.redhat.com + resources: + - devhubplugincatalogs + verbs: + - get + - list + - watch - apiGroups: - route.openshift.io resources: diff --git a/dist/rhdh/install.yaml b/dist/rhdh/install.yaml index d7f1c8f30..6ea3dea16 100644 --- a/dist/rhdh/install.yaml +++ b/dist/rhdh/install.yaml @@ -2180,6 +2180,120 @@ spec: subresources: status: {} --- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: devhubplugincatalogs.rhdh.redhat.com +spec: + group: rhdh.redhat.com + names: + kind: DevHubPluginCatalog + listKind: DevHubPluginCatalogList + plural: devhubplugincatalogs + shortNames: + - dhpc + singular: devhubplugincatalog + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.source.ref + name: Ref + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha5 + schema: + openAPIV3Schema: + description: |- + DevHubPluginCatalog is the Schema for the devhubplugincatalogs API. + It defines a source for plugin catalogs that can be fetched from OCI registries. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: DevHubPluginCatalogSpec defines the desired state of DevHubPluginCatalog + properties: + source: + description: Source defines where to fetch the catalog from + properties: + certificateAuthority: + description: CertificateAuthority is a reference to a ConfigMap + key containing CA certificate + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + pullSecret: + description: PullSecret is a reference to a Secret containing + registry credentials + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + ref: + description: |- + Ref is the OCI artifact reference containing the catalog + Example: registry.redhat.io/rhdh/plugin-catalog:1.5 + type: string + skipTLSVerify: + default: false + description: SkipTLSVerify skips TLS certificate verification + (not recommended for production) + type: boolean + required: + - ref + type: object + required: + - source + type: object + type: object + served: true + storage: true + subresources: {} +--- apiVersion: v1 kind: ServiceAccount metadata: @@ -2393,6 +2507,14 @@ rules: - get - patch - update +- apiGroups: + - rhdh.redhat.com + resources: + - devhubplugincatalogs + verbs: + - get + - list + - watch - apiGroups: - route.openshift.io resources: @@ -2796,7 +2918,7 @@ data: - ./install-dynamic-plugins.sh - /dynamic-plugins-root # image will be replaced by the value of the `RELATED_IMAGE_backstage` env var, if set - # image will be replaced with INSTALL_DP_IMAGE if OPERATOR_DP_PROCESSING==true + # image will be replaced with RELATED_IMAGE_plugin_installer if OPERATOR_DP_PROCESSING==true image: quay.io/rhdh-community/rhdh:next imagePullPolicy: IfNotPresent securityContext: @@ -2815,7 +2937,7 @@ data: value: "40000000" # CATALOG_INDEX_IMAGE will be replaced by the value of the `RELATED_IMAGE_catalog_index` env var, if set - name: CATALOG_INDEX_IMAGE - value: "quay.io/rhdh/plugin-catalog-index:next" + value: "quay.io/rhdh/plugin-catalog-index:2.0.0" - name: CATALOG_ENTITIES_EXTRACT_DIR value: '/extensions' - name: INPUT_FILE @@ -4040,17 +4162,23 @@ spec: env: - name: OPERATOR_NAME value: rhdh-operator - - name: POD_NAME - valueFrom: - fieldRef: - fieldPath: metadata.name + - name: OPERATOR_DP_PROCESSING + value: 'true' - name: RELATED_IMAGE_postgresql value: quay.io/fedora/postgresql-15:latest - name: RELATED_IMAGE_backstage value: quay.io/rhdh-community/rhdh:next - - name: INSTALL_DP_IMAGE - value: quay.io/gazarenk/install-plugins:skopeo - image: quay.io/rhdh/rhdh-rhel9-operator:2.0 + - name: RELATED_IMAGE_plugin_installer + value: 'quay.io/gazarenk/rhdh-plugin-installer:next' + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + image: quay.io/rhdh-community/operator:2.0.0 livenessProbe: httpGet: path: /healthz @@ -4092,6 +4220,8 @@ spec: name: default-config - mountPath: /plugin-deps name: plugin-deps + - mountPath: /tmp + name: tmp securityContext: runAsNonRoot: true serviceAccountName: rhdh-controller-manager @@ -4110,3 +4240,14 @@ spec: name: rhdh-plugin-deps optional: true name: plugin-deps + - emptyDir: {} + name: tmp +--- +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: rhdh-primary-catalog + namespace: rhdh-operator +spec: + source: + ref: quay.io/rhdh/plugin-catalog-index:2.0.0 diff --git a/go.sum b/go.sum index c7689307f..eb5009cbf 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -92,8 +92,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= -github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= +github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -151,8 +151,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/openshift/api v0.0.0-20260724095150-18550f1a6d13 h1:xDz0RoMp1GYVmA93Hht8CQxN8rFAADif8H6CtKkHEYk= -github.com/openshift/api v0.0.0-20260724095150-18550f1a6d13/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= +github.com/openshift/api v0.0.0-20260807110950-72ae4424ef35 h1:eJOpmjlTvrSwKwdpJ71Q2yTOSj+msZ7re0unuiBrjc4= +github.com/openshift/api v0.0.0-20260807110950-72ae4424ef35/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e h1:k89oIo2EjX0PRSdi1kesktCyWp50SC9WwKurvupvRGs= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e/go.mod h1:XGabTMnNbz0M5Oa7IbscZp/jmcc7aHobvOCUWwkzKvM= github.com/openshift/library-go v0.0.0-20260213153706-03f1709971c5 h1:9Pe6iVOMjt9CdA/vaKBNUSoEIjIe1po5Ha3ABRYXLJI= @@ -182,17 +182,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -212,20 +205,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -238,8 +231,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A= -golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= @@ -260,14 +253,14 @@ golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -279,7 +272,6 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 49c832175..e2a56fdcb 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -3,7 +3,9 @@ package controller import ( "context" "fmt" + "os" "reflect" + "time" "github.com/redhat-developer/rhdh-operator/pkg/model/multiobject" "github.com/redhat-developer/rhdh-operator/pkg/platform" @@ -27,6 +29,9 @@ import ( const ( BackstageFieldManager = "backstage-controller" + // CatalogStatusPath is the path to the catalog readiness marker in the mounted default-config + CatalogStatusPath = "/default-config/.catalogs-ready" + // AutoSyncEnvVar: EXT_CONF_SYNC_backstage env variable which defines the value for rhdh.redhat.com/ext-config-sync annotation of external config object (ConfigMap|Secret) // True by default AutoSyncEnvVar = "EXT_CONF_SYNC_backstage" @@ -86,6 +91,13 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( setStatusCondition(&backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonInProgress, "Deployment process started") } + // Check if plugin catalogs are ready before proceeding + if !areCatalogsReady() { + lg.Info("Waiting for plugin catalogs to be ready") + setStatusCondition(&backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonInProgress, "Waiting for DevHubPluginCatalog resources to be ready") + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + // 1. Preliminary read and prepare external config objects from the specs (configMaps, Secrets) // 2. Make some validation to fail fast externalConfig, err := r.preprocessSpec(ctx, backstage) @@ -190,3 +202,23 @@ func (r *BackstageReconciler) SetupWithManager(mgr ctrl.Manager) error { return b.Complete(r) } + +// areCatalogsReady checks if plugin catalogs are ready by reading mounted status file +// Returns true if: +// - No status file exists (local dev mode - use static default-config files) +// - Status file has content (catalogs are ready) +// Returns false if status file exists but is empty (DHPC controller still processing) +func areCatalogsReady() bool { + info, err := os.Stat(CatalogStatusPath) + if os.IsNotExist(err) { + // No file → local dev → ready + return true + } + if err != nil { + // Error reading file, assume ready + return true + } + // Empty file → still processing → not ready + // Has content → ready + return info.Size() > 0 +} diff --git a/internal/controller/devhubplugincatalog_controller.go b/internal/controller/devhubplugincatalog_controller.go new file mode 100644 index 000000000..63f88eacd --- /dev/null +++ b/internal/controller/devhubplugincatalog_controller.go @@ -0,0 +1,136 @@ +package controller + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/redhat-developer/rhdh-operator/api" + "github.com/redhat-developer/rhdh-operator/pkg/catalog" +) + +const ( + // DefaultConfigMapName is the name of the default-config ConfigMap to patch + // This should match the kustomize-generated name (namePrefix + base name) + DefaultConfigMapName = "rhdh-default-config" + + catalogRequeueOnError = 1 * time.Minute + catalogRequeueOnSuccess = 5 * time.Minute +) + +// DevHubPluginCatalogReconciler reconciles DevHubPluginCatalog objects +type DevHubPluginCatalogReconciler struct { + client.Client + Scheme *runtime.Scheme + OperatorNamespace string + Processor *catalog.Processor +} + +// +kubebuilder:rbac:groups=rhdh.redhat.com,resources=devhubplugincatalogs,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch + +// Reconcile handles any DevHubPluginCatalog change by reconciling ALL catalogs +func (r *DevHubPluginCatalogReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + lg := log.FromContext(ctx) + lg.V(1).Info("Reconciling DevHubPluginCatalog", "name", req.Name) + + // 1. List all catalogs and build inputs + inputs, err := r.buildCatalogInputs(ctx) + if err != nil { + return ctrl.Result{}, err + } + + if len(inputs) == 0 { + lg.Info("No DevHubPluginCatalog resources found") + return ctrl.Result{}, nil + } + + // 2. Process all catalogs (fetch and merge) + content, err := r.Processor.Process(ctx, inputs) + if err != nil { + return ctrl.Result{RequeueAfter: catalogRequeueOnError}, fmt.Errorf("failed to process catalogs: %w", err) + } + + // 3. Apply merged ConfigMap + if err := r.applyConfigMap(ctx, content); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to apply ConfigMap: %w", err) + } + + lg.Info("Successfully reconciled all DevHubPluginCatalogs", "count", len(inputs)) + return ctrl.Result{RequeueAfter: catalogRequeueOnSuccess}, nil +} + +// buildCatalogInputs lists all catalogs and builds processor inputs +func (r *DevHubPluginCatalogReconciler) buildCatalogInputs(ctx context.Context) ([]catalog.CatalogInput, error) { + catalogList := &api.DevHubPluginCatalogList{} + if err := r.List(ctx, catalogList); err != nil { + return nil, fmt.Errorf("failed to list catalogs: %w", err) + } + + inputs := make([]catalog.CatalogInput, 0, len(catalogList.Items)) + + for i := range catalogList.Items { + dhpc := &catalogList.Items[i] + + input := catalog.CatalogInput{ + Ref: dhpc.Spec.Source.Ref, + SkipTLSVerify: dhpc.Spec.Source.SkipTLSVerify, + } + + // Get pull secret + if dhpc.Spec.Source.PullSecret != nil { + secret := &corev1.Secret{} + key := types.NamespacedName{Name: dhpc.Spec.Source.PullSecret.Name, Namespace: r.OperatorNamespace} + if err := r.Get(ctx, key, secret); err == nil { + input.DockerConfig = secret.Data[".dockerconfigjson"] + } + } + + // Get CA certificate + if dhpc.Spec.Source.CertificateAuthority != nil { + cm := &corev1.ConfigMap{} + key := types.NamespacedName{Name: dhpc.Spec.Source.CertificateAuthority.Name, Namespace: r.OperatorNamespace} + if err := r.Get(ctx, key, cm); err == nil { + caKey := dhpc.Spec.Source.CertificateAuthority.Key + if caKey == "" { + caKey = "ca.crt" + } + input.CACert = []byte(cm.Data[caKey]) + } + } + + inputs = append(inputs, input) + } + + return inputs, nil +} + +// applyConfigMap patches the default-config ConfigMap with pre-built patch bytes. +func (r *DevHubPluginCatalogReconciler) applyConfigMap(ctx context.Context, patchBytes []byte) error { + cm := &corev1.ConfigMap{} + cm.Name = DefaultConfigMapName + cm.Namespace = r.OperatorNamespace + + if err := r.Patch(ctx, cm, client.RawPatch(types.MergePatchType, patchBytes)); err != nil { + return fmt.Errorf("failed to patch ConfigMap %s: %w", DefaultConfigMapName, err) + } + + log.FromContext(ctx).V(1).Info("Patched ConfigMap with catalog content", "name", cm.Name) + return nil +} + +// SetupWithManager sets up the controller with the Manager +func (r *DevHubPluginCatalogReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&api.DevHubPluginCatalog{}). + Named("devhubplugincatalog"). + Complete(r) +} diff --git a/pkg/catalog/processor.go b/pkg/catalog/processor.go new file mode 100644 index 000000000..d6b9ba492 --- /dev/null +++ b/pkg/catalog/processor.go @@ -0,0 +1,150 @@ +package catalog + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v2" + + "github.com/redhat-developer/rhdh-operator/pkg/fetcher" + "github.com/redhat-developer/rhdh-operator/pkg/model" +) + +const ( + // InternalConfigMapName is the name embedded in the wrapped ConfigMap + InternalConfigMapName = "catalog-dynamic-plugins" + + // CatalogFileName is the expected file in OCI artifacts + CatalogFileName = "dynamic-plugins.default.yaml" + + // CatalogsReadyKey is the marker key added to ConfigMap when catalogs are ready + CatalogsReadyKey = ".catalogs-ready" +) + +// Processor handles fetching, processing, and merging plugin catalogs +type Processor struct{} + +// NewProcessor creates a new catalog processor +func NewProcessor() *Processor { + return &Processor{} +} + +// CatalogInput contains everything needed to fetch and process a catalog +type CatalogInput struct { + Ref string + DockerConfig []byte + CACert []byte + SkipTLSVerify bool +} + +// Process fetches all catalogs, merges them, and returns JSON patch bytes ready to apply +func (p *Processor) Process(ctx context.Context, catalogs []CatalogInput) ([]byte, error) { + var allContent [][]byte + + for _, cat := range catalogs { + content, err := p.fetch(ctx, cat) + if err != nil { + return nil, fmt.Errorf("failed to fetch catalog %s: %w", cat.Ref, err) + } + allContent = append(allContent, content) + } + + merged, err := p.merge(allContent) + if err != nil { + return nil, fmt.Errorf("failed to merge catalogs: %w", err) + } + + // Wrap in ConfigMap YAML and build JSON patch + wrapped := p.wrapInConfigMap(merged) + patchData := map[string]interface{}{ + "data": map[string]string{ + model.DynamicPluginsFile: string(wrapped), + CatalogsReadyKey: "true", + }, + } + + return json.Marshal(patchData) +} + +// wrapInConfigMap wraps the plugin config in a ConfigMap manifest YAML +func (p *Processor) wrapInConfigMap(content []byte) []byte { + wrapped := fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + name: %s +data: + %s: | +%s`, InternalConfigMapName, model.DynamicPluginsFile, indentYAML(string(content), 4)) + return []byte(wrapped) +} + +// indentYAML indents each line of the YAML content by the specified number of spaces +func indentYAML(content string, spaces int) string { + indent := strings.Repeat(" ", spaces) + lines := strings.Split(content, "\n") + for i, line := range lines { + if line != "" { + lines[i] = indent + line + } + } + return strings.Join(lines, "\n") +} + +// fetch downloads a single catalog +func (p *Processor) fetch(ctx context.Context, cat CatalogInput) ([]byte, error) { + var opts []fetcher.OCIOption + if cat.SkipTLSVerify { + opts = append(opts, fetcher.WithInsecure()) + } + if cat.DockerConfig != nil { + opts = append(opts, fetcher.WithDockerConfig(cat.DockerConfig)) + } + if cat.CACert != nil { + opts = append(opts, fetcher.WithCACert(cat.CACert)) + } + + ociFetcher := fetcher.NewOCIFetcher(opts...) + + tempDir, err := os.MkdirTemp("", "dhpc-") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + ref := strings.TrimPrefix(cat.Ref, "oci://") + if err := ociFetcher.Fetch(ctx, ref, tempDir); err != nil { + return nil, fmt.Errorf("failed to fetch OCI artifact: %w", err) + } + + content, err := os.ReadFile(filepath.Join(tempDir, CatalogFileName)) + if err != nil { + return nil, fmt.Errorf("failed to read catalog file: %w", err) + } + + return content, nil +} + +// merge combines multiple catalog contents, deduplicating by package name +func (p *Processor) merge(catalogs [][]byte) ([]byte, error) { + var allPlugins []model.DynaPlugin + seen := make(map[string]bool) + + for _, content := range catalogs { + var catalog model.DynaPluginsConfig + if err := yaml.Unmarshal(content, &catalog); err != nil { + return nil, fmt.Errorf("failed to parse catalog: %w", err) + } + for _, plugin := range catalog.Plugins { + if !seen[plugin.Package] { + allPlugins = append(allPlugins, plugin) + seen[plugin.Package] = true + } + } + } + + return yaml.Marshal(model.DynaPluginsConfig{Plugins: allPlugins}) +} diff --git a/pkg/fetcher/extract.go b/pkg/fetcher/extract.go index 5a597f07a..e2e272ce4 100644 --- a/pkg/fetcher/extract.go +++ b/pkg/fetcher/extract.go @@ -41,6 +41,11 @@ func extractTarGzBytes(data []byte, destDir string) error { return extractTarGz(bytes.NewReader(data), destDir) } +// extractTarBytes extracts an uncompressed tarball from bytes to destDir +func extractTarBytes(data []byte, destDir string) error { + return extractTar(bytes.NewReader(data), destDir) +} + // extractTar extracts a tarball from a reader to destDir func extractTar(r io.Reader, destDir string) error { tr := tar.NewReader(r) diff --git a/pkg/fetcher/fetcher.go b/pkg/fetcher/fetcher.go.1 similarity index 100% rename from pkg/fetcher/fetcher.go rename to pkg/fetcher/fetcher.go.1 diff --git a/pkg/fetcher/http.go b/pkg/fetcher/http.go.1 similarity index 100% rename from pkg/fetcher/http.go rename to pkg/fetcher/http.go.1 diff --git a/pkg/fetcher/local.go b/pkg/fetcher/local.go.1 similarity index 100% rename from pkg/fetcher/local.go rename to pkg/fetcher/local.go.1 diff --git a/pkg/fetcher/lock.go b/pkg/fetcher/lock.go.1 similarity index 100% rename from pkg/fetcher/lock.go rename to pkg/fetcher/lock.go.1 diff --git a/pkg/fetcher/npm.go b/pkg/fetcher/npm.go.1 similarity index 100% rename from pkg/fetcher/npm.go rename to pkg/fetcher/npm.go.1 diff --git a/pkg/fetcher/npm_extract.go b/pkg/fetcher/npm_extract.go.1 similarity index 100% rename from pkg/fetcher/npm_extract.go rename to pkg/fetcher/npm_extract.go.1 diff --git a/pkg/fetcher/oci.go b/pkg/fetcher/oci.go index 35d3599a8..bebf89fa6 100644 --- a/pkg/fetcher/oci.go +++ b/pkg/fetcher/oci.go @@ -169,8 +169,8 @@ func (c *OCIFetcher) Fetch(ctx context.Context, ref string, destDir string) erro return err } - // Extract tarball content to destDir - return extractTarGzBytes(result.Content, destDir) + // Extract tarball content to destDir (already uncompressed by Uncompressed()) + return extractTarBytes(result.Content, destDir) } // FetchContent downloads an OCI artifact and returns its content @@ -222,7 +222,7 @@ func (c *OCIFetcher) FetchContent(ctx context.Context, ref string) (*FetchResult if err != nil { return nil, fmt.Errorf("failed to uncompress layer: %w", err) } - defer reader.Close() + defer func() { _ = reader.Close() }() content, err := io.ReadAll(reader) if err != nil { diff --git a/pkg/model/deployment.go b/pkg/model/deployment.go index 37f6ed844..de276dd69 100644 --- a/pkg/model/deployment.go +++ b/pkg/model/deployment.go @@ -115,8 +115,9 @@ func (b *BackstageDeployment) addToModel(model *BackstageModel, backstage api.Ba // if Operator dynamic plugins processing - put dedicated image // Only apply if the deployment has the install-dynamic-plugins init container - if IsOperatorDPProcessing() && i >= 0 { - idpImage := os.Getenv(InstallDpImageEnvVar) + dpProcessing := IsOperatorDPProcessing() + idpImage := os.Getenv(InstallDpImageEnvVar) + if dpProcessing && i >= 0 { if idpImage == "" { return fmt.Errorf("%s environment variable is required when operator dynamic plugins processing is enabled", InstallDpImageEnvVar) } diff --git a/pkg/model/deployment_test.go b/pkg/model/deployment_test.go index e1694703e..f3b9bf702 100644 --- a/pkg/model/deployment_test.go +++ b/pkg/model/deployment_test.go @@ -68,7 +68,7 @@ func TestOverrideBackstageImage(t *testing.T) { deployment := model.GetRuntimeObject(DeploymentKey).(*BackstageDeployment) assert.Equal(t, 2, len(deployment.podSpec().Containers)) assert.Equal(t, "dummy", deployment.container().Image) - // With OPERATOR_DP_PROCESSING=true, init container uses INSTALL_DP_IMAGE (set in model_tests.go) + // With OPERATOR_DP_PROCESSING=true, init container uses RELATED_IMAGE_plugin_installer (set in model_tests.go) // With OPERATOR_DP_PROCESSING=false, init container uses RELATED_IMAGE_backstage if IsOperatorDPProcessing() { assert.Equal(t, "test-install-dp-image", deployment.podSpec().InitContainers[0].Image) diff --git a/pkg/model/dynamic-plugins.go b/pkg/model/dynamic-plugins.go index 22a16991f..54ee7b26d 100644 --- a/pkg/model/dynamic-plugins.go +++ b/pkg/model/dynamic-plugins.go @@ -8,6 +8,7 @@ import ( "gopkg.in/yaml.v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" "github.com/redhat-developer/rhdh-operator/api" "github.com/redhat-developer/rhdh-operator/pkg/utils" @@ -29,7 +30,7 @@ import ( const dynamicPluginInitContainerName = "install-dynamic-plugins" const DynamicPluginsFile = "dynamic-plugins.yaml" const OperatorDPProcessingEnvVar = "OPERATOR_DP_PROCESSING" -const InstallDpImageEnvVar = "INSTALL_DP_IMAGE" +const InstallDpImageEnvVar = "RELATED_IMAGE_plugin_installer" type DynamicPluginsFactory struct{} @@ -145,6 +146,11 @@ func (p *DynamicPlugins) addToModel(model *BackstageModel, backstage api.Backsta for _, plugin := range pluginsData { if !plugin.IsDisabled() { p.enabledPlugins = append(p.enabledPlugins, plugin) + // Skip local paths - they're built into the image and don't need downloading + if strings.HasPrefix(plugin.Package, "./") || strings.HasPrefix(plugin.Package, "/") { + klog.V(1).Infof("skipping local path plugin %q (built into image)", plugin.Package) + continue + } packages = append(packages, plugin.Package) } } @@ -274,8 +280,10 @@ func (p *DynamicPlugins) getInitContainer() (int, *corev1.Container) { if initContainer == nil { return i, nil } - // override image with env var - if os.Getenv(BackstageImageEnvVar) != "" { + // Override image with env var, but only when NOT using operator DP processing. + // When OPERATOR_DP_PROCESSING=true, the init container image is already set + // to RELATED_IMAGE_plugin_installer in deployment.addToModel() and should not be overwritten. + if !IsOperatorDPProcessing() && os.Getenv(BackstageImageEnvVar) != "" { initContainer.Image = os.Getenv(BackstageImageEnvVar) } return i, initContainer diff --git a/pkg/model/model_tests.go b/pkg/model/model_tests.go index 480552e2e..2856d44ca 100644 --- a/pkg/model/model_tests.go +++ b/pkg/model/model_tests.go @@ -40,7 +40,7 @@ func createBackstageTest(bs api.Backstage) *testBackstageObject { utilruntime.Must(api.AddToScheme(b.scheme)) utilruntime.Must(clientgoscheme.AddToScheme(b.scheme)) utilruntime.Must(openshift.Install(b.scheme)) - // Set dummy INSTALL_DP_IMAGE for tests that use deployments with install-dynamic-plugins init container + // Set dummy RELATED_IMAGE_plugin_installer for tests that use deployments with install-dynamic-plugins init container _ = os.Setenv(InstallDpImageEnvVar, "test-install-dp-image") return b } diff --git a/plugin-installer/README.md b/plugin-installer/README.md index 519b9cd55..c62107121 100644 --- a/plugin-installer/README.md +++ b/plugin-installer/README.md @@ -35,7 +35,7 @@ make install-dp-build make install-dp-push # Or build with custom image name -make install-dp-build INSTALL_DP_IMAGE=myregistry/my-plugin-installer:v1 +make install-dp-build RELATED_IMAGE_plugin_installer=myregistry/my-plugin-installer:v1 # Build oras variant manually docker build -f plugin-installer/Dockerfile.oras -t myregistry/plugin-installer:oras . From 38fd936cd35d8439db465d290b7ff4b138a04a50 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 24 Aug 2026 12:21:31 +0300 Subject: [PATCH 05/28] docs --- Makefile | 1 + docs/dynamic-plugins.md | 134 +++++++++++--- .../devhubplugincatalog_controller.go | 26 ++- pkg/catalog/processor.go | 17 +- pkg/fetcher/oci.go | 174 ++++++++++-------- 5 files changed, 229 insertions(+), 123 deletions(-) diff --git a/Makefile b/Makefile index 5d7858a37..692e75422 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ ifeq ($(PROFILE), rhdh) # IMAGE_TAG_VERSION := $(shell echo $(VERSION) | cut -d. -f1,2) # endif + # TODO: Changed to 'rhdh-community' at least until 'rhdh' does not support arm64 # IMAGE_TAG_BASE ?= registry.redhat.io/rhdh/rhdh-rhel9-operator IMAGE_TAG_BASE ?= quay.io/rhdh-community/operator DEFAULT_CHANNEL ?= fast diff --git a/docs/dynamic-plugins.md b/docs/dynamic-plugins.md index 213103ac5..7af442578 100644 --- a/docs/dynamic-plugins.md +++ b/docs/dynamic-plugins.md @@ -43,47 +43,135 @@ spec: TODO: Dynamic plugins can be configured to use container registries for authentication and image pulling. This section should cover the configuration options available for container registry integration with dynamic plugins. -## Catalog Index Configuration +## Plugin Catalog Configuration -The operator supports loading default plugin configurations from an OCI container image (catalog index). For general information about how the catalog index works, see [Using a Catalog Index Image for Default Plugin Configurations](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#using-a-catalog-index-image-for-default-plugin-configurations). +The operator supports loading default plugin configurations from OCI container images (plugin catalogs). For general information about how the catalog index works, see [Using a Catalog Index Image for Default Plugin Configurations](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#using-a-catalog-index-image-for-default-plugin-configurations). -By default, the `rhdh` profile of operator [injects](../config/profile/rhdh/patches/deployment-patch.yaml#L31-L32) the `CATALOG_INDEX_IMAGE` environment variable in the RHDH `install-dynamic-plugins` init container. -To use a different catalog index image, such as a newer version or a mirrored image, use the `extraEnvs` field in your Backstage CR. See [examples/catalog-index.yaml](../examples/catalog-index.yaml) for a complete example. +### DevHubPluginCatalog Resources -### Extra catalog index images +**Available from operator version 2.0** -In addition to the primary catalog index image, you can configure extra catalog index images using the `EXTRA_CATALOG_INDEX_IMAGES` environment variable. This allows loading plugin configurations from multiple catalog index images. See [Using extra catalog index images](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#using-extra-catalog-index-images) for more details on how RHDH handles this environment variables. +Plugin catalogs are defined using `DevHubPluginCatalog` cluster-scoped resources. The operator fetches catalogs from OCI registries, merges them, and makes the configuration available to Backstage instances. -The value is a comma-separated list of entries. Each entry supports two forms: -- **`name=image_ref`**: Assigns an explicit name to the catalog index image, which controls the extraction subdirectory under `/extensions/extra//`. -- **`image_ref`**: A direct image reference without a name; the extraction directory is auto-generated from the image reference. +**Default Catalog**: The operator automatically includes the default RHDH plugin catalog (`quay.io/rhdh/plugin-catalog-index:`). -To configure extra catalog index images, use the `extraEnvs` field in your Backstage CR: +#### Basic Example ```yaml apiVersion: rhdh.redhat.com/v1alpha5 -kind: Backstage +kind: DevHubPluginCatalog metadata: - name: my-backstage + name: my-catalog spec: - application: - extraEnvs: - envs: - - name: EXTRA_CATALOG_INDEX_IMAGES - value: "rhdh-community=quay.io/rhdh-community/plugin-catalog-index:1.10,registry.example.com/rhdh-catalog:latest" - containers: - - install-dynamic-plugins + source: + ref: quay.io/my-org/plugin-catalog:v1.0 +``` + +#### Adding Multiple Catalogs + +Create additional `DevHubPluginCatalog` resources to add more plugin sources. The operator merges all catalogs automatically. + +**Important**: Plugin names must be unique across all catalogs. Duplicate plugin names will cause a reconciliation error. + +#### Private Registry Authentication + +For private registries, create a Secret with registry credentials in the operator namespace: + +```bash +kubectl create secret docker-registry private-registry-creds \ + --namespace=rhdh-operator \ + --docker-server=registry.example.com \ + --docker-username=YOUR_USERNAME \ + --docker-password=YOUR_PASSWORD +``` + +Then reference it in the DevHubPluginCatalog: + +```yaml +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: private-catalog +spec: + source: + ref: registry.example.com/rhdh/plugin-catalog:v1.0 + pullSecret: + name: private-registry-creds +``` + +The Secret must be of type `kubernetes.io/dockerconfigjson` and exist in the operator namespace. + +#### Custom CA Certificate + +For registries using self-signed certificates or internal CAs, create a ConfigMap with the CA certificate in the operator namespace: + +```bash +kubectl create configmap internal-ca \ + --namespace=rhdh-operator \ + --from-file=ca.crt=/path/to/ca-certificate.pem +``` + +Then reference it in the DevHubPluginCatalog: + +```yaml +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: internal-catalog +spec: + source: + ref: internal-registry.corp.example.com/rhdh/plugin-catalog:latest + certificateAuthority: + name: internal-ca + key: ca.crt # Optional, defaults to "ca.crt" +``` + +#### Skip TLS Verification (Development Only) + +```yaml +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: dev-catalog +spec: + source: + ref: dev-registry.local:5000/rhdh/plugin-catalog:dev + skipTLSVerify: true +``` + +#### Proxy Settings + +The operator respects standard proxy environment variables when fetching catalogs: + +- `HTTP_PROXY` / `http_proxy` - proxy for HTTP requests +- `HTTPS_PROXY` / `https_proxy` - proxy for HTTPS requests +- `NO_PROXY` / `no_proxy` - comma-separated list of hosts to bypass proxy + +These variables should be set on the operator deployment. For environments with HTTPS-inspecting proxies that use a corporate CA, combine proxy settings with `certificateAuthority` configuration. + +#### Manual Refresh + +To manually trigger a catalog refresh (e.g., after pushing a new image with the same tag): + +```bash +kubectl annotate devhubplugincatalog my-catalog rhdh.redhat.com/refresh=$(date +%s) --overwrite ``` ### Extensions Catalog Entities -Starting from version 1.9, the `rhdh` profile of the operator instructs the RHDH `install-dynamic-plugins` init container to extract catalog entities from the catalog index image to a new `/extensions` volume mount by default. +Starting from version 1.9, the `rhdh` profile of the operator extracts catalog entities from the catalog index image to a new `/extensions` volume mount by default. This allows the extensions backend providers to automatically discover plugin metadata for display in the RHDH Extensions UI. -The extraction directory can be configured via the `CATALOG_ENTITIES_EXTRACT_DIR` environment variable in the `install-dynamic-plugins` init container. - More details in [Catalog Entities Extraction](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#catalog-entities-extraction). +### Init-container processing Mode + +When `OPERATOR_DP_PROCESSING=false`, the RHDH `install-dynamic-plugins` init container handles catalog fetching using environment variables. + +By default, the `rhdh` profile [injects](../config/profile/rhdh/patches/deployment-patch.yaml#L31-L32) the `CATALOG_INDEX_IMAGE` environment variable. To use a different catalog index image, use the `extraEnvs` field in your Backstage CR. See [examples/catalog-index.yaml](../examples/catalog-index.yaml) for an example. + +For multiple catalog sources in this mode, use the `EXTRA_CATALOG_INDEX_IMAGES` environment variable. See [Using extra catalog index images](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#using-extra-catalog-index-images) for details. + ## Supported Package URL Formats | Format | Type | Description | @@ -99,8 +187,6 @@ More details in [Catalog Entities Extraction](https://github.com/redhat-develope The operator optionally supports special URL reference syntax in plugin package URLs, allowing users to reference plugins from the default configuration by name. -TODO: document Operator Dynamic Plugins processing mode - **Operator behavior:** - The operator resolves all references during ConfigMap merge (before passing to the init container) - If a reference cannot be resolved, the operator returns an error and the Backstage CR will not reconcile diff --git a/internal/controller/devhubplugincatalog_controller.go b/internal/controller/devhubplugincatalog_controller.go index 63f88eacd..a56d111ec 100644 --- a/internal/controller/devhubplugincatalog_controller.go +++ b/internal/controller/devhubplugincatalog_controller.go @@ -3,7 +3,6 @@ package controller import ( "context" "fmt" - "time" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" @@ -20,9 +19,6 @@ const ( // DefaultConfigMapName is the name of the default-config ConfigMap to patch // This should match the kustomize-generated name (namePrefix + base name) DefaultConfigMapName = "rhdh-default-config" - - catalogRequeueOnError = 1 * time.Minute - catalogRequeueOnSuccess = 5 * time.Minute ) // DevHubPluginCatalogReconciler reconciles DevHubPluginCatalog objects @@ -56,7 +52,7 @@ func (r *DevHubPluginCatalogReconciler) Reconcile(ctx context.Context, req ctrl. // 2. Process all catalogs (fetch and merge) content, err := r.Processor.Process(ctx, inputs) if err != nil { - return ctrl.Result{RequeueAfter: catalogRequeueOnError}, fmt.Errorf("failed to process catalogs: %w", err) + return ctrl.Result{}, fmt.Errorf("failed to process catalogs: %w", err) } // 3. Apply merged ConfigMap @@ -65,7 +61,7 @@ func (r *DevHubPluginCatalogReconciler) Reconcile(ctx context.Context, req ctrl. } lg.Info("Successfully reconciled all DevHubPluginCatalogs", "count", len(inputs)) - return ctrl.Result{RequeueAfter: catalogRequeueOnSuccess}, nil + return ctrl.Result{}, nil } // buildCatalogInputs lists all catalogs and builds processor inputs @@ -89,22 +85,24 @@ func (r *DevHubPluginCatalogReconciler) buildCatalogInputs(ctx context.Context) if dhpc.Spec.Source.PullSecret != nil { secret := &corev1.Secret{} key := types.NamespacedName{Name: dhpc.Spec.Source.PullSecret.Name, Namespace: r.OperatorNamespace} - if err := r.Get(ctx, key, secret); err == nil { - input.DockerConfig = secret.Data[".dockerconfigjson"] + if err := r.Get(ctx, key, secret); err != nil { + return nil, fmt.Errorf("failed to get pull secret %q for catalog %q: %w", dhpc.Spec.Source.PullSecret.Name, dhpc.Name, err) } + input.DockerConfig = secret.Data[".dockerconfigjson"] } // Get CA certificate if dhpc.Spec.Source.CertificateAuthority != nil { cm := &corev1.ConfigMap{} key := types.NamespacedName{Name: dhpc.Spec.Source.CertificateAuthority.Name, Namespace: r.OperatorNamespace} - if err := r.Get(ctx, key, cm); err == nil { - caKey := dhpc.Spec.Source.CertificateAuthority.Key - if caKey == "" { - caKey = "ca.crt" - } - input.CACert = []byte(cm.Data[caKey]) + if err := r.Get(ctx, key, cm); err != nil { + return nil, fmt.Errorf("failed to get CA ConfigMap %q for catalog %q: %w", dhpc.Spec.Source.CertificateAuthority.Name, dhpc.Name, err) + } + caKey := dhpc.Spec.Source.CertificateAuthority.Key + if caKey == "" { + caKey = "ca.crt" } + input.CACert = []byte(cm.Data[caKey]) } inputs = append(inputs, input) diff --git a/pkg/catalog/processor.go b/pkg/catalog/processor.go index d6b9ba492..e1c1802e6 100644 --- a/pkg/catalog/processor.go +++ b/pkg/catalog/processor.go @@ -128,10 +128,12 @@ func (p *Processor) fetch(ctx context.Context, cat CatalogInput) ([]byte, error) return content, nil } -// merge combines multiple catalog contents, deduplicating by package name +// merge combines multiple catalog contents, validating no duplicate plugin names exist. +// Plugins are identified by name (last path segment of the package URL, without tag/digest). +// Returns an error if duplicate plugin names are found across catalogs. func (p *Processor) merge(catalogs [][]byte) ([]byte, error) { var allPlugins []model.DynaPlugin - seen := make(map[string]bool) + seen := make(map[string]string) // plugin name -> package URL (for error reporting) for _, content := range catalogs { var catalog model.DynaPluginsConfig @@ -139,10 +141,15 @@ func (p *Processor) merge(catalogs [][]byte) ([]byte, error) { return nil, fmt.Errorf("failed to parse catalog: %w", err) } for _, plugin := range catalog.Plugins { - if !seen[plugin.Package] { - allPlugins = append(allPlugins, plugin) - seen[plugin.Package] = true + name := plugin.Name() + if name == "" { + return nil, fmt.Errorf("invalid plugin package %q: cannot extract plugin name", plugin.Package) } + if existingPkg, exists := seen[name]; exists { + return nil, fmt.Errorf("duplicate plugin name %q: found in both %q and %q", name, existingPkg, plugin.Package) + } + allPlugins = append(allPlugins, plugin) + seen[name] = plugin.Package } } diff --git a/pkg/fetcher/oci.go b/pkg/fetcher/oci.go index bebf89fa6..b822de497 100644 --- a/pkg/fetcher/oci.go +++ b/pkg/fetcher/oci.go @@ -27,6 +27,22 @@ type OCIFetcher struct { validatePlugin bool // if true, validate io.backstage.dynamic-packages annotation } +// NewOCIFetcher creates a new OCI fetcher +func NewOCIFetcher(opts ...OCIOption) *OCIFetcher { + c := &OCIFetcher{ + transport: http.DefaultTransport, + keychain: authn.DefaultKeychain, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// ---------------------------------------------------------------------------- +// Options +// ---------------------------------------------------------------------------- + // OCIOption configures the OCIFetcher type OCIOption func(*OCIFetcher) @@ -34,6 +50,7 @@ type OCIOption func(*OCIFetcher) func WithInsecure() OCIOption { return func(c *OCIFetcher) { c.transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec } } @@ -45,6 +62,7 @@ func WithCACert(caCert []byte) OCIOption { pool := x509.NewCertPool() pool.AppendCertsFromPEM(caCert) c.transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{RootCAs: pool}, } } @@ -60,75 +78,6 @@ func WithDockerConfig(dockerConfig []byte) OCIOption { } } -// dockerConfigKeychain implements authn.Keychain using dockerconfigjson -type dockerConfigKeychain struct { - auths map[string]dockerAuthConfig -} - -type dockerAuthConfig struct { - Auth string `json:"auth"` - Username string `json:"username"` - Password string `json:"password"` -} - -type dockerConfig struct { - Auths map[string]dockerAuthConfig `json:"auths"` -} - -func newDockerConfigKeychain(data []byte) (*dockerConfigKeychain, error) { - var cfg dockerConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, err - } - return &dockerConfigKeychain{auths: cfg.Auths}, nil -} - -func (k *dockerConfigKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) { - registry := target.RegistryStr() - - // Try exact match first - if auth, ok := k.auths[registry]; ok { - return authToAuthenticator(auth) - } - - // Try with https:// prefix - if auth, ok := k.auths["https://"+registry]; ok { - return authToAuthenticator(auth) - } - - // Try with /v1/ suffix (Docker Hub style) - if auth, ok := k.auths["https://"+registry+"/v1/"]; ok { - return authToAuthenticator(auth) - } - - return authn.Anonymous, nil -} - -func authToAuthenticator(auth dockerAuthConfig) (authn.Authenticator, error) { - if auth.Username != "" && auth.Password != "" { - return authn.FromConfig(authn.AuthConfig{ - Username: auth.Username, - Password: auth.Password, - }), nil - } - - if auth.Auth != "" { - decoded, err := base64.StdEncoding.DecodeString(auth.Auth) - if err != nil { - return nil, err - } - parts := strings.SplitN(string(decoded), ":", 2) - if len(parts) == 2 { - return authn.FromConfig(authn.AuthConfig{ - Username: parts[0], - Password: parts[1], - }), nil - } - } - - return authn.Anonymous, nil -} - // WithPluginValidation enables plugin artifact validation. // When enabled, Fetch will verify the io.backstage.dynamic-packages annotation exists. // Use this for plugin artifacts; skip for catalog index images. @@ -138,17 +87,9 @@ func WithPluginValidation() OCIOption { } } -// NewOCIFetcher creates a new OCI fetcher -func NewOCIFetcher(opts ...OCIOption) *OCIFetcher { - c := &OCIFetcher{ - transport: http.DefaultTransport, - keychain: authn.DefaultKeychain, - } - for _, opt := range opts { - opt(c) - } - return c -} +// ---------------------------------------------------------------------------- +// Fetch methods +// ---------------------------------------------------------------------------- // FetchResult contains the result of fetching an artifact type FetchResult struct { @@ -240,6 +181,10 @@ func (c *OCIFetcher) FetchContent(ctx context.Context, ref string) (*FetchResult }, nil } +// ---------------------------------------------------------------------------- +// Internal helpers +// ---------------------------------------------------------------------------- + // validatePluginArtifact checks that the image has the required plugin annotation. // Returns nil if valid, error if the annotation is missing. func validatePluginArtifact(img v1.Image) error { @@ -265,3 +210,72 @@ func validatePluginArtifact(img v1.Image) error { return fmt.Errorf("not a valid plugin artifact (missing %s annotation)", PluginAnnotation) } + +// dockerConfigKeychain implements authn.Keychain using dockerconfigjson +type dockerConfigKeychain struct { + auths map[string]dockerAuthConfig +} + +type dockerAuthConfig struct { + Auth string `json:"auth"` + Username string `json:"username"` + Password string `json:"password"` +} + +type dockerConfig struct { + Auths map[string]dockerAuthConfig `json:"auths"` +} + +func newDockerConfigKeychain(data []byte) (*dockerConfigKeychain, error) { + var cfg dockerConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &dockerConfigKeychain{auths: cfg.Auths}, nil +} + +func (k *dockerConfigKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) { + registry := target.RegistryStr() + + // Try exact match first + if auth, ok := k.auths[registry]; ok { + return authToAuthenticator(auth) + } + + // Try with https:// prefix + if auth, ok := k.auths["https://"+registry]; ok { + return authToAuthenticator(auth) + } + + // Try with /v1/ suffix (Docker Hub style) + if auth, ok := k.auths["https://"+registry+"/v1/"]; ok { + return authToAuthenticator(auth) + } + + return authn.Anonymous, nil +} + +func authToAuthenticator(auth dockerAuthConfig) (authn.Authenticator, error) { + if auth.Username != "" && auth.Password != "" { + return authn.FromConfig(authn.AuthConfig{ + Username: auth.Username, + Password: auth.Password, + }), nil + } + + if auth.Auth != "" { + decoded, err := base64.StdEncoding.DecodeString(auth.Auth) + if err != nil { + return nil, err + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + return authn.FromConfig(authn.AuthConfig{ + Username: parts[0], + Password: parts[1], + }), nil + } + } + + return authn.Anonymous, nil +} From e381b5534c9f842612842cbfc098397aafab3389 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 24 Aug 2026 15:02:04 +0300 Subject: [PATCH 06/28] missed files --- integration_tests/devhubplugincatalog_test.go | 111 +++++++ .../devhubplugincatalog_controller_test.go | 312 +++++++++++++++++ pkg/catalog/processor_test.go | 314 ++++++++++++++++++ pkg/fetcher/extract_test.go | 201 +++++++++++ pkg/fetcher/oci_test.go | 295 ++++++++++++++++ 5 files changed, 1233 insertions(+) create mode 100644 integration_tests/devhubplugincatalog_test.go create mode 100644 internal/controller/devhubplugincatalog_controller_test.go create mode 100644 pkg/catalog/processor_test.go create mode 100644 pkg/fetcher/extract_test.go create mode 100644 pkg/fetcher/oci_test.go diff --git a/integration_tests/devhubplugincatalog_test.go b/integration_tests/devhubplugincatalog_test.go new file mode 100644 index 000000000..33a34d667 --- /dev/null +++ b/integration_tests/devhubplugincatalog_test.go @@ -0,0 +1,111 @@ +package integration_tests + +import ( + "context" + "os" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/redhat-developer/rhdh-operator/api" + "github.com/redhat-developer/rhdh-operator/internal/controller" + "github.com/redhat-developer/rhdh-operator/pkg/catalog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const ( + defaultCatalogIndexImage = "quay.io/rhdh/plugin-catalog-index:next" +) + +func getCatalogIndexImage() string { + if img := os.Getenv("CATALOG_INDEX_IMAGE"); img != "" { + return img + } + return defaultCatalogIndexImage +} + +var _ = When("create DevHubPluginCatalog", func() { + + var ( + ctx context.Context + ns string + ) + + BeforeEach(func() { + ctx = context.Background() + ns = createNamespace(ctx) + }) + + AfterEach(func() { + deleteNamespace(ctx, ns) + }) + + It("fetches catalog and patches ConfigMap with dynamic plugins", func() { + catalogName := "test-catalog-" + randString(5) + configMapName := controller.DefaultConfigMapName + + // Create the default-config ConfigMap that will be patched + defaultConfigCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: ns, + }, + Data: map[string]string{ + "placeholder": "initial", + }, + } + err := k8sClient.Create(ctx, defaultConfigCM) + Expect(err).ShouldNot(HaveOccurred()) + + // Create DevHubPluginCatalog pointing to the catalog index + dhpc := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: catalogName, + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: getCatalogIndexImage(), + }, + }, + } + + err = k8sClient.Create(ctx, dhpc) + Expect(err).ShouldNot(HaveOccurred()) + + // Create and run the reconciler + reconciler := &controller.DevHubPluginCatalogReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + OperatorNamespace: ns, + Processor: catalog.NewProcessor(), + } + + // Reconcile - this should fetch the catalog and patch the ConfigMap + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: catalogName}, + }) + Expect(err).ShouldNot(HaveOccurred()) + + // Verify the ConfigMap was patched with dynamic plugins content + Eventually(func(g Gomega) { + cm := &corev1.ConfigMap{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: ns}, cm) + g.Expect(err).ShouldNot(HaveOccurred()) + + // Should have the catalogs-ready marker + g.Expect(cm.Data).To(HaveKey(catalog.CatalogsReadyKey)) + g.Expect(cm.Data[catalog.CatalogsReadyKey]).To(Equal("true")) + + // Should have dynamic-plugins.yaml with content + g.Expect(cm.Data).To(HaveKey("dynamic-plugins.yaml")) + dpContent := cm.Data["dynamic-plugins.yaml"] + g.Expect(dpContent).To(ContainSubstring("plugins:")) + g.Expect(dpContent).To(ContainSubstring("oci://")) + }, time.Second*30, time.Second).Should(Succeed()) + }) +}) diff --git a/internal/controller/devhubplugincatalog_controller_test.go b/internal/controller/devhubplugincatalog_controller_test.go new file mode 100644 index 000000000..008ddfb01 --- /dev/null +++ b/internal/controller/devhubplugincatalog_controller_test.go @@ -0,0 +1,312 @@ +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/redhat-developer/rhdh-operator/api" + "github.com/redhat-developer/rhdh-operator/pkg/catalog" +) + +func setupCatalogTestReconciler(objects ...client.Object) *DevHubPluginCatalogReconciler { + scheme := runtime.NewScheme() + _ = api.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + return &DevHubPluginCatalogReconciler{ + Client: fakeClient, + Scheme: scheme, + OperatorNamespace: "rhdh-operator", + Processor: catalog.NewProcessor(), + } +} + +func TestBuildCatalogInputs_NoCatalogs(t *testing.T) { + r := setupCatalogTestReconciler() + + inputs, err := r.buildCatalogInputs(context.TODO()) + require.NoError(t, err) + assert.Empty(t, inputs) +} + +func TestBuildCatalogInputs_SingleCatalog(t *testing.T) { + dhpc := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog:v1", + }, + }, + } + + r := setupCatalogTestReconciler(dhpc) + + inputs, err := r.buildCatalogInputs(context.TODO()) + require.NoError(t, err) + require.Len(t, inputs, 1) + assert.Equal(t, "oci://registry.example.com/rhdh/plugin-catalog:v1", inputs[0].Ref) + assert.False(t, inputs[0].SkipTLSVerify) + assert.Nil(t, inputs[0].DockerConfig) + assert.Nil(t, inputs[0].CACert) +} + +func TestBuildCatalogInputs_WithSkipTLSVerify(t *testing.T) { + dhpc := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog:v1", + SkipTLSVerify: true, + }, + }, + } + + r := setupCatalogTestReconciler(dhpc) + + inputs, err := r.buildCatalogInputs(context.TODO()) + require.NoError(t, err) + require.Len(t, inputs, 1) + assert.True(t, inputs[0].SkipTLSVerify) +} + +func TestBuildCatalogInputs_WithPullSecret(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-creds", + Namespace: "rhdh-operator", + }, + Data: map[string][]byte{ + ".dockerconfigjson": []byte(`{"auths":{"registry.example.com":{"auth":"dXNlcjpwYXNz"}}}`), + }, + } + + dhpc := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog:v1", + PullSecret: &corev1.LocalObjectReference{ + Name: "registry-creds", + }, + }, + }, + } + + r := setupCatalogTestReconciler(dhpc, secret) + + inputs, err := r.buildCatalogInputs(context.TODO()) + require.NoError(t, err) + require.Len(t, inputs, 1) + assert.NotNil(t, inputs[0].DockerConfig) + assert.Contains(t, string(inputs[0].DockerConfig), "registry.example.com") +} + +func TestBuildCatalogInputs_WithCACert(t *testing.T) { + caCert := `-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +-----END CERTIFICATE-----` + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-ca", + Namespace: "rhdh-operator", + }, + Data: map[string]string{ + "ca.crt": caCert, + }, + } + + dhpc := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog:v1", + CertificateAuthority: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "registry-ca", + }, + Key: "ca.crt", + }, + }, + }, + } + + r := setupCatalogTestReconciler(dhpc, cm) + + inputs, err := r.buildCatalogInputs(context.TODO()) + require.NoError(t, err) + require.Len(t, inputs, 1) + assert.NotNil(t, inputs[0].CACert) + assert.Contains(t, string(inputs[0].CACert), "BEGIN CERTIFICATE") +} + +func TestBuildCatalogInputs_WithCACert_DefaultKey(t *testing.T) { + caCert := `-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +-----END CERTIFICATE-----` + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "registry-ca", + Namespace: "rhdh-operator", + }, + Data: map[string]string{ + "ca.crt": caCert, + }, + } + + dhpc := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog:v1", + CertificateAuthority: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "registry-ca", + }, + // Key is empty - should default to "ca.crt" + }, + }, + }, + } + + r := setupCatalogTestReconciler(dhpc, cm) + + inputs, err := r.buildCatalogInputs(context.TODO()) + require.NoError(t, err) + require.Len(t, inputs, 1) + assert.NotNil(t, inputs[0].CACert) +} + +func TestBuildCatalogInputs_MultipleCatalogs(t *testing.T) { + dhpc1 := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "catalog-1", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog-1:v1", + }, + }, + } + + dhpc2 := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "catalog-2", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog-2:v1", + }, + }, + } + + r := setupCatalogTestReconciler(dhpc1, dhpc2) + + inputs, err := r.buildCatalogInputs(context.TODO()) + require.NoError(t, err) + assert.Len(t, inputs, 2) +} + +func TestBuildCatalogInputs_MissingSecret_Fails(t *testing.T) { + dhpc := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog:v1", + PullSecret: &corev1.LocalObjectReference{ + Name: "non-existent-secret", + }, + }, + }, + } + + r := setupCatalogTestReconciler(dhpc) + + _, err := r.buildCatalogInputs(context.TODO()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get pull secret") + assert.Contains(t, err.Error(), "non-existent-secret") +} + +func TestBuildCatalogInputs_MissingCACert_Fails(t *testing.T) { + dhpc := &api.DevHubPluginCatalog{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-catalog", + }, + Spec: api.DevHubPluginCatalogSpec{ + Source: api.CatalogSource{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog:v1", + CertificateAuthority: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "non-existent-ca", + }, + }, + }, + }, + } + + r := setupCatalogTestReconciler(dhpc) + + _, err := r.buildCatalogInputs(context.TODO()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get CA ConfigMap") + assert.Contains(t, err.Error(), "non-existent-ca") +} + +func TestApplyConfigMap(t *testing.T) { + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: DefaultConfigMapName, + Namespace: "rhdh-operator", + }, + Data: map[string]string{ + "existing-key": "existing-value", + }, + } + + r := setupCatalogTestReconciler(existingCM) + + patchBytes := []byte(`{"data":{"dynamic-plugins.yaml":"test content",".catalogs-ready":"true"}}`) + + err := r.applyConfigMap(context.TODO(), patchBytes) + require.NoError(t, err) + + // Verify the ConfigMap was patched + cm := &corev1.ConfigMap{} + err = r.Get(context.TODO(), types.NamespacedName{ + Name: DefaultConfigMapName, + Namespace: "rhdh-operator", + }, cm) + require.NoError(t, err) +} + +func TestConstants(t *testing.T) { + assert.Equal(t, "rhdh-default-config", DefaultConfigMapName) +} diff --git a/pkg/catalog/processor_test.go b/pkg/catalog/processor_test.go new file mode 100644 index 000000000..bd9799839 --- /dev/null +++ b/pkg/catalog/processor_test.go @@ -0,0 +1,314 @@ +package catalog + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" + + "github.com/redhat-developer/rhdh-operator/pkg/model" +) + +func TestNewProcessor(t *testing.T) { + p := NewProcessor() + assert.NotNil(t, p) +} + +func TestMerge_SingleCatalog(t *testing.T) { + p := NewProcessor() + + catalog := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" + disabled: false + - package: "oci://registry.example.com/rhdh/plugin-kubernetes:1.0" + disabled: false +` + + result, err := p.merge([][]byte{[]byte(catalog)}) + require.NoError(t, err) + + var merged model.DynaPluginsConfig + require.NoError(t, yaml.Unmarshal(result, &merged)) + + assert.Len(t, merged.Plugins, 2) +} + +func TestMerge_MultipleCatalogs(t *testing.T) { + p := NewProcessor() + + catalog1 := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" + disabled: false + - package: "oci://registry.example.com/rhdh/plugin-kubernetes:1.0" + disabled: false +` + + catalog2 := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-argocd:1.0" + disabled: false + - package: "oci://registry.example.com/rhdh/plugin-search:1.0" + disabled: false +` + + result, err := p.merge([][]byte{[]byte(catalog1), []byte(catalog2)}) + require.NoError(t, err) + + var merged model.DynaPluginsConfig + require.NoError(t, yaml.Unmarshal(result, &merged)) + + assert.Len(t, merged.Plugins, 4) +} + +func TestMerge_DuplicateSameURL_Fails(t *testing.T) { + p := NewProcessor() + + catalog := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" + disabled: false + - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" + disabled: true +` + + _, err := p.merge([][]byte{[]byte(catalog)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "duplicate plugin name") + assert.Contains(t, err.Error(), "plugin-techdocs") +} + +func TestMerge_DuplicateDifferentURL_Fails(t *testing.T) { + p := NewProcessor() + + // Same plugin name but different registries/tags + catalog := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" + disabled: false + - package: "oci://other-registry.com/path/plugin-techdocs@sha256:abc123" + disabled: false +` + + _, err := p.merge([][]byte{[]byte(catalog)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "duplicate plugin name") + assert.Contains(t, err.Error(), "plugin-techdocs") +} + +func TestMerge_DuplicateAcrossCatalogs_Fails(t *testing.T) { + p := NewProcessor() + + catalog1 := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" + disabled: false +` + + catalog2 := `plugins: + - package: "oci://other-registry.com/rhdh/plugin-techdocs:2.0" + disabled: false +` + + _, err := p.merge([][]byte{[]byte(catalog1), []byte(catalog2)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "duplicate plugin name") + assert.Contains(t, err.Error(), "plugin-techdocs") +} + +func TestMerge_EmptyCatalog(t *testing.T) { + p := NewProcessor() + + catalog := `plugins: []` + + result, err := p.merge([][]byte{[]byte(catalog)}) + require.NoError(t, err) + + var merged model.DynaPluginsConfig + require.NoError(t, yaml.Unmarshal(result, &merged)) + + assert.Empty(t, merged.Plugins) +} + +func TestMerge_InvalidYAML(t *testing.T) { + p := NewProcessor() + + invalidYAML := `this is not: valid: yaml: [}` + + _, err := p.merge([][]byte{[]byte(invalidYAML)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse catalog") +} + +func TestMerge_InvalidPluginPackage(t *testing.T) { + p := NewProcessor() + + // Plugin with unsupported URL format that results in empty name + catalog := `plugins: + - package: "invalid-format" + disabled: false +` + + _, err := p.merge([][]byte{[]byte(catalog)}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot extract plugin name") +} + +func TestMerge_PreservesPluginConfig(t *testing.T) { + p := NewProcessor() + + catalog := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-github:1.0" + disabled: false + pluginConfig: + catalog: + providers: + github: + organization: my-org +` + + result, err := p.merge([][]byte{[]byte(catalog)}) + require.NoError(t, err) + + var merged model.DynaPluginsConfig + require.NoError(t, yaml.Unmarshal(result, &merged)) + + assert.Len(t, merged.Plugins, 1) + assert.NotNil(t, merged.Plugins[0].PluginConfig) +} + +func TestWrapInConfigMap(t *testing.T) { + p := NewProcessor() + + content := []byte(`plugins: + - package: "oci://registry.example.com/rhdh/plugin-test:1.0" +`) + + wrapped := p.wrapInConfigMap(content) + wrappedStr := string(wrapped) + + // Verify it's valid YAML + var cm map[string]interface{} + require.NoError(t, yaml.Unmarshal(wrapped, &cm)) + + // Verify structure + assert.Equal(t, "v1", cm["apiVersion"]) + assert.Equal(t, "ConfigMap", cm["kind"]) + + metadata, ok := cm["metadata"].(map[interface{}]interface{}) + require.True(t, ok) + assert.Equal(t, InternalConfigMapName, metadata["name"]) + + // Verify data key + data, ok := cm["data"].(map[interface{}]interface{}) + require.True(t, ok) + assert.Contains(t, data, model.DynamicPluginsFile) + + // The content should contain our plugin + assert.Contains(t, wrappedStr, "oci://registry.example.com/rhdh/plugin-test:1.0") +} + +func TestIndentYAML(t *testing.T) { + tests := []struct { + name string + content string + spaces int + expected string + }{ + { + name: "simple indent", + content: "line1\nline2", + spaces: 2, + expected: " line1\n line2", + }, + { + name: "four spaces", + content: "key: value", + spaces: 4, + expected: " key: value", + }, + { + name: "empty lines preserved", + content: "line1\n\nline3", + spaces: 2, + expected: " line1\n\n line3", + }, + { + name: "zero spaces", + content: "no indent", + spaces: 0, + expected: "no indent", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := indentYAML(tt.content, tt.spaces) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCatalogInput(t *testing.T) { + input := CatalogInput{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog-index:v1", + DockerConfig: []byte(`{"auths":{}}`), + CACert: []byte("-----BEGIN CERTIFICATE-----"), + SkipTLSVerify: true, + } + + assert.Equal(t, "oci://registry.example.com/rhdh/plugin-catalog-index:v1", input.Ref) + assert.NotNil(t, input.DockerConfig) + assert.NotNil(t, input.CACert) + assert.True(t, input.SkipTLSVerify) +} + +func TestConstants(t *testing.T) { + assert.Equal(t, "catalog-dynamic-plugins", InternalConfigMapName) + assert.Equal(t, "dynamic-plugins.default.yaml", CatalogFileName) + assert.Equal(t, ".catalogs-ready", CatalogsReadyKey) +} + +func TestProcessOutputFormat(t *testing.T) { + // Verifies the JSON patch structure without actually fetching + + patchData := map[string]interface{}{ + "data": map[string]string{ + model.DynamicPluginsFile: "test content", + CatalogsReadyKey: "true", + }, + } + + jsonBytes, err := json.Marshal(patchData) + require.NoError(t, err) + + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(jsonBytes, &parsed)) + + data, ok := parsed["data"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "test content", data[model.DynamicPluginsFile]) + assert.Equal(t, "true", data[CatalogsReadyKey]) +} + +func TestWrapInConfigMap_MultilineContent(t *testing.T) { + p := NewProcessor() + + content := []byte(`plugins: + - package: "oci://registry.example.com/rhdh/plugin-github:1.0" + disabled: false + pluginConfig: + catalog: + providers: + github: + organization: my-org +`) + + wrapped := p.wrapInConfigMap(content) + + // Should produce valid YAML + var cm map[string]interface{} + require.NoError(t, yaml.Unmarshal(wrapped, &cm)) + + wrappedStr := string(wrapped) + assert.True(t, strings.Contains(wrappedStr, "data:")) + assert.True(t, strings.Contains(wrappedStr, model.DynamicPluginsFile+": |")) +} diff --git a/pkg/fetcher/extract_test.go b/pkg/fetcher/extract_test.go new file mode 100644 index 000000000..55bf5a37d --- /dev/null +++ b/pkg/fetcher/extract_test.go @@ -0,0 +1,201 @@ +package fetcher + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractTarBytes(t *testing.T) { + // Create a tar archive in memory + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + // Add a file + content := []byte("hello world") + hdr := &tar.Header{ + Name: "test.txt", + Mode: 0644, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(content) + require.NoError(t, err) + + // Add a directory + dirHdr := &tar.Header{ + Name: "subdir/", + Mode: 0755, + Typeflag: tar.TypeDir, + } + require.NoError(t, tw.WriteHeader(dirHdr)) + + // Add a file in subdirectory + subContent := []byte("nested content") + subHdr := &tar.Header{ + Name: "subdir/nested.txt", + Mode: 0644, + Size: int64(len(subContent)), + } + require.NoError(t, tw.WriteHeader(subHdr)) + _, err = tw.Write(subContent) + require.NoError(t, err) + + require.NoError(t, tw.Close()) + + // Extract to temp directory + destDir := t.TempDir() + err = extractTarBytes(buf.Bytes(), destDir) + require.NoError(t, err) + + // Verify files exist + data, err := os.ReadFile(filepath.Join(destDir, "test.txt")) + require.NoError(t, err) + assert.Equal(t, "hello world", string(data)) + + data, err = os.ReadFile(filepath.Join(destDir, "subdir", "nested.txt")) + require.NoError(t, err) + assert.Equal(t, "nested content", string(data)) +} + +func TestExtractTarGzBytes(t *testing.T) { + // Create a gzipped tar archive + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + content := []byte("gzipped content") + hdr := &tar.Header{ + Name: "gzipped.txt", + Mode: 0644, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(content) + require.NoError(t, err) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + // Extract + destDir := t.TempDir() + err = extractTarGzBytes(buf.Bytes(), destDir) + require.NoError(t, err) + + // Verify + data, err := os.ReadFile(filepath.Join(destDir, "gzipped.txt")) + require.NoError(t, err) + assert.Equal(t, "gzipped content", string(data)) +} + +func TestExtractTarPathTraversal(t *testing.T) { + // Create a tar with path traversal attempt + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + hdr := &tar.Header{ + Name: "../../../etc/passwd", + Mode: 0644, + Size: 0, + } + require.NoError(t, tw.WriteHeader(hdr)) + require.NoError(t, tw.Close()) + + destDir := t.TempDir() + err := extractTarBytes(buf.Bytes(), destDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid tar path") +} + +func TestExtractTarSymlinkEscape(t *testing.T) { + // Create a tar with symlink escape attempt + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + hdr := &tar.Header{ + Name: "escape-link", + Mode: 0777, + Typeflag: tar.TypeSymlink, + Linkname: "../../../etc/passwd", + } + require.NoError(t, tw.WriteHeader(hdr)) + require.NoError(t, tw.Close()) + + destDir := t.TempDir() + err := extractTarBytes(buf.Bytes(), destDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "symlink escapes destination") +} + +func TestExtractTarValidSymlink(t *testing.T) { + // Create a tar with valid internal symlink + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + // Add target file + content := []byte("target content") + hdr := &tar.Header{ + Name: "target.txt", + Mode: 0644, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(content) + require.NoError(t, err) + + // Add symlink to target + linkHdr := &tar.Header{ + Name: "link.txt", + Mode: 0777, + Typeflag: tar.TypeSymlink, + Linkname: "target.txt", + } + require.NoError(t, tw.WriteHeader(linkHdr)) + require.NoError(t, tw.Close()) + + destDir := t.TempDir() + err = extractTarBytes(buf.Bytes(), destDir) + require.NoError(t, err) + + // Verify symlink works + data, err := os.ReadFile(filepath.Join(destDir, "link.txt")) + require.NoError(t, err) + assert.Equal(t, "target content", string(data)) +} + +func TestExtractTarEmptyArchive(t *testing.T) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, tw.Close()) + + destDir := t.TempDir() + err := extractTarBytes(buf.Bytes(), destDir) + assert.NoError(t, err) +} + +func TestGetMaxEntrySize(t *testing.T) { + // Test default value + os.Unsetenv("MAX_ENTRY_SIZE") + assert.Equal(t, int64(DefaultMaxEntrySize), getMaxEntrySize()) + + // Test custom value + os.Setenv("MAX_ENTRY_SIZE", "1000") + assert.Equal(t, int64(1000), getMaxEntrySize()) + + // Test invalid value falls back to default + os.Setenv("MAX_ENTRY_SIZE", "invalid") + assert.Equal(t, int64(DefaultMaxEntrySize), getMaxEntrySize()) + + // Test negative value falls back to default + os.Setenv("MAX_ENTRY_SIZE", "-100") + assert.Equal(t, int64(DefaultMaxEntrySize), getMaxEntrySize()) + + // Cleanup + os.Unsetenv("MAX_ENTRY_SIZE") +} diff --git a/pkg/fetcher/oci_test.go b/pkg/fetcher/oci_test.go new file mode 100644 index 000000000..74182a5aa --- /dev/null +++ b/pkg/fetcher/oci_test.go @@ -0,0 +1,295 @@ +package fetcher + +import ( + "crypto/tls" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOCIFetcher(t *testing.T) { + fetcher := NewOCIFetcher() + assert.NotNil(t, fetcher) + assert.NotNil(t, fetcher.keychain) + assert.NotNil(t, fetcher.transport) + assert.False(t, fetcher.validatePlugin) +} + +func TestWithInsecure(t *testing.T) { + fetcher := NewOCIFetcher(WithInsecure()) + + transport, ok := fetcher.transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.TLSClientConfig) + assert.True(t, transport.TLSClientConfig.InsecureSkipVerify) +} + +func TestWithCACert(t *testing.T) { + // Create a test CA cert (self-signed, for testing) + caCert := []byte(`-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCFzP9GVMF/odlY0x +fXH5XKPF6a9kKZl7e8V7H6KYQO7MYHl7aNQp7XZf0g/iYn/lFxVKcGFz/fQMt7oB +AgMBAAGjUzBRMB0GA1UdDgQWBBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAfBgNVHSME +GDAWgBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG +SIb3DQEBCwUAA0EA0GXpF1JguGJ2I7m3pCnYdU3lfQEb7y0e6pZyfMz5EPuV1JJh +k5mN+kTl5kmYJsYLsL7Q3v5K5ng+q3lQiPqE/w== +-----END CERTIFICATE-----`) + + fetcher := NewOCIFetcher(WithCACert(caCert)) + + transport, ok := fetcher.transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.TLSClientConfig) + assert.NotNil(t, transport.TLSClientConfig.RootCAs) +} + +func TestWithPluginValidation(t *testing.T) { + fetcher := NewOCIFetcher(WithPluginValidation()) + assert.True(t, fetcher.validatePlugin) +} + +func TestWithDockerConfig(t *testing.T) { + dockerConfig := createDockerConfig(t, map[string]authEntry{ + "registry.example.com": {Username: "user", Password: "pass"}, + }) + + fetcher := NewOCIFetcher(WithDockerConfig(dockerConfig)) + assert.NotNil(t, fetcher.keychain) + + // Verify we can resolve credentials + kc, ok := fetcher.keychain.(*dockerConfigKeychain) + require.True(t, ok) + assert.Len(t, kc.auths, 1) +} + +func TestDockerConfigKeychain_Resolve(t *testing.T) { + tests := []struct { + name string + auths map[string]authEntry + registry string + expectedUser string + expectedAnon bool + }{ + { + name: "exact match", + auths: map[string]authEntry{ + "registry.example.com": {Username: "user1", Password: "pass1"}, + }, + registry: "registry.example.com", + expectedUser: "user1", + }, + { + name: "https prefix match", + auths: map[string]authEntry{ + "https://registry.example.com": {Username: "user2", Password: "pass2"}, + }, + registry: "registry.example.com", + expectedUser: "user2", + }, + { + name: "docker hub style v1 suffix", + auths: map[string]authEntry{ + "https://index.docker.io/v1/": {Username: "user3", Password: "pass3"}, + }, + registry: "index.docker.io", + expectedUser: "user3", + }, + { + name: "no match returns anonymous", + auths: map[string]authEntry{ + "other.registry.com": {Username: "user", Password: "pass"}, + }, + registry: "registry.example.com", + expectedAnon: true, + }, + { + name: "base64 auth field", + auths: map[string]authEntry{ + "registry.example.com": {Auth: base64.StdEncoding.EncodeToString([]byte("user4:pass4"))}, + }, + registry: "registry.example.com", + expectedUser: "user4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dockerConfig := createDockerConfig(t, tt.auths) + kc, err := newDockerConfigKeychain(dockerConfig) + require.NoError(t, err) + + ref, err := name.ParseReference(tt.registry + "/test/image:latest") + require.NoError(t, err) + + auth, err := kc.Resolve(ref.Context().Registry) + require.NoError(t, err) + + if tt.expectedAnon { + assert.Equal(t, authn.Anonymous, auth) + } else { + cfg, err := auth.Authorization() + require.NoError(t, err) + assert.Equal(t, tt.expectedUser, cfg.Username) + } + }) + } +} + +func TestNewDockerConfigKeychain_InvalidJSON(t *testing.T) { + _, err := newDockerConfigKeychain([]byte("not valid json")) + assert.Error(t, err) +} + +func TestAuthToAuthenticator_EmptyAuth(t *testing.T) { + auth, err := authToAuthenticator(dockerAuthConfig{}) + require.NoError(t, err) + assert.Equal(t, authn.Anonymous, auth) +} + +func TestAuthToAuthenticator_InvalidBase64(t *testing.T) { + _, err := authToAuthenticator(dockerAuthConfig{Auth: "not-valid-base64!"}) + assert.Error(t, err) +} + +func TestAuthToAuthenticator_MalformedAuthString(t *testing.T) { + // Base64 encoded "no-colon-here" + auth, err := authToAuthenticator(dockerAuthConfig{ + Auth: base64.StdEncoding.EncodeToString([]byte("nocolonhere")), + }) + require.NoError(t, err) + // Should return anonymous when format is wrong (no colon separator) + assert.Equal(t, authn.Anonymous, auth) +} + +func TestMultipleOptions(t *testing.T) { + caCert := []byte(`-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCFzP9GVMF/odlY0x +fXH5XKPF6a9kKZl7e8V7H6KYQO7MYHl7aNQp7XZf0g/iYn/lFxVKcGFz/fQMt7oB +AgMBAAGjUzBRMB0GA1UdDgQWBBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAfBgNVHSME +GDAWgBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG +SIb3DQEBCwUAA0EA0GXpF1JguGJ2I7m3pCnYdU3lfQEb7y0e6pZyfMz5EPuV1JJh +k5mN+kTl5kmYJsYLsL7Q3v5K5ng+q3lQiPqE/w== +-----END CERTIFICATE-----`) + + dockerConfig := createDockerConfig(t, map[string]authEntry{ + "registry.example.com": {Username: "user", Password: "pass"}, + }) + + // Apply multiple options - last one that sets transport wins for transport + fetcher := NewOCIFetcher( + WithCACert(caCert), + WithDockerConfig(dockerConfig), + WithPluginValidation(), + ) + + // Verify all options were applied + assert.True(t, fetcher.validatePlugin) + + transport, ok := fetcher.transport.(*http.Transport) + require.True(t, ok) + assert.NotNil(t, transport.TLSClientConfig) + + kc, ok := fetcher.keychain.(*dockerConfigKeychain) + require.True(t, ok) + assert.Len(t, kc.auths, 1) +} + +func TestInsecureOverridesCACert(t *testing.T) { + caCert := []byte(`-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCFzP9GVMF/odlY0x +fXH5XKPF6a9kKZl7e8V7H6KYQO7MYHl7aNQp7XZf0g/iYn/lFxVKcGFz/fQMt7oB +AgMBAAGjUzBRMB0GA1UdDgQWBBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAfBgNVHSME +GDAWgBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG +SIb3DQEBCwUAA0EA0GXpF1JguGJ2I7m3pCnYdU3lfQEb7y0e6pZyfMz5EPuV1JJh +k5mN+kTl5kmYJsYLsL7Q3v5K5ng+q3lQiPqE/w== +-----END CERTIFICATE-----`) + + // WithInsecure applied after WithCACert should override + fetcher := NewOCIFetcher( + WithCACert(caCert), + WithInsecure(), + ) + + transport, ok := fetcher.transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.TLSClientConfig) + // InsecureSkipVerify should be true since it was applied last + assert.True(t, transport.TLSClientConfig.InsecureSkipVerify) +} + +// Helper types and functions + +type authEntry struct { + Username string + Password string + Auth string +} + +func createDockerConfig(t *testing.T, auths map[string]authEntry) []byte { + t.Helper() + + cfg := struct { + Auths map[string]dockerAuthConfig `json:"auths"` + }{ + Auths: make(map[string]dockerAuthConfig), + } + + for registry, entry := range auths { + cfg.Auths[registry] = dockerAuthConfig{ + Username: entry.Username, + Password: entry.Password, + Auth: entry.Auth, + } + } + + data, err := json.Marshal(cfg) + require.NoError(t, err) + return data +} + +// TestFetchResult verifies the FetchResult struct +func TestFetchResult(t *testing.T) { + result := &FetchResult{ + Digest: "sha256:abc123", + Content: []byte("test content"), + MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + } + + assert.Equal(t, "sha256:abc123", result.Digest) + assert.Equal(t, []byte("test content"), result.Content) + assert.Equal(t, "application/vnd.oci.image.layer.v1.tar+gzip", result.MediaType) +} + +// TestPluginAnnotation verifies the constant +func TestPluginAnnotation(t *testing.T) { + assert.Equal(t, "io.backstage.dynamic-packages", PluginAnnotation) +} + +// TestTransportIsConfigurable verifies transport configuration +func TestTransportIsConfigurable(t *testing.T) { + customTransport := &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + }, + } + + // Verify default transport works + fetcher := NewOCIFetcher() + assert.Equal(t, http.DefaultTransport, fetcher.transport) + + // Verify WithInsecure creates a new transport + fetcher2 := NewOCIFetcher(WithInsecure()) + assert.NotEqual(t, customTransport, fetcher2.transport) +} From 558789983127502f8f30e8a042b872b170896376 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 25 Aug 2026 16:24:32 +0300 Subject: [PATCH 07/28] DISABLE_CATALOG_CONTROLLER --- Makefile | 6 +++--- cmd/main.go | 25 ++++++++++++++++--------- docs/developer.md | 2 ++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 692e75422..1933dbda5 100644 --- a/Makefile +++ b/Makefile @@ -172,12 +172,12 @@ fmt: goimports ## Format the code using goimports .PHONY: test test: manifests generate fmt vet setup-envtest $(LOCALBIN) ## Run tests. We need LOCALBIN=$(LOCALBIN) to get correct default-config path @OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) ./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) - OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $(PKGS) -coverprofile cover.out + DISABLE_CATALOG_CONTROLLER=true OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $(PKGS) -coverprofile cover.out .PHONY: integration-test integration-test: ginkgo manifests generate fmt vet envtest $(LOCALBIN) ## Run integration_tests. We need LOCALBIN=$(LOCALBIN) to get correct default-config path @OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) ./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) - OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -v -r $(ARGS) integration_tests + DISABLE_CATALOG_CONTROLLER=true OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -v -r $(ARGS) integration_tests # After this time, Ginkgo will emit progress reports, so we can get visibility into long-running tests. POLL_PROGRESS_INTERVAL := 600s @@ -237,7 +237,7 @@ build: manifests generate fmt vet ## Build manager binary. .PHONY: run run: manifests generate fmt vet $(LOCALBIN) ## Run a controller from your host. @OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) ./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) - OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) go run -C $(LOCALBIN) ../cmd/main.go $(ARGS) + DISABLE_CATALOG_CONTROLLER=true OPERATOR_DP_PROCESSING=$(OPERATOR_DP_PROCESSING) RELATED_IMAGE_plugin_installer=$(RELATED_IMAGE_plugin_installer) go run -C $(LOCALBIN) ../cmd/main.go $(ARGS) .PHONY: local-dynamic-plugins local-dynamic-plugins: ## Generate local-test dynamic-plugins.yaml from catalog-index image for local testing diff --git a/cmd/main.go b/cmd/main.go index 080886657..09ef304ff 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -69,6 +69,7 @@ func main() { var metricsCertPath, metricsCertName, metricsCertKey string var webhookCertPath, webhookCertName, webhookCertKey string var enableCacheLabelFilter bool + var disableCatalogController bool flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") @@ -91,6 +92,8 @@ func main() { flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableCacheLabelFilter, "enable-cache-label-filter", os.Getenv("ENABLE_CACHE_LABEL_FILTER") == "true", "If set, the cache will only store Secrets and ConfigMaps with the label 'rhdh.redhat.com/external-config=true'. This reduces memory consumption. Can also be set via ENABLE_CACHE_LABEL_FILTER env var.") + flag.BoolVar(&disableCatalogController, "disable-catalog-controller", os.Getenv("DISABLE_CATALOG_CONTROLLER") == "true", + "If set, the DevHubPluginCatalog controller will not be started. Useful for local development. Can also be set via DISABLE_CATALOG_CONTROLLER env var.") opts := zap.Options{ Development: true, @@ -295,15 +298,19 @@ func main() { } // Setup DevHubPluginCatalog controller - operatorNamespace := getOperatorNamespace() - if err = (&controller.DevHubPluginCatalogReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - OperatorNamespace: operatorNamespace, - Processor: catalog.NewProcessor(), - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "DevHubPluginCatalog") - os.Exit(1) + if !disableCatalogController { + operatorNamespace := getOperatorNamespace() + if err = (&controller.DevHubPluginCatalogReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + OperatorNamespace: operatorNamespace, + Processor: catalog.NewProcessor(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "DevHubPluginCatalog") + os.Exit(1) + } + } else { + setupLog.Info("DevHubPluginCatalog controller disabled") } // +kubebuilder:scaffold:builder diff --git a/docs/developer.md b/docs/developer.md index 4d8d589a6..c3c6711f5 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -94,6 +94,8 @@ make [PROFILE=] [install] run You can use it for manual and automated ([such as](../integration_tests/README.md) `USE_EXISTING_CLUSTER=true make integration-test`) tests efficiently, but, note, RBAC is not working with this kind of deployment. +**Note:** The DevHubPluginCatalog controller is disabled by default for local development (`DISABLE_CATALOG_CONTROLLER=true`) to avoid conflicts with an already deployed operator's catalog ConfigMap. + ### Deploy operator to the real cluster #### Configuration Profiles From d420ddcef4e4f56df17a3d7001d5f94b542f2405 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 25 Aug 2026 16:30:01 +0300 Subject: [PATCH 08/28] bundle --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- ...kstage-operator.clusterserviceversion.yaml | 26 +++- ...kstage-operator.clusterserviceversion.yaml | 2 +- dist/backstage.io/install.yaml | 135 ++++++++++++++++++ 8 files changed, 166 insertions(+), 7 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ca4d7a346..9b7acdd38 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 1357c08bd..c3ecd6b25 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 6613a8896..b315b789e 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index 9c86c46ee..c9c126a0f 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 7355223e3..004d0b420 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -6,7 +6,7 @@ package v1alpha5 import ( corev1 "k8s.io/api/core/v1" - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 37a8caf68..cb3669bd9 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-06T10:03:19Z" + createdAt: "2026-08-25T13:29:27Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 @@ -54,6 +54,9 @@ spec: kind: Backstage name: backstages.rhdh.redhat.com version: v1alpha5 + - kind: DevHubPluginCatalog + name: devhubplugincatalogs.rhdh.redhat.com + version: v1alpha5 description: | Backstage Operator displayName: Backstage Operator @@ -153,6 +156,14 @@ spec: - get - patch - update + - apiGroups: + - rhdh.redhat.com + resources: + - devhubplugincatalogs + verbs: + - get + - list + - watch - apiGroups: - route.openshift.io resources: @@ -218,6 +229,15 @@ spec: - --metrics-bind-address=:8443 command: - /manager + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace image: quay.io/rhdh-community/operator:2.0.0 livenessProbe: httpGet: @@ -256,6 +276,8 @@ spec: name: default-config - mountPath: /plugin-deps name: plugin-deps + - mountPath: /tmp + name: tmp securityContext: runAsNonRoot: true serviceAccountName: backstage-controller-manager @@ -268,6 +290,8 @@ spec: name: plugin-deps optional: true name: plugin-deps + - emptyDir: {} + name: tmp permissions: - rules: - apiGroups: diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index bf3c52839..26c44a1bc 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-20T07:51:12Z" + createdAt: "2026-08-25T13:29:24Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/dist/backstage.io/install.yaml b/dist/backstage.io/install.yaml index 2bf27cedf..63e453185 100644 --- a/dist/backstage.io/install.yaml +++ b/dist/backstage.io/install.yaml @@ -2180,6 +2180,120 @@ spec: subresources: status: {} --- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: devhubplugincatalogs.rhdh.redhat.com +spec: + group: rhdh.redhat.com + names: + kind: DevHubPluginCatalog + listKind: DevHubPluginCatalogList + plural: devhubplugincatalogs + shortNames: + - dhpc + singular: devhubplugincatalog + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.source.ref + name: Ref + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha5 + schema: + openAPIV3Schema: + description: |- + DevHubPluginCatalog is the Schema for the devhubplugincatalogs API. + It defines a source for plugin catalogs that can be fetched from OCI registries. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: DevHubPluginCatalogSpec defines the desired state of DevHubPluginCatalog + properties: + source: + description: Source defines where to fetch the catalog from + properties: + certificateAuthority: + description: CertificateAuthority is a reference to a ConfigMap + key containing CA certificate + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + pullSecret: + description: PullSecret is a reference to a Secret containing + registry credentials + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + ref: + description: |- + Ref is the OCI artifact reference containing the catalog + Example: registry.redhat.io/rhdh/plugin-catalog:1.5 + type: string + skipTLSVerify: + default: false + description: SkipTLSVerify skips TLS certificate verification + (not recommended for production) + type: boolean + required: + - ref + type: object + required: + - source + type: object + type: object + served: true + storage: true + subresources: {} +--- apiVersion: v1 kind: ServiceAccount metadata: @@ -2393,6 +2507,14 @@ rules: - get - patch - update +- apiGroups: + - rhdh.redhat.com + resources: + - devhubplugincatalogs + verbs: + - get + - list + - watch - apiGroups: - route.openshift.io resources: @@ -2799,6 +2921,15 @@ spec: - --metrics-bind-address=:8443 command: - /manager + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace image: quay.io/rhdh-community/operator:2.0.0 livenessProbe: httpGet: @@ -2837,6 +2968,8 @@ spec: name: default-config - mountPath: /plugin-deps name: plugin-deps + - mountPath: /tmp + name: tmp securityContext: runAsNonRoot: true serviceAccountName: backstage-controller-manager @@ -2849,3 +2982,5 @@ spec: name: plugin-deps optional: true name: plugin-deps + - emptyDir: {} + name: tmp From f973d4077763b56572bca1b2b3afe77ee0cd35c1 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 25 Aug 2026 16:53:47 +0300 Subject: [PATCH 09/28] bundle --- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- go.sum | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 794e5c6a8..02a8e40e9 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-25T13:29:27Z" + createdAt: "2026-08-25T13:53:15Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index c4e56cd00..5f195a528 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-25T13:29:24Z" + createdAt: "2026-08-25T13:53:12Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/go.sum b/go.sum index 5bed50ff5..7fb780feb 100644 --- a/go.sum +++ b/go.sum @@ -151,8 +151,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/openshift/api v0.0.0-20260807110950-72ae4424ef35 h1:eJOpmjlTvrSwKwdpJ71Q2yTOSj+msZ7re0unuiBrjc4= -github.com/openshift/api v0.0.0-20260807110950-72ae4424ef35/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= +github.com/openshift/api v0.0.0-20260825030118-c804ea655299 h1:qvbJU++GqfY7k+gJ/1UtosNjchMW0UNWfbrsbbVoX7E= +github.com/openshift/api v0.0.0-20260825030118-c804ea655299/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e h1:k89oIo2EjX0PRSdi1kesktCyWp50SC9WwKurvupvRGs= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e/go.mod h1:XGabTMnNbz0M5Oa7IbscZp/jmcc7aHobvOCUWwkzKvM= github.com/openshift/library-go v0.0.0-20260213153706-03f1709971c5 h1:9Pe6iVOMjt9CdA/vaKBNUSoEIjIe1po5Ha3ABRYXLJI= From b49b0552dc615566dbcb6af16c5183ab82e2d507 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 25 Aug 2026 17:17:41 +0300 Subject: [PATCH 10/28] Remove .go.1 backup files from repo --- cmd/plugin-fetch/main.go.1 | 372 ----------------------------------- pkg/fetcher/fetcher.go.1 | 66 ------- pkg/fetcher/http.go.1 | 105 ---------- pkg/fetcher/local.go.1 | 70 ------- pkg/fetcher/lock.go.1 | 66 ------- pkg/fetcher/npm.go.1 | 307 ----------------------------- pkg/fetcher/npm_extract.go.1 | 84 -------- 7 files changed, 1070 deletions(-) delete mode 100644 cmd/plugin-fetch/main.go.1 delete mode 100644 pkg/fetcher/fetcher.go.1 delete mode 100644 pkg/fetcher/http.go.1 delete mode 100644 pkg/fetcher/local.go.1 delete mode 100644 pkg/fetcher/lock.go.1 delete mode 100644 pkg/fetcher/npm.go.1 delete mode 100644 pkg/fetcher/npm_extract.go.1 diff --git a/cmd/plugin-fetch/main.go.1 b/cmd/plugin-fetch/main.go.1 deleted file mode 100644 index 3d13a61c1..000000000 --- a/cmd/plugin-fetch/main.go.1 +++ /dev/null @@ -1,372 +0,0 @@ -package main - -import ( - "bufio" - "context" - "fmt" - "os" - "os/signal" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - - "github.com/redhat-developer/rhdh-operator/pkg/fetcher" - "github.com/redhat-developer/rhdh-operator/pkg/utils" -) - -// Environment variables -const ( - InputFileEnvVar = "INPUT_FILE" // Package list file (default: /input/packages.txt) - OutputDirEnvVar = "OUTPUT_DIR" // Output directory (default: /dynamic-plugins-root) - DockerConfigEnvVar = "DOCKER_CONFIG" // Path to dockerconfigjson file - CAFileEnvVar = "CA_FILE" // Path to CA certificate file - InsecureEnvVar = "INSECURE" // Skip TLS verification (true/false) - ValidatePluginsEnvVar = "VALIDATE_PLUGINS" // Validate plugin artifacts (default: true) - ParallelEnvVar = "PARALLEL" // Number of parallel downloads (default: 4) - LockFileEnvVar = "LOCK_FILE" // Lock file path (default: OUTPUT_DIR/install-dynamic-plugins.lock) - TerminationLogEnvVar = "TERMINATION_LOG" // Kubernetes termination log - - // Catalog index for Extensions UI - CatalogIndexImageEnvVar = "CATALOG_INDEX_IMAGE" // OCI image containing catalog entities - CatalogEntitiesExtractDirEnvVar = "CATALOG_ENTITIES_EXTRACT_DIR" // Where to extract catalog entities (default: /tmp/extensions) - - // NPM configuration - SkipIntegrityCheckEnvVar = "SKIP_INTEGRITY_CHECK" // Skip integrity verification (default: false) - NPMRegistryEnvVar = "NPM_REGISTRY" // NPM registry URL (default: https://registry.npmjs.org) - NPMAuthTokenEnvVar = "NPM_AUTH_TOKEN" // NPM auth token - NPMConfigEnvVar = "NPM_CONFIG_USERCONFIG" // Path to .npmrc file -) - -// Package represents a plugin package with optional integrity hash -type Package struct { - URL string - Integrity string -} - -func main() { - // Read configuration from environment - inputFile := utils.StringEnvVar(InputFileEnvVar, "/input/packages.txt") - outputDir := utils.StringEnvVar(OutputDirEnvVar, "/dynamic-plugins-root") - dockerConfig := utils.StringEnvVar(DockerConfigEnvVar, "") - caFile := utils.StringEnvVar(CAFileEnvVar, "") - insecure := utils.BoolEnvVar(InsecureEnvVar, false) - validate := utils.BoolEnvVar(ValidatePluginsEnvVar, true) - parallel := utils.IntEnvVar(ParallelEnvVar, 4) - lockFile := utils.StringEnvVar(LockFileEnvVar, filepath.Join(outputDir, "install-dynamic-plugins.lock")) - termLog := utils.StringEnvVar(TerminationLogEnvVar, "/dev/termination-log") - - // Catalog index settings - catalogIndexImage := utils.StringEnvVar(CatalogIndexImageEnvVar, "") - catalogEntitiesDir := utils.StringEnvVar(CatalogEntitiesExtractDirEnvVar, "/tmp/extensions") - - // NPM settings - skipIntegrityCheck := utils.BoolEnvVar(SkipIntegrityCheckEnvVar, false) - npmRegistry := utils.StringEnvVar(NPMRegistryEnvVar, "") - npmAuthToken := utils.StringEnvVar(NPMAuthTokenEnvVar, "") - npmConfigFile := utils.StringEnvVar(NPMConfigEnvVar, "") - - // Handle signals - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) - go func() { - <-sigCh - cancel() - }() - - // Ensure output directory exists (needed for lock file) - if err := os.MkdirAll(outputDir, 0755); err != nil { - fatal(termLog, "Failed to create output directory: %v", err) - } - - // Acquire lock - lock := fetcher.NewFileLock(lockFile) - if err := lock.Acquire(5 * time.Minute); err != nil { - fatal(termLog, "Failed to acquire lock: %v", err) - } - defer func() { - if err := lock.Release(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to release lock: %v\n", err) - } - }() - - // Build OCI options - var ociOpts []fetcher.OCIOption - if insecure { - ociOpts = append(ociOpts, fetcher.WithInsecure()) - } - if caFile != "" { - caCert, err := os.ReadFile(caFile) - if err != nil { - fatal(termLog, "Error reading CA file: %v", err) - } - ociOpts = append(ociOpts, fetcher.WithCACert(caCert)) - } - if dockerConfig != "" { - secret, err := os.ReadFile(dockerConfig) - if err != nil { - fatal(termLog, "Error reading docker config: %v", err) - } - ociOpts = append(ociOpts, fetcher.WithDockerConfig(secret)) - } - if validate { - ociOpts = append(ociOpts, fetcher.WithPluginValidation()) - } - - // Build NPM options - var npmOpts []fetcher.NPMOption - if npmRegistry != "" { - npmOpts = append(npmOpts, fetcher.WithNPMRegistry(npmRegistry)) - } - if npmAuthToken != "" { - npmOpts = append(npmOpts, fetcher.WithNPMAuthToken(npmAuthToken)) - } - if npmConfigFile != "" { - npmOpts = append(npmOpts, fetcher.WithNPMConfigFile(npmConfigFile)) - } - if skipIntegrityCheck { - npmOpts = append(npmOpts, fetcher.WithSkipIntegrityCheck()) - } - - // Create fetcher - f := fetcher.New( - fetcher.WithOCIOptions(ociOpts...), - fetcher.WithNPMOptions(npmOpts...), - ) - - // Extract catalog entities from catalog index image (for Extensions UI) - if catalogIndexImage != "" { - fmt.Printf("=== Extracting catalog entities from %s ===\n", catalogIndexImage) - if err := extractCatalogEntities(ctx, f, catalogIndexImage, catalogEntitiesDir, ociOpts); err != nil { - fmt.Fprintf(os.Stderr, "WARNING: Failed to extract catalog index: %v\n", err) - } - } else { - fmt.Println("=== CATALOG_INDEX_IMAGE not set, skipping catalog entities extraction") - } - - // Read packages from input file - packages, err := readPackages(inputFile) - if err != nil { - fatal(termLog, "Error reading input file: %v", err) - } - - if len(packages) == 0 { - fmt.Println("No packages to install") - return - } - - fmt.Printf("=== Downloading %d plugins to %s (%d parallel) ===\n\n", len(packages), outputDir, parallel) - - // Process packages in parallel - if err := processParallel(ctx, f, packages, outputDir, parallel); err != nil { - fatal(termLog, "Error installing plugins: %v", err) - } - - fmt.Printf("\n=== Complete ===\n") - fmt.Printf("Successfully installed %d plugins\n", len(packages)) -} - -// extractCatalogEntities extracts catalog-entities from an OCI image -func extractCatalogEntities(ctx context.Context, f *fetcher.Fetcher, image, destDir string, ociOpts []fetcher.OCIOption) error { - // Create OCI fetcher without plugin validation (catalog index is not a plugin) - ociFetcher := fetcher.NewOCIFetcher(ociOpts...) - - tmpDir, err := os.MkdirTemp("", "catalog-index-") - if err != nil { - return fmt.Errorf("failed to create temp dir: %w", err) - } - defer os.RemoveAll(tmpDir) - - // Fetch and extract OCI image - if err := ociFetcher.Fetch(ctx, strings.TrimPrefix(image, "oci://"), tmpDir); err != nil { - return fmt.Errorf("failed to fetch catalog index: %w", err) - } - - // Look for catalog-entities/extensions or catalog-entities - var entitiesSrc string - if info, err := os.Stat(filepath.Join(tmpDir, "catalog-entities", "extensions")); err == nil && info.IsDir() { - entitiesSrc = filepath.Join(tmpDir, "catalog-entities", "extensions") - } else if info, err := os.Stat(filepath.Join(tmpDir, "catalog-entities")); err == nil && info.IsDir() { - entitiesSrc = filepath.Join(tmpDir, "catalog-entities") - } - - if entitiesSrc == "" { - return fmt.Errorf("no catalog-entities found in %s", image) - } - - // Copy to destination - entitiesDest := filepath.Join(destDir, "catalog-entities") - if err := os.MkdirAll(destDir, 0755); err != nil { - return err - } - os.RemoveAll(entitiesDest) // Remove old if exists - - if err := copyDir(entitiesSrc, entitiesDest); err != nil { - return fmt.Errorf("failed to copy catalog entities: %w", err) - } - - // Count YAML files - count := 0 - filepath.Walk(entitiesDest, func(path string, info os.FileInfo, err error) error { - if err == nil && !info.IsDir() && (strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")) { - count++ - } - return nil - }) - - fmt.Printf("Catalog entities extracted to %s (%d files)\n", entitiesDest, count) - return nil -} - -// copyDir recursively copies a directory -func copyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(src, path) - if err != nil { - return err - } - dstPath := filepath.Join(dst, relPath) - - if info.IsDir() { - return os.MkdirAll(dstPath, 0755) - } - - data, err := os.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(dstPath, data, info.Mode()) - }) -} - -func readPackages(path string) ([]Package, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - var packages []Package - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - // Skip empty lines and comments - if line == "" || strings.HasPrefix(line, "#") { - continue - } - // Handle "url [integrity]" format - parts := strings.Fields(line) - pkg := Package{URL: parts[0]} - if len(parts) > 1 { - pkg.Integrity = parts[1] - } - packages = append(packages, pkg) - } - return packages, scanner.Err() -} - -func processParallel(ctx context.Context, f *fetcher.Fetcher, packages []Package, outputDir string, parallel int) error { - sem := make(chan struct{}, parallel) - var wg sync.WaitGroup - var firstErr error - var errOnce sync.Once - - for _, pkg := range packages { - select { - case <-ctx.Done(): - return ctx.Err() - case sem <- struct{}{}: - } - - wg.Add(1) - go func(pkg Package) { - defer wg.Done() - defer func() { <-sem }() - - name := pluginName(pkg.URL) - destDir := filepath.Join(outputDir, name) - - // Skip if already exists - if info, err := os.Stat(destDir); err == nil && info.IsDir() { - entries, _ := os.ReadDir(destDir) - if len(entries) > 0 { - fmt.Printf("[SKIP] %s (exists)\n", name) - return - } - } - - fmt.Printf("[DOWN] %s\n", pkg.URL) - - if err := f.FetchWithIntegrity(ctx, pkg.URL, destDir, pkg.Integrity); err != nil { - fmt.Fprintf(os.Stderr, "[FAIL] %s: %v\n", name, err) - errOnce.Do(func() { - firstErr = fmt.Errorf("%s: %w", name, err) - }) - return - } - fmt.Printf("[DONE] %s\n", name) - }(pkg) - } - - wg.Wait() - return firstErr -} - -// pluginName extracts a directory name from various URL formats -func pluginName(url string) string { - // Remove scheme prefixes - url = strings.TrimPrefix(url, "oci://") - url = strings.TrimPrefix(url, "https://") - url = strings.TrimPrefix(url, "http://") - url = strings.TrimPrefix(url, "file://") - url = strings.TrimPrefix(url, "file:") - url = strings.TrimPrefix(url, "@npm:") - - // Remove digest/tag suffix - if idx := strings.Index(url, "@sha256:"); idx > 0 { - url = url[:idx] - } - if idx := strings.LastIndex(url, "@"); idx > 0 { - // For npm packages like @backstage/plugin-foo@1.0.0 - // Keep @backstage/plugin-foo, remove @1.0.0 - beforeAt := url[:idx] - if !strings.HasPrefix(beforeAt, "@") || strings.Contains(beforeAt[1:], "/") { - url = beforeAt - } - } - if idx := strings.LastIndex(url, ":"); idx > 0 { - // Remove tag like :latest or :v1.0.0 - url = url[:idx] - } - - // Get the last path component - name := filepath.Base(url) - - // Clean up common suffixes - name = strings.TrimSuffix(name, ".tgz") - name = strings.TrimSuffix(name, ".tar.gz") - name = strings.TrimSuffix(name, ".tar") - - return name -} - -func writeTerminationLog(path, msg string) { - if len(msg) > 4096 { - msg = msg[:4096] - } - _ = os.WriteFile(path, []byte(msg), 0644) -} - -func fatal(termLog, format string, args ...interface{}) { - msg := fmt.Sprintf(format, args...) - writeTerminationLog(termLog, msg) - fmt.Fprintln(os.Stderr, msg) - os.Exit(1) -} diff --git a/pkg/fetcher/fetcher.go.1 b/pkg/fetcher/fetcher.go.1 deleted file mode 100644 index a5215159a..000000000 --- a/pkg/fetcher/fetcher.go.1 +++ /dev/null @@ -1,66 +0,0 @@ -package fetcher - -import ( - "context" - "fmt" - "strings" -) - -// Fetcher downloads plugins/artifacts from various sources -type Fetcher struct { - oci *OCIFetcher - http *HTTPFetcher - npm *NPMFetcher -} - -// FetcherOption configures the Fetcher -type FetcherOption func(*Fetcher) - -// New creates a new Fetcher with the given options -func New(opts ...FetcherOption) *Fetcher { - f := &Fetcher{ - oci: NewOCIFetcher(), - http: NewHTTPFetcher(), - npm: NewNPMFetcher(), - } - for _, opt := range opts { - opt(f) - } - return f -} - -// WithOCIOptions configures the OCI fetcher with the given options -func WithOCIOptions(opts ...OCIOption) FetcherOption { - return func(f *Fetcher) { - f.oci = NewOCIFetcher(opts...) - } -} - -// WithNPMOptions configures the NPM fetcher with the given options -func WithNPMOptions(opts ...NPMOption) FetcherOption { - return func(f *Fetcher) { - f.npm = NewNPMFetcher(opts...) - } -} - -// Fetch downloads from a URL and extracts to destDir -func (f *Fetcher) Fetch(ctx context.Context, url string, destDir string) error { - return f.FetchWithIntegrity(ctx, url, destDir, "") -} - -// FetchWithIntegrity downloads from a URL with optional integrity verification -func (f *Fetcher) FetchWithIntegrity(ctx context.Context, url string, destDir string, integrity string) error { - switch { - case strings.HasPrefix(url, "oci://"): - // OCI uses digest in URL for verification, integrity param ignored - return f.oci.Fetch(ctx, strings.TrimPrefix(url, "oci://"), destDir) - case strings.HasPrefix(url, "https://"), strings.HasPrefix(url, "http://"): - return f.http.FetchWithIntegrity(ctx, url, destDir, integrity) - case strings.HasPrefix(url, "@") || strings.Contains(url, "@npm:"): - return f.npm.FetchWithIntegrity(ctx, url, destDir, integrity) - case strings.HasPrefix(url, "file:"): - return copyLocal(strings.TrimPrefix(url, "file:"), destDir) - default: - return fmt.Errorf("unsupported URL scheme: %s", url) - } -} diff --git a/pkg/fetcher/http.go.1 b/pkg/fetcher/http.go.1 deleted file mode 100644 index 23fa9106f..000000000 --- a/pkg/fetcher/http.go.1 +++ /dev/null @@ -1,105 +0,0 @@ -package fetcher - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" -) - -// HTTPFetcher downloads plugins from HTTP/HTTPS URLs -type HTTPFetcher struct { - client *http.Client -} - -// NewHTTPFetcher creates a new HTTP fetcher -func NewHTTPFetcher() *HTTPFetcher { - return &HTTPFetcher{client: http.DefaultClient} -} - -// Fetch downloads from HTTP URL and extracts to destDir -func (f *HTTPFetcher) Fetch(ctx context.Context, url string, destDir string) error { - return f.FetchWithIntegrity(ctx, url, destDir, "") -} - -// FetchWithIntegrity downloads from HTTP URL with optional integrity verification -func (f *HTTPFetcher) FetchWithIntegrity(ctx context.Context, url string, destDir string, integrity string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := f.client.Do(req) - if err != nil { - return fmt.Errorf("failed to download %s: %w", url, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) - } - - // Create dest directory - if err := os.MkdirAll(destDir, 0755); err != nil { - return err - } - - // Detect if it's a tarball and extract, or save directly - contentType := resp.Header.Get("Content-Type") - if isTarball(url, contentType) { - // Read body into memory for integrity check - data, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - // Verify integrity if provided - if integrity != "" { - if err := verifyIntegrity(data, integrity); err != nil { - return fmt.Errorf("integrity verification failed: %w", err) - } - } - - return extractTarGzBytes(data, destDir) - } - - // Save as single file - filename := filepath.Base(url) - return saveFile(resp.Body, filepath.Join(destDir, filename)) -} - -// isTarball checks if the URL or content type indicates a tarball -func isTarball(url, contentType string) bool { - // Check URL extension - lowerURL := strings.ToLower(url) - if strings.HasSuffix(lowerURL, ".tar.gz") || - strings.HasSuffix(lowerURL, ".tgz") || - strings.HasSuffix(lowerURL, ".tar") { - return true - } - - // Check content type - if strings.Contains(contentType, "application/gzip") || - strings.Contains(contentType, "application/x-gzip") || - strings.Contains(contentType, "application/x-tar") || - strings.Contains(contentType, "application/x-compressed-tar") { - return true - } - - return false -} - -// saveFile saves reader content to a file -func saveFile(r io.Reader, path string) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - - _, err = io.Copy(f, r) - return err -} diff --git a/pkg/fetcher/local.go.1 b/pkg/fetcher/local.go.1 deleted file mode 100644 index 43f8d8678..000000000 --- a/pkg/fetcher/local.go.1 +++ /dev/null @@ -1,70 +0,0 @@ -package fetcher - -import ( - "fmt" - "io" - "os" - "path/filepath" -) - -// copyLocal copies a local file or directory to destDir -func copyLocal(src string, destDir string) error { - info, err := os.Stat(src) - if err != nil { - return fmt.Errorf("failed to stat %s: %w", src, err) - } - - if err := os.MkdirAll(destDir, 0755); err != nil { - return err - } - - if info.IsDir() { - return copyDir(src, destDir) - } - return copyFile(src, filepath.Join(destDir, filepath.Base(src))) -} - -// copyDir recursively copies a directory -func copyDir(src, dest string) error { - entries, err := os.ReadDir(src) - if err != nil { - return err - } - - for _, entry := range entries { - srcPath := filepath.Join(src, entry.Name()) - destPath := filepath.Join(dest, entry.Name()) - - if entry.IsDir() { - if err := os.MkdirAll(destPath, 0755); err != nil { - return err - } - if err := copyDir(srcPath, destPath); err != nil { - return err - } - } else { - if err := copyFile(srcPath, destPath); err != nil { - return err - } - } - } - return nil -} - -// copyFile copies a single file -func copyFile(src, dest string) error { - srcFile, err := os.Open(src) - if err != nil { - return err - } - defer srcFile.Close() - - destFile, err := os.Create(dest) - if err != nil { - return err - } - defer destFile.Close() - - _, err = io.Copy(destFile, srcFile) - return err -} diff --git a/pkg/fetcher/lock.go.1 b/pkg/fetcher/lock.go.1 deleted file mode 100644 index d1f9b4f48..000000000 --- a/pkg/fetcher/lock.go.1 +++ /dev/null @@ -1,66 +0,0 @@ -package fetcher - -import ( - "fmt" - "os" - "syscall" - "time" -) - -// FileLock provides file-based locking using flock() syscall. -// This works correctly across containers sharing the same filesystem (PVC). -type FileLock struct { - path string - file *os.File -} - -// NewFileLock creates a new file lock -func NewFileLock(path string) *FileLock { - return &FileLock{path: path} -} - -// Acquire attempts to acquire an exclusive lock with a timeout. -// Uses flock() which is automatically released if the process dies. -func (l *FileLock) Acquire(timeout time.Duration) error { - f, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return fmt.Errorf("failed to open lock file: %w", err) - } - l.file = f - - // Try non-blocking lock first - err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err == nil { - return nil // Got the lock immediately - } - - // Lock is held by another process, wait with timeout - deadline := time.Now().Add(timeout) - for { - if time.Now().After(deadline) { - l.file.Close() - l.file = nil - return fmt.Errorf("timeout waiting for lock %s", l.path) - } - - time.Sleep(100 * time.Millisecond) - - err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err == nil { - return nil // Got the lock - } - } -} - -// Release releases the lock -func (l *FileLock) Release() error { - if l.file == nil { - return nil - } - - // Unlock and close - syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) - err := l.file.Close() - l.file = nil - return err -} diff --git a/pkg/fetcher/npm.go.1 b/pkg/fetcher/npm.go.1 deleted file mode 100644 index 33f727c61..000000000 --- a/pkg/fetcher/npm.go.1 +++ /dev/null @@ -1,307 +0,0 @@ -package fetcher - -import ( - "bufio" - "context" - "crypto/sha256" - "crypto/sha512" - "encoding/base64" - "encoding/json" - "fmt" - "hash" - "io" - "net/http" - "os" - "strings" -) - -// NPMFetcher downloads plugins from NPM registry (no Node.js required) -type NPMFetcher struct { - registryURL string - authToken string - skipIntegrityCheck bool - client *http.Client -} - -// NPMOption configures the NPM fetcher -type NPMOption func(*NPMFetcher) - -// NewNPMFetcher creates a new NPM fetcher -func NewNPMFetcher(opts ...NPMOption) *NPMFetcher { - f := &NPMFetcher{ - registryURL: "https://registry.npmjs.org", - client: http.DefaultClient, - } - for _, opt := range opts { - opt(f) - } - return f -} - -// WithNPMRegistry sets the NPM registry URL -func WithNPMRegistry(url string) NPMOption { - return func(f *NPMFetcher) { - if url != "" { - f.registryURL = strings.TrimSuffix(url, "/") - } - } -} - -// WithNPMAuthToken sets the NPM auth token -func WithNPMAuthToken(token string) NPMOption { - return func(f *NPMFetcher) { - f.authToken = token - } -} - -// WithNPMConfigFile reads registry and auth from .npmrc file -func WithNPMConfigFile(path string) NPMOption { - return func(f *NPMFetcher) { - if path == "" { - return - } - registry, token := parseNPMRC(path) - if registry != "" { - f.registryURL = strings.TrimSuffix(registry, "/") - } - if token != "" { - f.authToken = token - } - } -} - -// WithSkipIntegrityCheck skips integrity verification -func WithSkipIntegrityCheck() NPMOption { - return func(f *NPMFetcher) { - f.skipIntegrityCheck = true - } -} - -// parseNPMRC parses .npmrc file for registry and auth token -func parseNPMRC(path string) (registry, authToken string) { - file, err := os.Open(path) - if err != nil { - return "", "" - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - // registry=https://registry.npmjs.org - if strings.HasPrefix(line, "registry") { - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - registry = strings.Trim(strings.TrimSpace(parts[1]), "\"'") - } - } - - // //registry.npmjs.org/:_authToken=xxx - if strings.Contains(line, ":_authToken=") { - parts := strings.SplitN(line, ":_authToken=", 2) - if len(parts) == 2 { - authToken = strings.Trim(strings.TrimSpace(parts[1]), "\"'") - } - } - } - return registry, authToken -} - -// Fetch downloads NPM package and extracts to destDir -// Supports formats: @scope/package@version, package@version -func (f *NPMFetcher) Fetch(ctx context.Context, pkg string, destDir string) error { - return f.FetchWithIntegrity(ctx, pkg, destDir, "") -} - -// FetchWithIntegrity downloads NPM package with optional integrity verification -func (f *NPMFetcher) FetchWithIntegrity(ctx context.Context, pkg string, destDir string, userIntegrity string) error { - // Parse package name and version - name, version := parseNPMPackage(pkg) - - // If version is "latest", resolve it first - if version == "latest" { - resolvedVersion, err := f.resolveLatestVersion(ctx, name) - if err != nil { - return err - } - version = resolvedVersion - } - - // Get version-specific metadata from registry (includes integrity) - tarballURL, registryIntegrity, err := f.getVersionMetadata(ctx, name, version) - if err != nil { - return err - } - - // Use user-provided integrity if available, otherwise use registry integrity - integrity := userIntegrity - if integrity == "" { - integrity = registryIntegrity - } - - // Download tarball - req, err := http.NewRequestWithContext(ctx, http.MethodGet, tarballURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - if f.authToken != "" { - req.Header.Set("Authorization", "Bearer "+f.authToken) - } - - resp, err := f.client.Do(req) - if err != nil { - return fmt.Errorf("failed to download %s: %w", tarballURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d for %s", resp.StatusCode, tarballURL) - } - - // Read body into memory for integrity check - data, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read tarball: %w", err) - } - - // Verify integrity if not skipped - if !f.skipIntegrityCheck && integrity != "" { - if err := verifyIntegrity(data, integrity); err != nil { - return fmt.Errorf("integrity verification failed: %w", err) - } - } - - // Extract tarball (npm tarballs have "package/" prefix) - if err := os.MkdirAll(destDir, 0755); err != nil { - return err - } - - return extractTarGzWithStripPrefix(data, destDir, "package/") -} - -// resolveLatestVersion gets the latest version from the full package document -func (f *NPMFetcher) resolveLatestVersion(ctx context.Context, name string) (string, error) { - metaURL := fmt.Sprintf("%s/%s", f.registryURL, name) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) - if err != nil { - return "", err - } - req.Header.Set("Accept", "application/json") - if f.authToken != "" { - req.Header.Set("Authorization", "Bearer "+f.authToken) - } - - resp, err := f.client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to fetch NPM metadata for %s: %w", name, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("NPM registry returned %d for %s", resp.StatusCode, name) - } - - var meta struct { - DistTags struct { - Latest string `json:"latest"` - } `json:"dist-tags"` - } - if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { - return "", fmt.Errorf("failed to parse NPM metadata: %w", err) - } - - if meta.DistTags.Latest == "" { - return "", fmt.Errorf("no latest version found for %s", name) - } - - return meta.DistTags.Latest, nil -} - -// getVersionMetadata gets the tarball URL and integrity for a specific version -func (f *NPMFetcher) getVersionMetadata(ctx context.Context, name, version string) (tarballURL, integrity string, err error) { - // Use version-specific endpoint to avoid grepping through full package doc - versionURL := fmt.Sprintf("%s/%s/%s", f.registryURL, name, version) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil) - if err != nil { - return "", "", err - } - req.Header.Set("Accept", "application/json") - if f.authToken != "" { - req.Header.Set("Authorization", "Bearer "+f.authToken) - } - - resp, err := f.client.Do(req) - if err != nil { - return "", "", fmt.Errorf("failed to fetch NPM version metadata for %s@%s: %w", name, version, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("NPM registry returned %d for %s@%s", resp.StatusCode, name, version) - } - - var versionMeta struct { - Dist struct { - Tarball string `json:"tarball"` - Integrity string `json:"integrity"` - } `json:"dist"` - } - if err := json.NewDecoder(resp.Body).Decode(&versionMeta); err != nil { - return "", "", fmt.Errorf("failed to parse NPM version metadata: %w", err) - } - - if versionMeta.Dist.Tarball == "" { - return "", "", fmt.Errorf("no tarball URL found for %s@%s", name, version) - } - - return versionMeta.Dist.Tarball, versionMeta.Dist.Integrity, nil -} - -// verifyIntegrity verifies data against an SRI hash (sha256-..., sha384-..., sha512-...) -func verifyIntegrity(data []byte, integrity string) error { - parts := strings.SplitN(integrity, "-", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid integrity format: %s", integrity) - } - - algorithm := parts[0] - expected := parts[1] - - var h hash.Hash - switch algorithm { - case "sha256": - h = sha256.New() - case "sha384": - h = sha512.New384() - case "sha512": - h = sha512.New() - default: - return fmt.Errorf("unsupported integrity algorithm: %s", algorithm) - } - - h.Write(data) - computed := base64.StdEncoding.EncodeToString(h.Sum(nil)) - - if computed != expected { - return fmt.Errorf("integrity mismatch: expected %s, got %s", expected, computed) - } - - return nil -} - -// parseNPMPackage parses package name and version from various formats -func parseNPMPackage(pkg string) (name, version string) { - // Handle @npm: prefix - pkg = strings.TrimPrefix(pkg, "@npm:") - - // Handle @scope/package@version or package@version - // Find last @ that separates version (not the scope @) - if idx := strings.LastIndex(pkg, "@"); idx > 0 { - return pkg[:idx], pkg[idx+1:] - } - return pkg, "latest" -} diff --git a/pkg/fetcher/npm_extract.go.1 b/pkg/fetcher/npm_extract.go.1 deleted file mode 100644 index eea06b67e..000000000 --- a/pkg/fetcher/npm_extract.go.1 +++ /dev/null @@ -1,84 +0,0 @@ -package fetcher - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "fmt" - "io" - "os" - "path/filepath" - "strings" -) - -// extractTarGzWithStripPrefix extracts a gzipped tarball, stripping a path prefix from entries -// This is useful for npm tarballs which have "package/" prefix -func extractTarGzWithStripPrefix(data []byte, destDir, stripPrefix string) error { - gzr, err := gzip.NewReader(bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("failed to create gzip reader: %w", err) - } - defer gzr.Close() - - return extractTarWithStripPrefix(gzr, destDir, stripPrefix) -} - -// extractTarWithStripPrefix extracts a tarball, stripping a path prefix from entries -func extractTarWithStripPrefix(r io.Reader, destDir, stripPrefix string) error { - tr := tar.NewReader(r) - - // Ensure destDir exists - if err := os.MkdirAll(destDir, 0755); err != nil { - return err - } - - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return err - } - - // Strip prefix from name - name := header.Name - if stripPrefix != "" && strings.HasPrefix(name, stripPrefix) { - name = strings.TrimPrefix(name, stripPrefix) - } - if name == "" || name == "." { - continue - } - - target := filepath.Join(destDir, name) - - // Security: prevent path traversal - if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) { - return fmt.Errorf("invalid tar path: %s", header.Name) - } - - switch header.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(target, 0755); err != nil { - return err - } - case tar.TypeReg: - if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return err - } - if err := extractFile(tr, target, header.Mode); err != nil { - return err - } - case tar.TypeSymlink: - // Handle symlinks - validate they don't escape destDir - linkTarget := filepath.Join(filepath.Dir(target), header.Linkname) - if !strings.HasPrefix(filepath.Clean(linkTarget), filepath.Clean(destDir)) { - return fmt.Errorf("symlink escapes destination: %s -> %s", header.Name, header.Linkname) - } - if err := os.Symlink(header.Linkname, target); err != nil { - return err - } - } - } - return nil -} From 772d28bc52412fba241a0ae853d16e234e948086 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 25 Aug 2026 17:19:49 +0300 Subject: [PATCH 11/28] fix --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9b7acdd38..ca4d7a346 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index c3ecd6b25..1357c08bd 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index b315b789e..6613a8896 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index c9c126a0f..9c86c46ee 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 249cccb0a..c66398c76 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -6,7 +6,7 @@ package v1alpha5 import ( corev1 "k8s.io/api/core/v1" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) From cb0c2c83655363788e57c64f0321b28624664ab6 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 25 Aug 2026 17:23:58 +0300 Subject: [PATCH 12/28] bundles --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- go.sum | 4 ++-- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ca4d7a346..9b7acdd38 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 1357c08bd..c3ecd6b25 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 6613a8896..b315b789e 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index 9c86c46ee..c9c126a0f 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index c66398c76..249cccb0a 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -6,7 +6,7 @@ package v1alpha5 import ( corev1 "k8s.io/api/core/v1" - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 02a8e40e9..677b41a52 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-25T13:53:15Z" + createdAt: "2026-08-25T14:23:30Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 5f195a528..e4a575796 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-25T13:53:12Z" + createdAt: "2026-08-25T14:23:27Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/go.sum b/go.sum index 7fb780feb..4bdb6b994 100644 --- a/go.sum +++ b/go.sum @@ -151,8 +151,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/openshift/api v0.0.0-20260825030118-c804ea655299 h1:qvbJU++GqfY7k+gJ/1UtosNjchMW0UNWfbrsbbVoX7E= -github.com/openshift/api v0.0.0-20260825030118-c804ea655299/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= +github.com/openshift/api v0.0.0-20260825094607-13a84dedc5a3 h1:JRsHyxZTqcs5MK0LYTsyzs9VMdO02cSYMBvyize1fhI= +github.com/openshift/api v0.0.0-20260825094607-13a84dedc5a3/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e h1:k89oIo2EjX0PRSdi1kesktCyWp50SC9WwKurvupvRGs= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e/go.mod h1:XGabTMnNbz0M5Oa7IbscZp/jmcc7aHobvOCUWwkzKvM= github.com/openshift/library-go v0.0.0-20260213153706-03f1709971c5 h1:9Pe6iVOMjt9CdA/vaKBNUSoEIjIe1po5Ha3ABRYXLJI= From d5241d6f571ab7d1246c0b3f61f7ac424bdf3954 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Wed, 26 Aug 2026 18:40:25 +0300 Subject: [PATCH 13/28] fixes --- api/v1alpha5/devhubplugincatalog_types.go | 29 +++- api/v1alpha5/zz_generated.deepcopy.go | 23 +++ ...kstage-operator.clusterserviceversion.yaml | 3 +- ...kstage-operator.clusterserviceversion.yaml | 5 +- cmd/main.go | 13 +- .../profile/rhdh/default-config/catalog.yaml | 2 +- config/rbac/role.yaml | 1 + dist/backstage.io/install.yaml | 73 ++++++++- dist/rhdh/install.yaml | 75 +++++++++- docs/dynamic-plugins.md | 14 +- integration_tests/devhubplugincatalog_test.go | 54 +++++-- internal/controller/backstage_controller.go | 61 +++++--- .../devhubplugincatalog_controller.go | 85 ++++++++++- .../devhubplugincatalog_controller_test.go | 116 ++++++++++++++- pkg/catalog/processor.go | 58 +++----- pkg/catalog/processor_test.go | 140 +++++------------- pkg/fetcher/extract.go | 82 +++++++--- pkg/fetcher/extract_test.go | 45 +++++- pkg/fetcher/oci.go | 63 ++------ pkg/fetcher/oci_test.go | 13 -- plugin-installer/install_plugins.sh | 9 +- 21 files changed, 674 insertions(+), 290 deletions(-) diff --git a/api/v1alpha5/devhubplugincatalog_types.go b/api/v1alpha5/devhubplugincatalog_types.go index a746801a0..2b1cb6c51 100644 --- a/api/v1alpha5/devhubplugincatalog_types.go +++ b/api/v1alpha5/devhubplugincatalog_types.go @@ -15,7 +15,7 @@ type DevHubPluginCatalogSpec struct { // CatalogSource defines the source of the plugin catalog type CatalogSource struct { // Ref is the OCI artifact reference containing the catalog - // Example: registry.redhat.io/rhdh/plugin-catalog:1.5 + // Example: oci://registry.redhat.io/rhdh/plugin-catalog:1.5 // +kubebuilder:validation:Required Ref string `json:"ref"` @@ -33,9 +33,31 @@ type CatalogSource struct { SkipTLSVerify bool `json:"skipTLSVerify,omitempty"` } +// DevHubPluginCatalogStatus defines the observed state of DevHubPluginCatalog +type DevHubPluginCatalogStatus struct { + // Conditions represent the latest available observations of the catalog's state + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// Condition types for DevHubPluginCatalog +const ( + // ConditionTypeReady indicates whether the catalog has been successfully processed + ConditionTypeReady = "Ready" +) + +// Condition reasons for DevHubPluginCatalog +const ( + ConditionReasonProcessing = "Processing" + ConditionReasonSucceeded = "Succeeded" + ConditionReasonFailed = "Failed" +) + // +kubebuilder:object:root=true -// +kubebuilder:resource:scope=Cluster,shortName=dhpc +// +kubebuilder:resource:scope=Namespaced,shortName=dhpc +// +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Ref",type=string,JSONPath=`.spec.source.ref` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // DevHubPluginCatalog is the Schema for the devhubplugincatalogs API. @@ -44,7 +66,8 @@ type DevHubPluginCatalog struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec DevHubPluginCatalogSpec `json:"spec,omitempty"` + Spec DevHubPluginCatalogSpec `json:"spec,omitempty"` + Status DevHubPluginCatalogStatus `json:"status,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 249cccb0a..b3805d13e 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -270,6 +270,7 @@ func (in *DevHubPluginCatalog) DeepCopyInto(out *DevHubPluginCatalog) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DevHubPluginCatalog. @@ -338,6 +339,28 @@ func (in *DevHubPluginCatalogSpec) DeepCopy() *DevHubPluginCatalogSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DevHubPluginCatalogStatus) DeepCopyInto(out *DevHubPluginCatalogStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DevHubPluginCatalogStatus. +func (in *DevHubPluginCatalogStatus) DeepCopy() *DevHubPluginCatalogStatus { + if in == nil { + return nil + } + out := new(DevHubPluginCatalogStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Env) DeepCopyInto(out *Env) { *out = *in diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 677b41a52..22a331595 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-25T14:23:30Z" + createdAt: "2026-08-26T15:35:57Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 @@ -153,6 +153,7 @@ spec: - rhdh.redhat.com resources: - backstages/status + - devhubplugincatalogs/status verbs: - get - patch diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index e4a575796..90dd91a7a 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -33,7 +33,7 @@ metadata: }, "spec": { "source": { - "ref": "quay.io/rhdh/plugin-catalog-index:2.0.0" + "ref": "oci://quay.io/rhdh/plugin-catalog-index:2.0.0" } } } @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-25T14:23:27Z" + createdAt: "2026-08-26T15:35:54Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed @@ -207,6 +207,7 @@ spec: - rhdh.redhat.com resources: - backstages/status + - devhubplugincatalogs/status verbs: - get - patch diff --git a/cmd/main.go b/cmd/main.go index 09ef304ff..b26569bf9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -289,9 +289,10 @@ func main() { } if err = (&controller.BackstageReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Platform: plf, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Platform: plf, + OperatorNamespace: getOperatorNamespace(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Backstage") os.Exit(1) @@ -299,11 +300,10 @@ func main() { // Setup DevHubPluginCatalog controller if !disableCatalogController { - operatorNamespace := getOperatorNamespace() if err = (&controller.DevHubPluginCatalogReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), - OperatorNamespace: operatorNamespace, + OperatorNamespace: getOperatorNamespace(), Processor: catalog.NewProcessor(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "DevHubPluginCatalog") @@ -410,14 +410,11 @@ func setupTLSProfileWatcher( // getOperatorNamespace returns the namespace the operator is running in func getOperatorNamespace() string { - // Check env var first (set via downward API in deployment) if ns := os.Getenv("POD_NAMESPACE"); ns != "" { return ns } - // Fall back to service account namespace file (auto-mounted by Kubernetes) if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil { return string(data) } - // Default for local development return "rhdh-operator" } diff --git a/config/profile/rhdh/default-config/catalog.yaml b/config/profile/rhdh/default-config/catalog.yaml index 46e3b11d8..28aa49067 100644 --- a/config/profile/rhdh/default-config/catalog.yaml +++ b/config/profile/rhdh/default-config/catalog.yaml @@ -5,4 +5,4 @@ metadata: spec: source: # Default value, patched with CATALOG_INDEX_IMAGE from Makefile at build time - ref: quay.io/rhdh/plugin-catalog-index:next + ref: oci://quay.io/rhdh/plugin-catalog-index:next diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2635b79a6..d0ccda797 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -93,6 +93,7 @@ rules: - rhdh.redhat.com resources: - backstages/status + - devhubplugincatalogs/status verbs: - get - patch diff --git a/dist/backstage.io/install.yaml b/dist/backstage.io/install.yaml index 0f37b1e8e..95d6842c5 100644 --- a/dist/backstage.io/install.yaml +++ b/dist/backstage.io/install.yaml @@ -2201,12 +2201,15 @@ spec: shortNames: - dhpc singular: devhubplugincatalog - scope: Cluster + scope: Namespaced versions: - additionalPrinterColumns: - jsonPath: .spec.source.ref name: Ref type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date @@ -2282,7 +2285,7 @@ spec: ref: description: |- Ref is the OCI artifact reference containing the catalog - Example: registry.redhat.io/rhdh/plugin-catalog:1.5 + Example: oci://registry.redhat.io/rhdh/plugin-catalog:1.5 type: string skipTLSVerify: default: false @@ -2295,10 +2298,73 @@ spec: required: - source type: object + status: + description: DevHubPluginCatalogStatus defines the observed state of DevHubPluginCatalog + properties: + conditions: + description: Conditions represent the latest available observations + of the catalog's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object type: object served: true storage: true - subresources: {} + subresources: + status: {} --- apiVersion: v1 kind: ServiceAccount @@ -2510,6 +2576,7 @@ rules: - rhdh.redhat.com resources: - backstages/status + - devhubplugincatalogs/status verbs: - get - patch diff --git a/dist/rhdh/install.yaml b/dist/rhdh/install.yaml index 91d708600..68be3cc9e 100644 --- a/dist/rhdh/install.yaml +++ b/dist/rhdh/install.yaml @@ -2201,12 +2201,15 @@ spec: shortNames: - dhpc singular: devhubplugincatalog - scope: Cluster + scope: Namespaced versions: - additionalPrinterColumns: - jsonPath: .spec.source.ref name: Ref type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date @@ -2282,7 +2285,7 @@ spec: ref: description: |- Ref is the OCI artifact reference containing the catalog - Example: registry.redhat.io/rhdh/plugin-catalog:1.5 + Example: oci://registry.redhat.io/rhdh/plugin-catalog:1.5 type: string skipTLSVerify: default: false @@ -2295,10 +2298,73 @@ spec: required: - source type: object + status: + description: DevHubPluginCatalogStatus defines the observed state of DevHubPluginCatalog + properties: + conditions: + description: Conditions represent the latest available observations + of the catalog's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object type: object served: true storage: true - subresources: {} + subresources: + status: {} --- apiVersion: v1 kind: ServiceAccount @@ -2510,6 +2576,7 @@ rules: - rhdh.redhat.com resources: - backstages/status + - devhubplugincatalogs/status verbs: - get - patch @@ -4256,4 +4323,4 @@ metadata: namespace: rhdh-operator spec: source: - ref: quay.io/rhdh/plugin-catalog-index:2.0.0 + ref: oci://quay.io/rhdh/plugin-catalog-index:2.0.0 diff --git a/docs/dynamic-plugins.md b/docs/dynamic-plugins.md index 7af442578..3c6cc52d3 100644 --- a/docs/dynamic-plugins.md +++ b/docs/dynamic-plugins.md @@ -51,7 +51,7 @@ The operator supports loading default plugin configurations from OCI container i **Available from operator version 2.0** -Plugin catalogs are defined using `DevHubPluginCatalog` cluster-scoped resources. The operator fetches catalogs from OCI registries, merges them, and makes the configuration available to Backstage instances. +Plugin catalogs are defined using `DevHubPluginCatalog` resources in the operator namespace. The operator fetches catalogs from OCI registries, merges them, and makes the configuration available to Backstage instances. **Default Catalog**: The operator automatically includes the default RHDH plugin catalog (`quay.io/rhdh/plugin-catalog-index:`). @@ -62,9 +62,10 @@ apiVersion: rhdh.redhat.com/v1alpha5 kind: DevHubPluginCatalog metadata: name: my-catalog + namespace: rhdh-operator # Must be in operator namespace spec: source: - ref: quay.io/my-org/plugin-catalog:v1.0 + ref: oci://quay.io/my-org/plugin-catalog:v1.0 ``` #### Adding Multiple Catalogs @@ -92,9 +93,10 @@ apiVersion: rhdh.redhat.com/v1alpha5 kind: DevHubPluginCatalog metadata: name: private-catalog + namespace: rhdh-operator spec: source: - ref: registry.example.com/rhdh/plugin-catalog:v1.0 + ref: oci://registry.example.com/rhdh/plugin-catalog:v1.0 pullSecret: name: private-registry-creds ``` @@ -118,9 +120,10 @@ apiVersion: rhdh.redhat.com/v1alpha5 kind: DevHubPluginCatalog metadata: name: internal-catalog + namespace: rhdh-operator spec: source: - ref: internal-registry.corp.example.com/rhdh/plugin-catalog:latest + ref: oci://internal-registry.corp.example.com/rhdh/plugin-catalog:latest certificateAuthority: name: internal-ca key: ca.crt # Optional, defaults to "ca.crt" @@ -133,9 +136,10 @@ apiVersion: rhdh.redhat.com/v1alpha5 kind: DevHubPluginCatalog metadata: name: dev-catalog + namespace: rhdh-operator spec: source: - ref: dev-registry.local:5000/rhdh/plugin-catalog:dev + ref: oci://dev-registry.local:5000/rhdh/plugin-catalog:dev skipTLSVerify: true ``` diff --git a/integration_tests/devhubplugincatalog_test.go b/integration_tests/devhubplugincatalog_test.go index 33a34d667..0a79f8416 100644 --- a/integration_tests/devhubplugincatalog_test.go +++ b/integration_tests/devhubplugincatalog_test.go @@ -5,21 +5,24 @@ import ( "os" "time" + "gopkg.in/yaml.v2" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/redhat-developer/rhdh-operator/api" + "github.com/redhat-developer/rhdh-operator/api/v1alpha5" "github.com/redhat-developer/rhdh-operator/internal/controller" "github.com/redhat-developer/rhdh-operator/pkg/catalog" + "github.com/redhat-developer/rhdh-operator/pkg/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) const ( - defaultCatalogIndexImage = "quay.io/rhdh/plugin-catalog-index:next" + defaultCatalogIndexImage = "oci://quay.io/rhdh/plugin-catalog-index:next" ) func getCatalogIndexImage() string { @@ -65,7 +68,8 @@ var _ = When("create DevHubPluginCatalog", func() { // Create DevHubPluginCatalog pointing to the catalog index dhpc := &api.DevHubPluginCatalog{ ObjectMeta: metav1.ObjectMeta{ - Name: catalogName, + Name: catalogName, + Namespace: ns, }, Spec: api.DevHubPluginCatalogSpec{ Source: api.CatalogSource{ @@ -97,15 +101,47 @@ var _ = When("create DevHubPluginCatalog", func() { err := k8sClient.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: ns}, cm) g.Expect(err).ShouldNot(HaveOccurred()) - // Should have the catalogs-ready marker - g.Expect(cm.Data).To(HaveKey(catalog.CatalogsReadyKey)) - g.Expect(cm.Data[catalog.CatalogsReadyKey]).To(Equal("true")) - - // Should have dynamic-plugins.yaml with content + // Should have dynamic-plugins.yaml with ConfigMap YAML content g.Expect(cm.Data).To(HaveKey("dynamic-plugins.yaml")) dpContent := cm.Data["dynamic-plugins.yaml"] - g.Expect(dpContent).To(ContainSubstring("plugins:")) - g.Expect(dpContent).To(ContainSubstring("oci://")) + + // Content must be a ConfigMap YAML (with apiVersion, kind, metadata, data) + var innerCM map[string]interface{} + err = yaml.Unmarshal([]byte(dpContent), &innerCM) + g.Expect(err).ShouldNot(HaveOccurred(), "dynamic-plugins.yaml should be valid YAML") + g.Expect(innerCM).To(HaveKey("apiVersion")) + g.Expect(innerCM).To(HaveKey("kind")) + g.Expect(innerCM["kind"]).To(Equal("ConfigMap")) + + // Extract the inner data and parse as DynaPluginsConfig + innerData, ok := innerCM["data"].(map[interface{}]interface{}) + g.Expect(ok).To(BeTrue(), "ConfigMap should have data field") + innerDPContent, ok := innerData["dynamic-plugins.yaml"].(string) + g.Expect(ok).To(BeTrue(), "inner ConfigMap should have dynamic-plugins.yaml key") + + var config model.DynaPluginsConfig + err = yaml.Unmarshal([]byte(innerDPContent), &config) + g.Expect(err).ShouldNot(HaveOccurred(), "inner content should be parseable as DynaPluginsConfig") + g.Expect(config.Plugins).ShouldNot(BeEmpty(), "should have at least one plugin") + g.Expect(config.Plugins[0].Package).To(ContainSubstring("oci://")) }, time.Second*30, time.Second).Should(Succeed()) + + // Verify the catalog has Ready=True condition + Eventually(func(g Gomega) { + updatedCatalog := &api.DevHubPluginCatalog{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: catalogName, Namespace: ns}, updatedCatalog) + g.Expect(err).ShouldNot(HaveOccurred()) + + var readyCondition *metav1.Condition + for i := range updatedCatalog.Status.Conditions { + if updatedCatalog.Status.Conditions[i].Type == v1alpha5.ConditionTypeReady { + readyCondition = &updatedCatalog.Status.Conditions[i] + break + } + } + g.Expect(readyCondition).ShouldNot(BeNil(), "should have Ready condition") + g.Expect(readyCondition.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(readyCondition.Reason).To(Equal(v1alpha5.ConditionReasonSucceeded)) + }, time.Second*10, time.Second).Should(Succeed()) }) }) diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 8db766969..24942b699 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -29,9 +29,6 @@ import ( const ( BackstageFieldManager = "backstage-controller" - // CatalogStatusPath is the path to the catalog readiness marker in the mounted default-config - CatalogStatusPath = "/default-config/.catalogs-ready" - // AutoSyncEnvVar: EXT_CONF_SYNC_backstage env variable which defines the value for rhdh.redhat.com/ext-config-sync annotation of external config object (ConfigMap|Secret) // True by default AutoSyncEnvVar = "EXT_CONF_SYNC_backstage" @@ -44,8 +41,9 @@ const ( // BackstageReconciler reconciles a Backstage object type BackstageReconciler struct { client.Client - Scheme *runtime.Scheme - Platform platform.Platform + Scheme *runtime.Scheme + Platform platform.Platform + OperatorNamespace string } // +kubebuilder:rbac:groups=rhdh.redhat.com,resources=backstages,verbs=get;list;watch;create;update;patch;delete @@ -92,7 +90,7 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } // Check if plugin catalogs are ready before proceeding - if !areCatalogsReady() { + if !r.areCatalogsReady(ctx) { lg.Info("Waiting for plugin catalogs to be ready") setStatusCondition(&backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonInProgress, "Waiting for DevHubPluginCatalog resources to be ready") return ctrl.Result{RequeueAfter: 10 * time.Second}, nil @@ -210,22 +208,47 @@ func (r *BackstageReconciler) SetupWithManager(mgr ctrl.Manager) error { return b.Complete(r) } -// areCatalogsReady checks if plugin catalogs are ready by reading mounted status file +// areCatalogsReady checks if plugin catalogs are ready by checking DevHubPluginCatalog status. // Returns true if: -// - No status file exists (local dev mode - use static default-config files) -// - Status file has content (catalogs are ready) -// Returns false if status file exists but is empty (DHPC controller still processing) -func areCatalogsReady() bool { - info, err := os.Stat(CatalogStatusPath) - if os.IsNotExist(err) { - // No file → local dev → ready +// - Catalog controller is disabled (DISABLE_CATALOG_CONTROLLER=true) +// - No DevHubPluginCatalog resources exist +// - All DevHubPluginCatalog resources have Ready=True condition +// Returns false if any catalog is not ready. +func (r *BackstageReconciler) areCatalogsReady(ctx context.Context) bool { + if isCatalogControllerDisabled() { return true } - if err != nil { - // Error reading file, assume ready + + catalogList := &api.DevHubPluginCatalogList{} + if err := r.List(ctx, catalogList, client.InNamespace(r.OperatorNamespace)); err != nil { + // Can't list catalogs, assume not ready + return false + } + + if len(catalogList.Items) == 0 { + // No catalogs defined, nothing to wait for return true } - // Empty file → still processing → not ready - // Has content → ready - return info.Size() > 0 + + // Check if all catalogs have Ready=True + for i := range catalogList.Items { + catalog := &catalogList.Items[i] + ready := false + for _, cond := range catalog.Status.Conditions { + if cond.Type == "Ready" && cond.Status == "True" { + ready = true + break + } + } + if !ready { + return false + } + } + + return true +} + +// isCatalogControllerDisabled returns true if DISABLE_CATALOG_CONTROLLER env var is set to "true" +func isCatalogControllerDisabled() bool { + return os.Getenv("DISABLE_CATALOG_CONTROLLER") == "true" } diff --git a/internal/controller/devhubplugincatalog_controller.go b/internal/controller/devhubplugincatalog_controller.go index a56d111ec..047d45474 100644 --- a/internal/controller/devhubplugincatalog_controller.go +++ b/internal/controller/devhubplugincatalog_controller.go @@ -3,15 +3,20 @@ package controller import ( "context" "fmt" + "time" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "github.com/redhat-developer/rhdh-operator/api" + v1alpha5 "github.com/redhat-developer/rhdh-operator/api/v1alpha5" "github.com/redhat-developer/rhdh-operator/pkg/catalog" ) @@ -30,6 +35,7 @@ type DevHubPluginCatalogReconciler struct { } // +kubebuilder:rbac:groups=rhdh.redhat.com,resources=devhubplugincatalogs,verbs=get;list;watch +// +kubebuilder:rbac:groups=rhdh.redhat.com,resources=devhubplugincatalogs/status,verbs=get;update;patch // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;patch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch @@ -38,29 +44,42 @@ func (r *DevHubPluginCatalogReconciler) Reconcile(ctx context.Context, req ctrl. lg := log.FromContext(ctx) lg.V(1).Info("Reconciling DevHubPluginCatalog", "name", req.Name) - // 1. List all catalogs and build inputs + // 1. List all catalogs + catalogList := &api.DevHubPluginCatalogList{} + if err := r.List(ctx, catalogList); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to list catalogs: %w", err) + } + + // 2. Build inputs from catalogs inputs, err := r.buildCatalogInputs(ctx) if err != nil { return ctrl.Result{}, err } - if len(inputs) == 0 { - lg.Info("No DevHubPluginCatalog resources found") - return ctrl.Result{}, nil + // 3. Set all catalogs to Processing (if any exist) + if len(catalogList.Items) > 0 { + r.setAllConditions(ctx, catalogList, metav1.ConditionFalse, v1alpha5.ConditionReasonProcessing, "Processing catalog") } - // 2. Process all catalogs (fetch and merge) + // 4. Process catalogs (handles empty input → empty plugins list) content, err := r.Processor.Process(ctx, inputs) if err != nil { + r.setAllConditions(ctx, catalogList, metav1.ConditionFalse, v1alpha5.ConditionReasonFailed, err.Error()) return ctrl.Result{}, fmt.Errorf("failed to process catalogs: %w", err) } - // 3. Apply merged ConfigMap + // 5. Apply merged ConfigMap (clears old config if no catalogs) if err := r.applyConfigMap(ctx, content); err != nil { + r.setAllConditions(ctx, catalogList, metav1.ConditionFalse, v1alpha5.ConditionReasonFailed, err.Error()) return ctrl.Result{}, fmt.Errorf("failed to apply ConfigMap: %w", err) } - lg.Info("Successfully reconciled all DevHubPluginCatalogs", "count", len(inputs)) + // 6. Set all catalogs to Ready (if any exist) + if len(catalogList.Items) > 0 { + r.setAllConditions(ctx, catalogList, metav1.ConditionTrue, v1alpha5.ConditionReasonSucceeded, "Catalog processed successfully") + } + + lg.Info("Reconciled DevHubPluginCatalogs", "count", len(inputs)) return ctrl.Result{}, nil } @@ -125,10 +144,62 @@ func (r *DevHubPluginCatalogReconciler) applyConfigMap(ctx context.Context, patc return nil } +// setAllConditions sets the Ready condition on all catalogs in the list +func (r *DevHubPluginCatalogReconciler) setAllConditions(ctx context.Context, catalogList *api.DevHubPluginCatalogList, status metav1.ConditionStatus, reason, message string) { + lg := log.FromContext(ctx) + for i := range catalogList.Items { + if err := r.setCondition(ctx, &catalogList.Items[i], status, reason, message); err != nil { + lg.Error(err, "Failed to set condition", "catalog", catalogList.Items[i].Name) + } + } +} + +// setCondition sets the Ready condition on a single catalog with retry on conflict +func (r *DevHubPluginCatalogReconciler) setCondition(ctx context.Context, dhpc *api.DevHubPluginCatalog, status metav1.ConditionStatus, reason, message string) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + // Re-fetch the catalog to get the latest resourceVersion + fresh := &api.DevHubPluginCatalog{} + if err := r.Get(ctx, types.NamespacedName{Name: dhpc.Name, Namespace: dhpc.Namespace}, fresh); err != nil { + return err + } + + now := metav1.NewTime(time.Now()) + condition := metav1.Condition{ + Type: v1alpha5.ConditionTypeReady, + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: now, + } + + // Update condition in status + updated := false + for i, c := range fresh.Status.Conditions { + if c.Type == v1alpha5.ConditionTypeReady { + if c.Status != status { + fresh.Status.Conditions[i] = condition + } else { + // Only update message/reason, not LastTransitionTime + fresh.Status.Conditions[i].Reason = reason + fresh.Status.Conditions[i].Message = message + } + updated = true + break + } + } + if !updated { + fresh.Status.Conditions = append(fresh.Status.Conditions, condition) + } + + return r.Status().Update(ctx, fresh) + }) +} + // SetupWithManager sets up the controller with the Manager func (r *DevHubPluginCatalogReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&api.DevHubPluginCatalog{}). + WithEventFilter(predicate.GenerationChangedPredicate{}). Named("devhubplugincatalog"). Complete(r) } diff --git a/internal/controller/devhubplugincatalog_controller_test.go b/internal/controller/devhubplugincatalog_controller_test.go index 008ddfb01..858e4a859 100644 --- a/internal/controller/devhubplugincatalog_controller_test.go +++ b/internal/controller/devhubplugincatalog_controller_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -15,6 +16,7 @@ import ( "github.com/redhat-developer/rhdh-operator/api" "github.com/redhat-developer/rhdh-operator/pkg/catalog" + "github.com/redhat-developer/rhdh-operator/pkg/model" ) func setupCatalogTestReconciler(objects ...client.Object) *DevHubPluginCatalogReconciler { @@ -280,20 +282,32 @@ func TestBuildCatalogInputs_MissingCACert_Fails(t *testing.T) { assert.Contains(t, err.Error(), "non-existent-ca") } -func TestApplyConfigMap(t *testing.T) { +func TestApplyConfigMap_WithExistingDynamicPlugins(t *testing.T) { + // Simulate default-config ConfigMap with existing dynamic-plugins.yaml existingCM := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: DefaultConfigMapName, Namespace: "rhdh-operator", }, Data: map[string]string{ - "existing-key": "existing-value", + "app-config.yaml": "app:\n title: My Backstage\n", + "dynamic-plugins.yaml": `plugins: + - package: "oci://old-registry.com/old-plugin:1.0" + disabled: false +`, }, } r := setupCatalogTestReconciler(existingCM) - patchBytes := []byte(`{"data":{"dynamic-plugins.yaml":"test content",".catalogs-ready":"true"}}`) + // Patch with new plugins from catalog + newPluginsYAML := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" + disabled: false + - package: "oci://registry.example.com/rhdh/plugin-kubernetes:2.0" + disabled: false +` + patchBytes := []byte(`{"data":{"dynamic-plugins.yaml":"` + escapeJSONString(newPluginsYAML) + `",".catalogs-ready":"true"}}`) err := r.applyConfigMap(context.TODO(), patchBytes) require.NoError(t, err) @@ -305,6 +319,102 @@ func TestApplyConfigMap(t *testing.T) { Namespace: "rhdh-operator", }, cm) require.NoError(t, err) + + // Verify dynamic-plugins.yaml was replaced with new content + assert.Contains(t, cm.Data, "dynamic-plugins.yaml") + dpContent := cm.Data["dynamic-plugins.yaml"] + + // Content must be parseable as DynaPluginsConfig + var config model.DynaPluginsConfig + err = yaml.Unmarshal([]byte(dpContent), &config) + require.NoError(t, err, "dynamic-plugins.yaml should be parseable as DynaPluginsConfig") + + // Verify plugins from catalog are present + assert.Len(t, config.Plugins, 2) + assert.Equal(t, "oci://registry.example.com/rhdh/plugin-techdocs:1.0", config.Plugins[0].Package) + assert.Equal(t, "oci://registry.example.com/rhdh/plugin-kubernetes:2.0", config.Plugins[1].Package) + + // Verify old plugin is NOT present (was replaced) + for _, p := range config.Plugins { + assert.NotContains(t, p.Package, "old-registry.com") + } + + // Verify catalogs-ready marker + assert.Equal(t, "true", cm.Data[".catalogs-ready"]) + + // Verify other keys are preserved + assert.Equal(t, "app:\n title: My Backstage\n", cm.Data["app-config.yaml"]) +} + +func TestApplyConfigMap_WithoutExistingDynamicPlugins(t *testing.T) { + // Simulate default-config ConfigMap WITHOUT dynamic-plugins.yaml + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: DefaultConfigMapName, + Namespace: "rhdh-operator", + }, + Data: map[string]string{ + "app-config.yaml": "app:\n title: My Backstage\n", + }, + } + + r := setupCatalogTestReconciler(existingCM) + + // Patch with plugins from catalog + newPluginsYAML := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-argocd:1.0" + disabled: false +` + patchBytes := []byte(`{"data":{"dynamic-plugins.yaml":"` + escapeJSONString(newPluginsYAML) + `",".catalogs-ready":"true"}}`) + + err := r.applyConfigMap(context.TODO(), patchBytes) + require.NoError(t, err) + + // Verify the ConfigMap was patched + cm := &corev1.ConfigMap{} + err = r.Get(context.TODO(), types.NamespacedName{ + Name: DefaultConfigMapName, + Namespace: "rhdh-operator", + }, cm) + require.NoError(t, err) + + // Verify dynamic-plugins.yaml was created + assert.Contains(t, cm.Data, "dynamic-plugins.yaml") + dpContent := cm.Data["dynamic-plugins.yaml"] + + // Content must be parseable as DynaPluginsConfig + var config model.DynaPluginsConfig + err = yaml.Unmarshal([]byte(dpContent), &config) + require.NoError(t, err, "dynamic-plugins.yaml should be parseable as DynaPluginsConfig") + + // Verify plugin from catalog is present + assert.Len(t, config.Plugins, 1) + assert.Equal(t, "oci://registry.example.com/rhdh/plugin-argocd:1.0", config.Plugins[0].Package) + + // Verify catalogs-ready marker + assert.Equal(t, "true", cm.Data[".catalogs-ready"]) + + // Verify other keys are preserved + assert.Equal(t, "app:\n title: My Backstage\n", cm.Data["app-config.yaml"]) +} + +// escapeJSONString escapes a string for inclusion in a JSON string value +func escapeJSONString(s string) string { + // Replace newlines and quotes for JSON embedding + result := "" + for _, c := range s { + switch c { + case '\n': + result += "\\n" + case '"': + result += "\\\"" + case '\\': + result += "\\\\" + default: + result += string(c) + } + } + return result } func TestConstants(t *testing.T) { diff --git a/pkg/catalog/processor.go b/pkg/catalog/processor.go index e1c1802e6..080306775 100644 --- a/pkg/catalog/processor.go +++ b/pkg/catalog/processor.go @@ -15,14 +15,8 @@ import ( ) const ( - // InternalConfigMapName is the name embedded in the wrapped ConfigMap - InternalConfigMapName = "catalog-dynamic-plugins" - // CatalogFileName is the expected file in OCI artifacts CatalogFileName = "dynamic-plugins.default.yaml" - - // CatalogsReadyKey is the marker key added to ConfigMap when catalogs are ready - CatalogsReadyKey = ".catalogs-ready" ) // Processor handles fetching, processing, and merging plugin catalogs @@ -58,40 +52,36 @@ func (p *Processor) Process(ctx context.Context, catalogs []CatalogInput) ([]byt return nil, fmt.Errorf("failed to merge catalogs: %w", err) } - // Wrap in ConfigMap YAML and build JSON patch - wrapped := p.wrapInConfigMap(merged) - patchData := map[string]interface{}{ + // Build the inner ConfigMap that will be stored as the value in default-config. + // The default-config ConfigMap stores Kubernetes manifests as file values, + // so each value must be a complete YAML document (apiVersion, kind, etc.) + innerConfigMap := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "default-dynamic-plugins", + "annotations": map[string]string{ + "rhdh.redhat.com/managed-by": "DevHubPluginCatalog", + }, + }, "data": map[string]string{ - model.DynamicPluginsFile: string(wrapped), - CatalogsReadyKey: "true", + model.DynamicPluginsFile: string(merged), }, } - return json.Marshal(patchData) -} - -// wrapInConfigMap wraps the plugin config in a ConfigMap manifest YAML -func (p *Processor) wrapInConfigMap(content []byte) []byte { - wrapped := fmt.Sprintf(`apiVersion: v1 -kind: ConfigMap -metadata: - name: %s -data: - %s: | -%s`, InternalConfigMapName, model.DynamicPluginsFile, indentYAML(string(content), 4)) - return []byte(wrapped) -} + innerYAML, err := yaml.Marshal(innerConfigMap) + if err != nil { + return nil, fmt.Errorf("failed to marshal inner ConfigMap: %w", err) + } -// indentYAML indents each line of the YAML content by the specified number of spaces -func indentYAML(content string, spaces int) string { - indent := strings.Repeat(" ", spaces) - lines := strings.Split(content, "\n") - for i, line := range lines { - if line != "" { - lines[i] = indent + line - } + // Build JSON patch for the default-config ConfigMap + patchData := map[string]interface{}{ + "data": map[string]string{ + model.DynamicPluginsFile: string(innerYAML), + }, } - return strings.Join(lines, "\n") + + return json.Marshal(patchData) } // fetch downloads a single catalog diff --git a/pkg/catalog/processor_test.go b/pkg/catalog/processor_test.go index bd9799839..1661cbc60 100644 --- a/pkg/catalog/processor_test.go +++ b/pkg/catalog/processor_test.go @@ -2,7 +2,6 @@ package catalog import ( "encoding/json" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -175,78 +174,6 @@ func TestMerge_PreservesPluginConfig(t *testing.T) { assert.NotNil(t, merged.Plugins[0].PluginConfig) } -func TestWrapInConfigMap(t *testing.T) { - p := NewProcessor() - - content := []byte(`plugins: - - package: "oci://registry.example.com/rhdh/plugin-test:1.0" -`) - - wrapped := p.wrapInConfigMap(content) - wrappedStr := string(wrapped) - - // Verify it's valid YAML - var cm map[string]interface{} - require.NoError(t, yaml.Unmarshal(wrapped, &cm)) - - // Verify structure - assert.Equal(t, "v1", cm["apiVersion"]) - assert.Equal(t, "ConfigMap", cm["kind"]) - - metadata, ok := cm["metadata"].(map[interface{}]interface{}) - require.True(t, ok) - assert.Equal(t, InternalConfigMapName, metadata["name"]) - - // Verify data key - data, ok := cm["data"].(map[interface{}]interface{}) - require.True(t, ok) - assert.Contains(t, data, model.DynamicPluginsFile) - - // The content should contain our plugin - assert.Contains(t, wrappedStr, "oci://registry.example.com/rhdh/plugin-test:1.0") -} - -func TestIndentYAML(t *testing.T) { - tests := []struct { - name string - content string - spaces int - expected string - }{ - { - name: "simple indent", - content: "line1\nline2", - spaces: 2, - expected: " line1\n line2", - }, - { - name: "four spaces", - content: "key: value", - spaces: 4, - expected: " key: value", - }, - { - name: "empty lines preserved", - content: "line1\n\nline3", - spaces: 2, - expected: " line1\n\n line3", - }, - { - name: "zero spaces", - content: "no indent", - spaces: 0, - expected: "no indent", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := indentYAML(tt.content, tt.spaces) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestCatalogInput(t *testing.T) { input := CatalogInput{ Ref: "oci://registry.example.com/rhdh/plugin-catalog-index:v1", @@ -262,18 +189,39 @@ func TestCatalogInput(t *testing.T) { } func TestConstants(t *testing.T) { - assert.Equal(t, "catalog-dynamic-plugins", InternalConfigMapName) assert.Equal(t, "dynamic-plugins.default.yaml", CatalogFileName) - assert.Equal(t, ".catalogs-ready", CatalogsReadyKey) } func TestProcessOutputFormat(t *testing.T) { - // Verifies the JSON patch structure without actually fetching + // Verifies the JSON patch structure contains a ConfigMap YAML (not raw plugin config). + // The default-config ConfigMap stores Kubernetes manifests as file values, + // so each value must be a complete ConfigMap YAML with apiVersion, kind, metadata, and data. + + // Simulate what Process() outputs + pluginConfig := `plugins: + - package: "oci://registry.example.com/rhdh/plugin-test:1.0" + disabled: false +` + innerConfigMap := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "default-dynamic-plugins", + "annotations": map[string]string{ + "rhdh.redhat.com/managed-by": "DevHubPluginCatalog", + }, + }, + "data": map[string]string{ + model.DynamicPluginsFile: pluginConfig, + }, + } + + innerYAML, err := yaml.Marshal(innerConfigMap) + require.NoError(t, err) patchData := map[string]interface{}{ "data": map[string]string{ - model.DynamicPluginsFile: "test content", - CatalogsReadyKey: "true", + model.DynamicPluginsFile: string(innerYAML), }, } @@ -285,30 +233,22 @@ func TestProcessOutputFormat(t *testing.T) { data, ok := parsed["data"].(map[string]interface{}) require.True(t, ok) - assert.Equal(t, "test content", data[model.DynamicPluginsFile]) - assert.Equal(t, "true", data[CatalogsReadyKey]) -} - -func TestWrapInConfigMap_MultilineContent(t *testing.T) { - p := NewProcessor() - content := []byte(`plugins: - - package: "oci://registry.example.com/rhdh/plugin-github:1.0" - disabled: false - pluginConfig: - catalog: - providers: - github: - organization: my-org -`) + // Verify the content is a ConfigMap YAML + dpContent := data[model.DynamicPluginsFile].(string) + var configMap map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(dpContent), &configMap), "dynamic-plugins.yaml content should be a ConfigMap YAML") - wrapped := p.wrapInConfigMap(content) + assert.Equal(t, "v1", configMap["apiVersion"]) + assert.Equal(t, "ConfigMap", configMap["kind"]) - // Should produce valid YAML - var cm map[string]interface{} - require.NoError(t, yaml.Unmarshal(wrapped, &cm)) + cmData, ok := configMap["data"].(map[interface{}]interface{}) + require.True(t, ok, "ConfigMap should have data field") - wrappedStr := string(wrapped) - assert.True(t, strings.Contains(wrappedStr, "data:")) - assert.True(t, strings.Contains(wrappedStr, model.DynamicPluginsFile+": |")) + // Verify the inner content is parseable as DynaPluginsConfig + innerContent := cmData[model.DynamicPluginsFile].(string) + var config model.DynaPluginsConfig + require.NoError(t, yaml.Unmarshal([]byte(innerContent), &config)) + assert.Len(t, config.Plugins, 1) + assert.Equal(t, "oci://registry.example.com/rhdh/plugin-test:1.0", config.Plugins[0].Package) } diff --git a/pkg/fetcher/extract.go b/pkg/fetcher/extract.go index e2e272ce4..2c9dfc020 100644 --- a/pkg/fetcher/extract.go +++ b/pkg/fetcher/extract.go @@ -12,8 +12,8 @@ import ( "strings" ) -// DefaultMaxEntrySize is the default maximum file size for extraction (1GB) -const DefaultMaxEntrySize = 1 << 30 +// DefaultMaxEntrySize is the default maximum file size for extraction (40MB) +const DefaultMaxEntrySize = 40_000_000 // getMaxEntrySize returns the maximum entry size from MAX_ENTRY_SIZE env var or default func getMaxEntrySize() int64 { @@ -31,7 +31,7 @@ func extractTarGz(r io.Reader, destDir string) error { if err != nil { return fmt.Errorf("failed to create gzip reader: %w", err) } - defer gzr.Close() + defer func() { _ = gzr.Close() }() return extractTar(gzr, destDir) } @@ -49,11 +49,16 @@ func extractTarBytes(data []byte, destDir string) error { // extractTar extracts a tarball from a reader to destDir func extractTar(r io.Reader, destDir string) error { tr := tar.NewReader(r) + maxEntrySize := getMaxEntrySize() - // Ensure destDir exists + // Ensure destDir exists and get absolute path for validation if err := os.MkdirAll(destDir, 0755); err != nil { return err } + destDir, err := filepath.Abs(destDir) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } for { header, err := tr.Next() @@ -64,11 +69,15 @@ func extractTar(r io.Reader, destDir string) error { return err } - target := filepath.Join(destDir, header.Name) + // Check header size before extraction (fail fast) + if header.Size > maxEntrySize { + return fmt.Errorf("entry %q exceeds maximum size: %d > %d", header.Name, header.Size, maxEntrySize) + } - // Security: prevent path traversal - if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) { - return fmt.Errorf("invalid tar path: %s", header.Name) + // Construct and validate safe path + target, err := safePath(destDir, header.Name) + if err != nil { + return err } switch header.Typeflag { @@ -80,15 +89,16 @@ func extractTar(r io.Reader, destDir string) error { if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } - if err := extractFile(tr, target, header.Mode); err != nil { + if err := extractFile(tr, target, header.Size, maxEntrySize); err != nil { return err } case tar.TypeSymlink: - // Handle symlinks - validate they don't escape destDir - linkTarget := filepath.Join(filepath.Dir(target), header.Linkname) - if !strings.HasPrefix(filepath.Clean(linkTarget), filepath.Clean(destDir)) { + // Validate symlink target doesn't escape destDir + linkTarget, err := safePath(destDir, filepath.Join(filepath.Dir(header.Name), header.Linkname)) + if err != nil { return fmt.Errorf("symlink escapes destination: %s -> %s", header.Name, header.Linkname) } + _ = linkTarget // validated, use original relative linkname for symlink if err := os.Symlink(header.Linkname, target); err != nil { return err } @@ -97,20 +107,50 @@ func extractTar(r io.Reader, destDir string) error { return nil } -// extractFile extracts a single file from tar reader -func extractFile(tr *tar.Reader, path string, mode int64) error { - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(mode)) +// safePath constructs a safe path within destDir, preventing path traversal attacks. +// Returns error if the resulting path would escape destDir. +func safePath(destDir, name string) (string, error) { + // Clean the name to remove any . or .. components + cleaned := filepath.Clean(name) + if strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { + return "", fmt.Errorf("invalid tar path: %s", name) + } + + // Join with destination and verify it's still within destDir + target := filepath.Join(destDir, cleaned) + if !strings.HasPrefix(target, destDir+string(os.PathSeparator)) && target != destDir { + return "", fmt.Errorf("invalid tar path: %s", name) + } + + return target, nil +} + +// extractFile extracts a single file from tar reader. +// expectedSize is the size declared in the tar header. +// maxSize is the maximum allowed size (defense in depth). +func extractFile(tr *tar.Reader, path string, expectedSize, maxSize int64) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() - // Limit copy size to prevent decompression bombs - // Uses MAX_ENTRY_SIZE env var or defaults to 1GB - maxSize := getMaxEntrySize() - _, err = io.CopyN(f, tr, maxSize) + // Use expected size from header, but cap at maxSize (defense in depth) + limit := expectedSize + if limit > maxSize { + limit = maxSize + } + // Add 1 byte to detect if actual content exceeds declared size + written, err := io.CopyN(f, tr, limit+1) if err == io.EOF { return nil } - return err + if err != nil { + return err + } + // If we read limit+1 bytes, the file is larger than declared + if written > limit { + return fmt.Errorf("file %q actual size exceeds declared size", path) + } + return nil } diff --git a/pkg/fetcher/extract_test.go b/pkg/fetcher/extract_test.go index 55bf5a37d..b80327e0b 100644 --- a/pkg/fetcher/extract_test.go +++ b/pkg/fetcher/extract_test.go @@ -180,22 +180,53 @@ func TestExtractTarEmptyArchive(t *testing.T) { } func TestGetMaxEntrySize(t *testing.T) { - // Test default value - os.Unsetenv("MAX_ENTRY_SIZE") + // Test default value (env not set) assert.Equal(t, int64(DefaultMaxEntrySize), getMaxEntrySize()) // Test custom value - os.Setenv("MAX_ENTRY_SIZE", "1000") + t.Setenv("MAX_ENTRY_SIZE", "1000") assert.Equal(t, int64(1000), getMaxEntrySize()) // Test invalid value falls back to default - os.Setenv("MAX_ENTRY_SIZE", "invalid") + t.Setenv("MAX_ENTRY_SIZE", "invalid") assert.Equal(t, int64(DefaultMaxEntrySize), getMaxEntrySize()) // Test negative value falls back to default - os.Setenv("MAX_ENTRY_SIZE", "-100") + t.Setenv("MAX_ENTRY_SIZE", "-100") assert.Equal(t, int64(DefaultMaxEntrySize), getMaxEntrySize()) +} + +func TestExtractTarHeaderSizeCheck(t *testing.T) { + // Set a small max size for testing + t.Setenv("MAX_ENTRY_SIZE", "100") + + // Create a tar with a file larger than the limit + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + // Create content larger than MAX_ENTRY_SIZE + content := make([]byte, 200) + for i := range content { + content[i] = 'x' + } + + hdr := &tar.Header{ + Name: "large-file.txt", + Mode: 0644, + Size: int64(len(content)), // 200 bytes exceeds MAX_ENTRY_SIZE of 100 + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + + destDir := t.TempDir() + err = extractTarBytes(buf.Bytes(), destDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum size") +} - // Cleanup - os.Unsetenv("MAX_ENTRY_SIZE") +func TestDefaultMaxEntrySize(t *testing.T) { + // Verify the default is 40MB + assert.Equal(t, int64(40_000_000), int64(DefaultMaxEntrySize)) } diff --git a/pkg/fetcher/oci.go b/pkg/fetcher/oci.go index b822de497..428ac47e7 100644 --- a/pkg/fetcher/oci.go +++ b/pkg/fetcher/oci.go @@ -7,7 +7,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io" "net/http" "strings" @@ -91,35 +90,13 @@ func WithPluginValidation() OCIOption { // Fetch methods // ---------------------------------------------------------------------------- -// FetchResult contains the result of fetching an artifact -type FetchResult struct { - // Digest is the artifact digest (sha256:...) - Digest string - - // Content is the extracted artifact content - Content []byte - - // MediaType of the artifact - MediaType string -} - -// Fetch downloads an OCI artifact and extracts it to destDir +// Fetch downloads an OCI artifact and extracts it to destDir. +// Streams directly to extraction without buffering the entire layer in memory. func (c *OCIFetcher) Fetch(ctx context.Context, ref string, destDir string) error { - result, err := c.FetchContent(ctx, ref) - if err != nil { - return err - } - - // Extract tarball content to destDir (already uncompressed by Uncompressed()) - return extractTarBytes(result.Content, destDir) -} - -// FetchContent downloads an OCI artifact and returns its content -func (c *OCIFetcher) FetchContent(ctx context.Context, ref string) (*FetchResult, error) { // 1. Parse reference imgRef, err := name.ParseReference(ref) if err != nil { - return nil, fmt.Errorf("invalid OCI reference %q: %w", ref, err) + return fmt.Errorf("invalid OCI reference %q: %w", ref, err) } // 2. Configure remote options @@ -134,51 +111,39 @@ func (c *OCIFetcher) FetchContent(ctx context.Context, ref string) (*FetchResult // 3. Fetch artifact desc, err := remote.Get(imgRef, remoteOpts...) if err != nil { - return nil, fmt.Errorf("failed to fetch %q: %w", ref, err) + return fmt.Errorf("failed to fetch %q: %w", ref, err) } - // 4. Handle image vs artifact + // 4. Get image img, err := desc.Image() if err != nil { - return nil, fmt.Errorf("failed to get image: %w", err) + return fmt.Errorf("failed to get image: %w", err) } - // 5. Validate plugin artifact (optional, for plugins only) + // 5. Validate plugin artifact (optional) if c.validatePlugin { if err := validatePluginArtifact(img); err != nil { - return nil, fmt.Errorf("validation failed for %q: %w", ref, err) + return fmt.Errorf("validation failed for %q: %w", ref, err) } } - // 6. Extract content from first layer + // 6. Get first layer layers, err := img.Layers() if err != nil { - return nil, fmt.Errorf("failed to get layers: %w", err) + return fmt.Errorf("failed to get layers: %w", err) } if len(layers) == 0 { - return nil, fmt.Errorf("artifact has no layers") + return fmt.Errorf("artifact has no layers") } + // 7. Stream layer directly to extraction (no buffering) reader, err := layers[0].Uncompressed() if err != nil { - return nil, fmt.Errorf("failed to uncompress layer: %w", err) + return fmt.Errorf("failed to uncompress layer: %w", err) } defer func() { _ = reader.Close() }() - content, err := io.ReadAll(reader) - if err != nil { - return nil, fmt.Errorf("failed to read layer: %w", err) - } - - digest, err := img.Digest() - if err != nil { - return nil, fmt.Errorf("failed to get digest: %w", err) - } - - return &FetchResult{ - Digest: digest.String(), - Content: content, - }, nil + return extractTar(reader, destDir) } // ---------------------------------------------------------------------------- diff --git a/pkg/fetcher/oci_test.go b/pkg/fetcher/oci_test.go index 74182a5aa..d26ea573b 100644 --- a/pkg/fetcher/oci_test.go +++ b/pkg/fetcher/oci_test.go @@ -259,19 +259,6 @@ func createDockerConfig(t *testing.T, auths map[string]authEntry) []byte { return data } -// TestFetchResult verifies the FetchResult struct -func TestFetchResult(t *testing.T) { - result := &FetchResult{ - Digest: "sha256:abc123", - Content: []byte("test content"), - MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", - } - - assert.Equal(t, "sha256:abc123", result.Digest) - assert.Equal(t, []byte("test content"), result.Content) - assert.Equal(t, "application/vnd.oci.image.layer.v1.tar+gzip", result.MediaType) -} - // TestPluginAnnotation verifies the constant func TestPluginAnnotation(t *testing.T) { assert.Equal(t, "io.backstage.dynamic-packages", PluginAnnotation) diff --git a/plugin-installer/install_plugins.sh b/plugin-installer/install_plugins.sh index 1a343ebd0..3ae7c57d0 100755 --- a/plugin-installer/install_plugins.sh +++ b/plugin-installer/install_plugins.sh @@ -723,7 +723,14 @@ fi export -f download_plugin extract_oci_image validate_plugin_artifact download_oci download_http download_npm download_local download_file detect_oci_tool parse_npmrc url_encode verify_integrity record_failure export OUTPUT_DIR OCI_TOOL NPM_REGISTRY NPM_AUTH_TOKEN FAILURE_LOG -total=$(grep -cv '^#\|^$' "${INPUT_FILE}") +total=$(grep -cv '^#\|^$' "${INPUT_FILE}" 2>/dev/null || echo 0) + +# Exit successfully if no packages to download (all plugins are local paths) +if [[ "${total}" -eq 0 ]]; then + echo "=== No remote plugins to download (all plugins are local paths) ===" + echo "=== Done in 0s ===" + exit 0 +fi echo "=== Downloading ${total} plugins to ${OUTPUT_DIR} (${PARALLEL_JOBS} parallel) ===" echo "" From 78535df1bcf49b97c7c1815307d2cbd374b1229c Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Wed, 26 Aug 2026 18:49:25 +0300 Subject: [PATCH 14/28] fixes --- pkg/fetcher/extract.go | 11 ++------ pkg/fetcher/extract_test.go | 56 ------------------------------------- 2 files changed, 2 insertions(+), 65 deletions(-) diff --git a/pkg/fetcher/extract.go b/pkg/fetcher/extract.go index 2c9dfc020..61e16a13b 100644 --- a/pkg/fetcher/extract.go +++ b/pkg/fetcher/extract.go @@ -93,15 +93,8 @@ func extractTar(r io.Reader, destDir string) error { return err } case tar.TypeSymlink: - // Validate symlink target doesn't escape destDir - linkTarget, err := safePath(destDir, filepath.Join(filepath.Dir(header.Name), header.Linkname)) - if err != nil { - return fmt.Errorf("symlink escapes destination: %s -> %s", header.Name, header.Linkname) - } - _ = linkTarget // validated, use original relative linkname for symlink - if err := os.Symlink(header.Linkname, target); err != nil { - return err - } + // Skip symlinks - not needed for catalog extraction and avoids path traversal risks + continue } } return nil diff --git a/pkg/fetcher/extract_test.go b/pkg/fetcher/extract_test.go index b80327e0b..bb831be84 100644 --- a/pkg/fetcher/extract_test.go +++ b/pkg/fetcher/extract_test.go @@ -113,62 +113,6 @@ func TestExtractTarPathTraversal(t *testing.T) { assert.Contains(t, err.Error(), "invalid tar path") } -func TestExtractTarSymlinkEscape(t *testing.T) { - // Create a tar with symlink escape attempt - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - - hdr := &tar.Header{ - Name: "escape-link", - Mode: 0777, - Typeflag: tar.TypeSymlink, - Linkname: "../../../etc/passwd", - } - require.NoError(t, tw.WriteHeader(hdr)) - require.NoError(t, tw.Close()) - - destDir := t.TempDir() - err := extractTarBytes(buf.Bytes(), destDir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "symlink escapes destination") -} - -func TestExtractTarValidSymlink(t *testing.T) { - // Create a tar with valid internal symlink - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - - // Add target file - content := []byte("target content") - hdr := &tar.Header{ - Name: "target.txt", - Mode: 0644, - Size: int64(len(content)), - } - require.NoError(t, tw.WriteHeader(hdr)) - _, err := tw.Write(content) - require.NoError(t, err) - - // Add symlink to target - linkHdr := &tar.Header{ - Name: "link.txt", - Mode: 0777, - Typeflag: tar.TypeSymlink, - Linkname: "target.txt", - } - require.NoError(t, tw.WriteHeader(linkHdr)) - require.NoError(t, tw.Close()) - - destDir := t.TempDir() - err = extractTarBytes(buf.Bytes(), destDir) - require.NoError(t, err) - - // Verify symlink works - data, err := os.ReadFile(filepath.Join(destDir, "link.txt")) - require.NoError(t, err) - assert.Equal(t, "target content", string(data)) -} - func TestExtractTarEmptyArchive(t *testing.T) { var buf bytes.Buffer tw := tar.NewWriter(&buf) From 8ec906f3596b30bf8ba84cfcddaf60a2800f972c Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Wed, 26 Aug 2026 18:56:33 +0300 Subject: [PATCH 15/28] fixes --- ...kstage-operator.clusterserviceversion.yaml | 2 +- ...kstage-operator.clusterserviceversion.yaml | 2 +- go.mod | 1 + go.sum | 2 ++ pkg/fetcher/extract.go | 27 ++++--------------- pkg/fetcher/extract_test.go | 21 ++++++++++++--- 6 files changed, 27 insertions(+), 28 deletions(-) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 22a331595..365583511 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-26T15:35:57Z" + createdAt: "2026-08-26T15:55:42Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 90dd91a7a..fc195bfb0 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-26T15:35:54Z" + createdAt: "2026-08-26T15:55:38Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/go.mod b/go.mod index 7270f4942..e8f9b0e01 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 toolchain go1.26.6 require ( + github.com/cyphar/filepath-securejoin v0.7.0 github.com/google/go-containerregistry v0.21.9 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 diff --git a/go.sum b/go.sum index 4bdb6b994..13d416ffa 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/pkg/fetcher/extract.go b/pkg/fetcher/extract.go index 61e16a13b..afdf8faa1 100644 --- a/pkg/fetcher/extract.go +++ b/pkg/fetcher/extract.go @@ -9,7 +9,8 @@ import ( "os" "path/filepath" "strconv" - "strings" + + securejoin "github.com/cyphar/filepath-securejoin" ) // DefaultMaxEntrySize is the default maximum file size for extraction (40MB) @@ -74,10 +75,10 @@ func extractTar(r io.Reader, destDir string) error { return fmt.Errorf("entry %q exceeds maximum size: %d > %d", header.Name, header.Size, maxEntrySize) } - // Construct and validate safe path - target, err := safePath(destDir, header.Name) + // Construct safe path using securejoin to prevent zip slip attacks + target, err := securejoin.SecureJoin(destDir, header.Name) if err != nil { - return err + return fmt.Errorf("invalid tar path %q: %w", header.Name, err) } switch header.Typeflag { @@ -100,24 +101,6 @@ func extractTar(r io.Reader, destDir string) error { return nil } -// safePath constructs a safe path within destDir, preventing path traversal attacks. -// Returns error if the resulting path would escape destDir. -func safePath(destDir, name string) (string, error) { - // Clean the name to remove any . or .. components - cleaned := filepath.Clean(name) - if strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { - return "", fmt.Errorf("invalid tar path: %s", name) - } - - // Join with destination and verify it's still within destDir - target := filepath.Join(destDir, cleaned) - if !strings.HasPrefix(target, destDir+string(os.PathSeparator)) && target != destDir { - return "", fmt.Errorf("invalid tar path: %s", name) - } - - return target, nil -} - // extractFile extracts a single file from tar reader. // expectedSize is the size declared in the tar header. // maxSize is the maximum allowed size (defense in depth). diff --git a/pkg/fetcher/extract_test.go b/pkg/fetcher/extract_test.go index bb831be84..c0532eb7d 100644 --- a/pkg/fetcher/extract_test.go +++ b/pkg/fetcher/extract_test.go @@ -6,6 +6,7 @@ import ( "compress/gzip" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -99,18 +100,30 @@ func TestExtractTarPathTraversal(t *testing.T) { var buf bytes.Buffer tw := tar.NewWriter(&buf) + content := []byte("malicious content") hdr := &tar.Header{ Name: "../../../etc/passwd", Mode: 0644, - Size: 0, + Size: int64(len(content)), } require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(content) + require.NoError(t, err) require.NoError(t, tw.Close()) destDir := t.TempDir() - err := extractTarBytes(buf.Bytes(), destDir) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid tar path") + err = extractTarBytes(buf.Bytes(), destDir) + // securejoin sanitizes the path, so extraction succeeds but stays within destDir + require.NoError(t, err) + + // Verify file was created inside destDir (path traversal neutralized) + // The sanitized path becomes "etc/passwd" inside destDir + sanitizedPath := filepath.Join(destDir, "etc", "passwd") + _, err = os.Stat(sanitizedPath) + assert.NoError(t, err, "file should exist at sanitized path inside destDir") + + // Verify /etc/passwd was NOT modified (would require root anyway, but check path) + assert.True(t, strings.HasPrefix(sanitizedPath, destDir), "path should be inside destDir") } func TestExtractTarEmptyArchive(t *testing.T) { From 6361ce86eaae3467226358cee203121cb5372582 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Wed, 26 Aug 2026 21:15:43 +0300 Subject: [PATCH 16/28] fixes --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- .../devhubplugincatalog_controller.go | 15 ++++------- .../devhubplugincatalog_controller_test.go | 27 ++++++++++++------- 7 files changed, 28 insertions(+), 24 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9b7acdd38..ca4d7a346 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index c3ecd6b25..1357c08bd 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index b315b789e..6613a8896 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index c9c126a0f..9c86c46ee 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index b3805d13e..c029c6140 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -6,7 +6,7 @@ package v1alpha5 import ( corev1 "k8s.io/api/core/v1" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/internal/controller/devhubplugincatalog_controller.go b/internal/controller/devhubplugincatalog_controller.go index 047d45474..d16e5bed9 100644 --- a/internal/controller/devhubplugincatalog_controller.go +++ b/internal/controller/devhubplugincatalog_controller.go @@ -44,14 +44,14 @@ func (r *DevHubPluginCatalogReconciler) Reconcile(ctx context.Context, req ctrl. lg := log.FromContext(ctx) lg.V(1).Info("Reconciling DevHubPluginCatalog", "name", req.Name) - // 1. List all catalogs + // 1. List catalogs in operator namespace only catalogList := &api.DevHubPluginCatalogList{} - if err := r.List(ctx, catalogList); err != nil { + if err := r.List(ctx, catalogList, client.InNamespace(r.OperatorNamespace)); err != nil { return ctrl.Result{}, fmt.Errorf("failed to list catalogs: %w", err) } // 2. Build inputs from catalogs - inputs, err := r.buildCatalogInputs(ctx) + inputs, err := r.buildCatalogInputs(ctx, catalogList) if err != nil { return ctrl.Result{}, err } @@ -83,13 +83,8 @@ func (r *DevHubPluginCatalogReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } -// buildCatalogInputs lists all catalogs and builds processor inputs -func (r *DevHubPluginCatalogReconciler) buildCatalogInputs(ctx context.Context) ([]catalog.CatalogInput, error) { - catalogList := &api.DevHubPluginCatalogList{} - if err := r.List(ctx, catalogList); err != nil { - return nil, fmt.Errorf("failed to list catalogs: %w", err) - } - +// buildCatalogInputs builds processor inputs from the catalog list +func (r *DevHubPluginCatalogReconciler) buildCatalogInputs(ctx context.Context, catalogList *api.DevHubPluginCatalogList) ([]catalog.CatalogInput, error) { inputs := make([]catalog.CatalogInput, 0, len(catalogList.Items)) for i := range catalogList.Items { diff --git a/internal/controller/devhubplugincatalog_controller_test.go b/internal/controller/devhubplugincatalog_controller_test.go index 858e4a859..e47c90ca5 100644 --- a/internal/controller/devhubplugincatalog_controller_test.go +++ b/internal/controller/devhubplugincatalog_controller_test.go @@ -37,10 +37,19 @@ func setupCatalogTestReconciler(objects ...client.Object) *DevHubPluginCatalogRe } } +// listAndBuildInputs is a test helper that lists catalogs and builds inputs +func listAndBuildInputs(r *DevHubPluginCatalogReconciler, ctx context.Context) ([]catalog.CatalogInput, error) { + catalogList := &api.DevHubPluginCatalogList{} + if err := r.List(ctx, catalogList); err != nil { + return nil, err + } + return r.buildCatalogInputs(ctx, catalogList) +} + func TestBuildCatalogInputs_NoCatalogs(t *testing.T) { r := setupCatalogTestReconciler() - inputs, err := r.buildCatalogInputs(context.TODO()) + inputs, err := listAndBuildInputs(r, context.TODO()) require.NoError(t, err) assert.Empty(t, inputs) } @@ -59,7 +68,7 @@ func TestBuildCatalogInputs_SingleCatalog(t *testing.T) { r := setupCatalogTestReconciler(dhpc) - inputs, err := r.buildCatalogInputs(context.TODO()) + inputs, err := listAndBuildInputs(r, context.TODO()) require.NoError(t, err) require.Len(t, inputs, 1) assert.Equal(t, "oci://registry.example.com/rhdh/plugin-catalog:v1", inputs[0].Ref) @@ -83,7 +92,7 @@ func TestBuildCatalogInputs_WithSkipTLSVerify(t *testing.T) { r := setupCatalogTestReconciler(dhpc) - inputs, err := r.buildCatalogInputs(context.TODO()) + inputs, err := listAndBuildInputs(r, context.TODO()) require.NoError(t, err) require.Len(t, inputs, 1) assert.True(t, inputs[0].SkipTLSVerify) @@ -116,7 +125,7 @@ func TestBuildCatalogInputs_WithPullSecret(t *testing.T) { r := setupCatalogTestReconciler(dhpc, secret) - inputs, err := r.buildCatalogInputs(context.TODO()) + inputs, err := listAndBuildInputs(r, context.TODO()) require.NoError(t, err) require.Len(t, inputs, 1) assert.NotNil(t, inputs[0].DockerConfig) @@ -157,7 +166,7 @@ MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl r := setupCatalogTestReconciler(dhpc, cm) - inputs, err := r.buildCatalogInputs(context.TODO()) + inputs, err := listAndBuildInputs(r, context.TODO()) require.NoError(t, err) require.Len(t, inputs, 1) assert.NotNil(t, inputs[0].CACert) @@ -198,7 +207,7 @@ MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl r := setupCatalogTestReconciler(dhpc, cm) - inputs, err := r.buildCatalogInputs(context.TODO()) + inputs, err := listAndBuildInputs(r, context.TODO()) require.NoError(t, err) require.Len(t, inputs, 1) assert.NotNil(t, inputs[0].CACert) @@ -229,7 +238,7 @@ func TestBuildCatalogInputs_MultipleCatalogs(t *testing.T) { r := setupCatalogTestReconciler(dhpc1, dhpc2) - inputs, err := r.buildCatalogInputs(context.TODO()) + inputs, err := listAndBuildInputs(r, context.TODO()) require.NoError(t, err) assert.Len(t, inputs, 2) } @@ -251,7 +260,7 @@ func TestBuildCatalogInputs_MissingSecret_Fails(t *testing.T) { r := setupCatalogTestReconciler(dhpc) - _, err := r.buildCatalogInputs(context.TODO()) + _, err := listAndBuildInputs(r, context.TODO()) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to get pull secret") assert.Contains(t, err.Error(), "non-existent-secret") @@ -276,7 +285,7 @@ func TestBuildCatalogInputs_MissingCACert_Fails(t *testing.T) { r := setupCatalogTestReconciler(dhpc) - _, err := r.buildCatalogInputs(context.TODO()) + _, err := listAndBuildInputs(r, context.TODO()) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to get CA ConfigMap") assert.Contains(t, err.Error(), "non-existent-ca") From 954331944fd7e3c67b1fb0e57714631d4fb8a34e Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 27 Aug 2026 11:48:07 +0300 Subject: [PATCH 17/28] fixes --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- ...kstage-operator.clusterserviceversion.yaml | 2 +- ...kstage-operator.clusterserviceversion.yaml | 2 +- docs/dynamic-plugins.md | 7 +- .../devhubplugincatalog_controller.go | 123 +++++---- .../devhubplugincatalog_controller_test.go | 121 ++++----- pkg/catalog/processor.go | 77 +++--- pkg/catalog/processor_test.go | 235 ++++++++---------- pkg/model/dynamic-plugins.go | 5 +- 13 files changed, 259 insertions(+), 323 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ca4d7a346..9b7acdd38 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 1357c08bd..c3ecd6b25 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 6613a8896..b315b789e 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index 9c86c46ee..c9c126a0f 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index c029c6140..b3805d13e 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -6,7 +6,7 @@ package v1alpha5 import ( corev1 "k8s.io/api/core/v1" - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 365583511..2d43b2eeb 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-26T15:55:42Z" + createdAt: "2026-08-27T08:47:22Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index fc195bfb0..f81072114 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-26T15:55:38Z" + createdAt: "2026-08-27T08:47:19Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/docs/dynamic-plugins.md b/docs/dynamic-plugins.md index 3c6cc52d3..f63365704 100644 --- a/docs/dynamic-plugins.md +++ b/docs/dynamic-plugins.md @@ -72,7 +72,12 @@ spec: Create additional `DevHubPluginCatalog` resources to add more plugin sources. The operator merges all catalogs automatically. -**Important**: Plugin names must be unique across all catalogs. Duplicate plugin names will cause a reconciliation error. +**Merge Strategy**: +- Plugin entries are **appended** in catalog processing order (alphabetical by resource name) +- Plugin objects are **not recursively merged** - each plugin entry is treated as a complete unit +- **Duplicate plugin names fail reconciliation** - if the same plugin name appears in multiple catalogs, the operator reports an error rather than silently overriding + +This means you cannot override a plugin's configuration by defining it in a second catalog. To customize a plugin from the default catalog, exclude it and provide your own complete plugin entry. #### Private Registry Authentication diff --git a/internal/controller/devhubplugincatalog_controller.go b/internal/controller/devhubplugincatalog_controller.go index d16e5bed9..d0179858a 100644 --- a/internal/controller/devhubplugincatalog_controller.go +++ b/internal/controller/devhubplugincatalog_controller.go @@ -50,83 +50,77 @@ func (r *DevHubPluginCatalogReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, fmt.Errorf("failed to list catalogs: %w", err) } - // 2. Build inputs from catalogs - inputs, err := r.buildCatalogInputs(ctx, catalogList) - if err != nil { - return ctrl.Result{}, err - } - - // 3. Set all catalogs to Processing (if any exist) - if len(catalogList.Items) > 0 { - r.setAllConditions(ctx, catalogList, metav1.ConditionFalse, v1alpha5.ConditionReasonProcessing, "Processing catalog") - } + // 2. Process each catalog + plugins := make(catalog.PluginMap) + for i := range catalogList.Items { + dhpc := &catalogList.Items[i] + r.setCondition(ctx, dhpc, metav1.ConditionFalse, v1alpha5.ConditionReasonProcessing, "Processing catalog") - // 4. Process catalogs (handles empty input → empty plugins list) - content, err := r.Processor.Process(ctx, inputs) - if err != nil { - r.setAllConditions(ctx, catalogList, metav1.ConditionFalse, v1alpha5.ConditionReasonFailed, err.Error()) - return ctrl.Result{}, fmt.Errorf("failed to process catalogs: %w", err) + input, err := r.buildCatalogInput(ctx, dhpc) + if err != nil { + r.setCondition(ctx, dhpc, metav1.ConditionFalse, v1alpha5.ConditionReasonFailed, err.Error()) + return ctrl.Result{}, err + } + if err := r.Processor.AddCatalog(ctx, input, plugins); err != nil { + r.setCondition(ctx, dhpc, metav1.ConditionFalse, v1alpha5.ConditionReasonFailed, err.Error()) + return ctrl.Result{}, fmt.Errorf("failed to process catalog %q: %w", dhpc.Name, err) + } } - // 5. Apply merged ConfigMap (clears old config if no catalogs) - if err := r.applyConfigMap(ctx, content); err != nil { + // 3. Apply merged ConfigMap (clears old config if no catalogs) + if err := r.applyConfigMap(ctx, plugins); err != nil { r.setAllConditions(ctx, catalogList, metav1.ConditionFalse, v1alpha5.ConditionReasonFailed, err.Error()) return ctrl.Result{}, fmt.Errorf("failed to apply ConfigMap: %w", err) } - // 6. Set all catalogs to Ready (if any exist) - if len(catalogList.Items) > 0 { - r.setAllConditions(ctx, catalogList, metav1.ConditionTrue, v1alpha5.ConditionReasonSucceeded, "Catalog processed successfully") - } + // 4. Set all catalogs to Ready + r.setAllConditions(ctx, catalogList, metav1.ConditionTrue, v1alpha5.ConditionReasonSucceeded, "Catalog processed successfully") - lg.Info("Reconciled DevHubPluginCatalogs", "count", len(inputs)) + lg.Info("Reconciled DevHubPluginCatalogs", "count", len(catalogList.Items)) return ctrl.Result{}, nil } -// buildCatalogInputs builds processor inputs from the catalog list -func (r *DevHubPluginCatalogReconciler) buildCatalogInputs(ctx context.Context, catalogList *api.DevHubPluginCatalogList) ([]catalog.CatalogInput, error) { - inputs := make([]catalog.CatalogInput, 0, len(catalogList.Items)) - - for i := range catalogList.Items { - dhpc := &catalogList.Items[i] +// buildCatalogInput builds a processor input from a single catalog +func (r *DevHubPluginCatalogReconciler) buildCatalogInput(ctx context.Context, dhpc *api.DevHubPluginCatalog) (catalog.CatalogInput, error) { + input := catalog.CatalogInput{ + Ref: dhpc.Spec.Source.Ref, + SkipTLSVerify: dhpc.Spec.Source.SkipTLSVerify, + } - input := catalog.CatalogInput{ - Ref: dhpc.Spec.Source.Ref, - SkipTLSVerify: dhpc.Spec.Source.SkipTLSVerify, + // Get pull secret + if dhpc.Spec.Source.PullSecret != nil { + secret := &corev1.Secret{} + key := types.NamespacedName{Name: dhpc.Spec.Source.PullSecret.Name, Namespace: r.OperatorNamespace} + if err := r.Get(ctx, key, secret); err != nil { + return input, fmt.Errorf("failed to get pull secret %q for catalog %q: %w", dhpc.Spec.Source.PullSecret.Name, dhpc.Name, err) } + input.DockerConfig = secret.Data[".dockerconfigjson"] + } - // Get pull secret - if dhpc.Spec.Source.PullSecret != nil { - secret := &corev1.Secret{} - key := types.NamespacedName{Name: dhpc.Spec.Source.PullSecret.Name, Namespace: r.OperatorNamespace} - if err := r.Get(ctx, key, secret); err != nil { - return nil, fmt.Errorf("failed to get pull secret %q for catalog %q: %w", dhpc.Spec.Source.PullSecret.Name, dhpc.Name, err) - } - input.DockerConfig = secret.Data[".dockerconfigjson"] + // Get CA certificate + if dhpc.Spec.Source.CertificateAuthority != nil { + cm := &corev1.ConfigMap{} + key := types.NamespacedName{Name: dhpc.Spec.Source.CertificateAuthority.Name, Namespace: r.OperatorNamespace} + if err := r.Get(ctx, key, cm); err != nil { + return input, fmt.Errorf("failed to get CA ConfigMap %q for catalog %q: %w", dhpc.Spec.Source.CertificateAuthority.Name, dhpc.Name, err) } - - // Get CA certificate - if dhpc.Spec.Source.CertificateAuthority != nil { - cm := &corev1.ConfigMap{} - key := types.NamespacedName{Name: dhpc.Spec.Source.CertificateAuthority.Name, Namespace: r.OperatorNamespace} - if err := r.Get(ctx, key, cm); err != nil { - return nil, fmt.Errorf("failed to get CA ConfigMap %q for catalog %q: %w", dhpc.Spec.Source.CertificateAuthority.Name, dhpc.Name, err) - } - caKey := dhpc.Spec.Source.CertificateAuthority.Key - if caKey == "" { - caKey = "ca.crt" - } - input.CACert = []byte(cm.Data[caKey]) + caKey := dhpc.Spec.Source.CertificateAuthority.Key + if caKey == "" { + caKey = "ca.crt" } - - inputs = append(inputs, input) + input.CACert = []byte(cm.Data[caKey]) } - return inputs, nil + return input, nil } -// applyConfigMap patches the default-config ConfigMap with pre-built patch bytes. -func (r *DevHubPluginCatalogReconciler) applyConfigMap(ctx context.Context, patchBytes []byte) error { +// applyConfigMap builds patch from plugins and applies it to the default-config ConfigMap. +func (r *DevHubPluginCatalogReconciler) applyConfigMap(ctx context.Context, plugins catalog.PluginMap) error { + patchBytes, err := r.Processor.BuildPatch(plugins) + if err != nil { + return fmt.Errorf("failed to build patch: %w", err) + } + cm := &corev1.ConfigMap{} cm.Name = DefaultConfigMapName cm.Namespace = r.OperatorNamespace @@ -141,17 +135,15 @@ func (r *DevHubPluginCatalogReconciler) applyConfigMap(ctx context.Context, patc // setAllConditions sets the Ready condition on all catalogs in the list func (r *DevHubPluginCatalogReconciler) setAllConditions(ctx context.Context, catalogList *api.DevHubPluginCatalogList, status metav1.ConditionStatus, reason, message string) { - lg := log.FromContext(ctx) for i := range catalogList.Items { - if err := r.setCondition(ctx, &catalogList.Items[i], status, reason, message); err != nil { - lg.Error(err, "Failed to set condition", "catalog", catalogList.Items[i].Name) - } + r.setCondition(ctx, &catalogList.Items[i], status, reason, message) } } -// setCondition sets the Ready condition on a single catalog with retry on conflict -func (r *DevHubPluginCatalogReconciler) setCondition(ctx context.Context, dhpc *api.DevHubPluginCatalog, status metav1.ConditionStatus, reason, message string) error { - return retry.RetryOnConflict(retry.DefaultRetry, func() error { +// setCondition sets the Ready condition on a single catalog with retry on conflict. +// Errors are logged internally; controller will retry on next reconciliation. +func (r *DevHubPluginCatalogReconciler) setCondition(ctx context.Context, dhpc *api.DevHubPluginCatalog, status metav1.ConditionStatus, reason, message string) { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { // Re-fetch the catalog to get the latest resourceVersion fresh := &api.DevHubPluginCatalog{} if err := r.Get(ctx, types.NamespacedName{Name: dhpc.Name, Namespace: dhpc.Namespace}, fresh); err != nil { @@ -188,6 +180,9 @@ func (r *DevHubPluginCatalogReconciler) setCondition(ctx context.Context, dhpc * return r.Status().Update(ctx, fresh) }) + if err != nil { + log.FromContext(ctx).Error(err, "Failed to set condition", "catalog", dhpc.Name, "reason", reason) + } } // SetupWithManager sets up the controller with the Manager diff --git a/internal/controller/devhubplugincatalog_controller_test.go b/internal/controller/devhubplugincatalog_controller_test.go index e47c90ca5..eaa42f891 100644 --- a/internal/controller/devhubplugincatalog_controller_test.go +++ b/internal/controller/devhubplugincatalog_controller_test.go @@ -43,10 +43,18 @@ func listAndBuildInputs(r *DevHubPluginCatalogReconciler, ctx context.Context) ( if err := r.List(ctx, catalogList); err != nil { return nil, err } - return r.buildCatalogInputs(ctx, catalogList) + var inputs []catalog.CatalogInput + for i := range catalogList.Items { + input, err := r.buildCatalogInput(ctx, &catalogList.Items[i]) + if err != nil { + return nil, err + } + inputs = append(inputs, input) + } + return inputs, nil } -func TestBuildCatalogInputs_NoCatalogs(t *testing.T) { +func TestBuildCatalogInput_NoCatalogs(t *testing.T) { r := setupCatalogTestReconciler() inputs, err := listAndBuildInputs(r, context.TODO()) @@ -54,7 +62,7 @@ func TestBuildCatalogInputs_NoCatalogs(t *testing.T) { assert.Empty(t, inputs) } -func TestBuildCatalogInputs_SingleCatalog(t *testing.T) { +func TestBuildCatalogInput_SingleCatalog(t *testing.T) { dhpc := &api.DevHubPluginCatalog{ ObjectMeta: metav1.ObjectMeta{ Name: "test-catalog", @@ -77,7 +85,7 @@ func TestBuildCatalogInputs_SingleCatalog(t *testing.T) { assert.Nil(t, inputs[0].CACert) } -func TestBuildCatalogInputs_WithSkipTLSVerify(t *testing.T) { +func TestBuildCatalogInput_WithSkipTLSVerify(t *testing.T) { dhpc := &api.DevHubPluginCatalog{ ObjectMeta: metav1.ObjectMeta{ Name: "test-catalog", @@ -98,7 +106,7 @@ func TestBuildCatalogInputs_WithSkipTLSVerify(t *testing.T) { assert.True(t, inputs[0].SkipTLSVerify) } -func TestBuildCatalogInputs_WithPullSecret(t *testing.T) { +func TestBuildCatalogInput_WithPullSecret(t *testing.T) { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "registry-creds", @@ -132,7 +140,7 @@ func TestBuildCatalogInputs_WithPullSecret(t *testing.T) { assert.Contains(t, string(inputs[0].DockerConfig), "registry.example.com") } -func TestBuildCatalogInputs_WithCACert(t *testing.T) { +func TestBuildCatalogInput_WithCACert(t *testing.T) { caCert := `-----BEGIN CERTIFICATE----- MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl -----END CERTIFICATE-----` @@ -173,7 +181,7 @@ MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl assert.Contains(t, string(inputs[0].CACert), "BEGIN CERTIFICATE") } -func TestBuildCatalogInputs_WithCACert_DefaultKey(t *testing.T) { +func TestBuildCatalogInput_WithCACert_DefaultKey(t *testing.T) { caCert := `-----BEGIN CERTIFICATE----- MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl -----END CERTIFICATE-----` @@ -213,7 +221,7 @@ MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl assert.NotNil(t, inputs[0].CACert) } -func TestBuildCatalogInputs_MultipleCatalogs(t *testing.T) { +func TestBuildCatalogInput_MultipleCatalogs(t *testing.T) { dhpc1 := &api.DevHubPluginCatalog{ ObjectMeta: metav1.ObjectMeta{ Name: "catalog-1", @@ -243,7 +251,7 @@ func TestBuildCatalogInputs_MultipleCatalogs(t *testing.T) { assert.Len(t, inputs, 2) } -func TestBuildCatalogInputs_MissingSecret_Fails(t *testing.T) { +func TestBuildCatalogInput_MissingSecret_Fails(t *testing.T) { dhpc := &api.DevHubPluginCatalog{ ObjectMeta: metav1.ObjectMeta{ Name: "test-catalog", @@ -266,7 +274,7 @@ func TestBuildCatalogInputs_MissingSecret_Fails(t *testing.T) { assert.Contains(t, err.Error(), "non-existent-secret") } -func TestBuildCatalogInputs_MissingCACert_Fails(t *testing.T) { +func TestBuildCatalogInput_MissingCACert_Fails(t *testing.T) { dhpc := &api.DevHubPluginCatalog{ ObjectMeta: metav1.ObjectMeta{ Name: "test-catalog", @@ -291,7 +299,7 @@ func TestBuildCatalogInputs_MissingCACert_Fails(t *testing.T) { assert.Contains(t, err.Error(), "non-existent-ca") } -func TestApplyConfigMap_WithExistingDynamicPlugins(t *testing.T) { +func TestApplyConfigMap_WithPlugins(t *testing.T) { // Simulate default-config ConfigMap with existing dynamic-plugins.yaml existingCM := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -309,16 +317,18 @@ func TestApplyConfigMap_WithExistingDynamicPlugins(t *testing.T) { r := setupCatalogTestReconciler(existingCM) - // Patch with new plugins from catalog - newPluginsYAML := `plugins: - - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" - disabled: false - - package: "oci://registry.example.com/rhdh/plugin-kubernetes:2.0" - disabled: false -` - patchBytes := []byte(`{"data":{"dynamic-plugins.yaml":"` + escapeJSONString(newPluginsYAML) + `",".catalogs-ready":"true"}}`) + // Create plugins map + plugins := make(catalog.PluginMap) + plugins["plugin-techdocs"] = model.DynaPlugin{ + Package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0", + Disabled: false, + } + plugins["plugin-kubernetes"] = model.DynaPlugin{ + Package: "oci://registry.example.com/rhdh/plugin-kubernetes:2.0", + Disabled: false, + } - err := r.applyConfigMap(context.TODO(), patchBytes) + err := r.applyConfigMap(context.TODO(), plugins) require.NoError(t, err) // Verify the ConfigMap was patched @@ -329,33 +339,22 @@ func TestApplyConfigMap_WithExistingDynamicPlugins(t *testing.T) { }, cm) require.NoError(t, err) - // Verify dynamic-plugins.yaml was replaced with new content + // Verify dynamic-plugins.yaml was replaced assert.Contains(t, cm.Data, "dynamic-plugins.yaml") dpContent := cm.Data["dynamic-plugins.yaml"] - // Content must be parseable as DynaPluginsConfig - var config model.DynaPluginsConfig - err = yaml.Unmarshal([]byte(dpContent), &config) - require.NoError(t, err, "dynamic-plugins.yaml should be parseable as DynaPluginsConfig") - - // Verify plugins from catalog are present - assert.Len(t, config.Plugins, 2) - assert.Equal(t, "oci://registry.example.com/rhdh/plugin-techdocs:1.0", config.Plugins[0].Package) - assert.Equal(t, "oci://registry.example.com/rhdh/plugin-kubernetes:2.0", config.Plugins[1].Package) - - // Verify old plugin is NOT present (was replaced) - for _, p := range config.Plugins { - assert.NotContains(t, p.Package, "old-registry.com") - } - - // Verify catalogs-ready marker - assert.Equal(t, "true", cm.Data[".catalogs-ready"]) + // Content should be a ConfigMap YAML + var configMap map[string]interface{} + err = yaml.Unmarshal([]byte(dpContent), &configMap) + require.NoError(t, err, "dynamic-plugins.yaml should be a ConfigMap YAML") + assert.Equal(t, "v1", configMap["apiVersion"]) + assert.Equal(t, "ConfigMap", configMap["kind"]) // Verify other keys are preserved assert.Equal(t, "app:\n title: My Backstage\n", cm.Data["app-config.yaml"]) } -func TestApplyConfigMap_WithoutExistingDynamicPlugins(t *testing.T) { +func TestApplyConfigMap_EmptyPlugins(t *testing.T) { // Simulate default-config ConfigMap WITHOUT dynamic-plugins.yaml existingCM := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -369,14 +368,10 @@ func TestApplyConfigMap_WithoutExistingDynamicPlugins(t *testing.T) { r := setupCatalogTestReconciler(existingCM) - // Patch with plugins from catalog - newPluginsYAML := `plugins: - - package: "oci://registry.example.com/rhdh/plugin-argocd:1.0" - disabled: false -` - patchBytes := []byte(`{"data":{"dynamic-plugins.yaml":"` + escapeJSONString(newPluginsYAML) + `",".catalogs-ready":"true"}}`) + // Empty plugins map + plugins := make(catalog.PluginMap) - err := r.applyConfigMap(context.TODO(), patchBytes) + err := r.applyConfigMap(context.TODO(), plugins) require.NoError(t, err) // Verify the ConfigMap was patched @@ -387,45 +382,13 @@ func TestApplyConfigMap_WithoutExistingDynamicPlugins(t *testing.T) { }, cm) require.NoError(t, err) - // Verify dynamic-plugins.yaml was created + // Verify dynamic-plugins.yaml was created with empty plugins assert.Contains(t, cm.Data, "dynamic-plugins.yaml") - dpContent := cm.Data["dynamic-plugins.yaml"] - - // Content must be parseable as DynaPluginsConfig - var config model.DynaPluginsConfig - err = yaml.Unmarshal([]byte(dpContent), &config) - require.NoError(t, err, "dynamic-plugins.yaml should be parseable as DynaPluginsConfig") - - // Verify plugin from catalog is present - assert.Len(t, config.Plugins, 1) - assert.Equal(t, "oci://registry.example.com/rhdh/plugin-argocd:1.0", config.Plugins[0].Package) - - // Verify catalogs-ready marker - assert.Equal(t, "true", cm.Data[".catalogs-ready"]) // Verify other keys are preserved assert.Equal(t, "app:\n title: My Backstage\n", cm.Data["app-config.yaml"]) } -// escapeJSONString escapes a string for inclusion in a JSON string value -func escapeJSONString(s string) string { - // Replace newlines and quotes for JSON embedding - result := "" - for _, c := range s { - switch c { - case '\n': - result += "\\n" - case '"': - result += "\\\"" - case '\\': - result += "\\\\" - default: - result += string(c) - } - } - return result -} - func TestConstants(t *testing.T) { assert.Equal(t, "rhdh-default-config", DefaultConfigMapName) } diff --git a/pkg/catalog/processor.go b/pkg/catalog/processor.go index 080306775..1f927c5b2 100644 --- a/pkg/catalog/processor.go +++ b/pkg/catalog/processor.go @@ -35,26 +35,49 @@ type CatalogInput struct { SkipTLSVerify bool } -// Process fetches all catalogs, merges them, and returns JSON patch bytes ready to apply -func (p *Processor) Process(ctx context.Context, catalogs []CatalogInput) ([]byte, error) { - var allContent [][]byte - - for _, cat := range catalogs { - content, err := p.fetch(ctx, cat) - if err != nil { - return nil, fmt.Errorf("failed to fetch catalog %s: %w", cat.Ref, err) +// PluginMap maps plugin names to plugins for duplicate detection +type PluginMap map[string]model.DynaPlugin + +// AddCatalog fetches a single catalog and adds its plugins to the map, checking for duplicates +func (p *Processor) AddCatalog(ctx context.Context, catalog CatalogInput, plugins PluginMap) error { + content, err := p.fetch(ctx, catalog) + if err != nil { + return fmt.Errorf("failed to fetch catalog %s: %w", catalog.Ref, err) + } + + var config model.DynaPluginsConfig + if err := yaml.Unmarshal(content, &config); err != nil { + return fmt.Errorf("failed to parse catalog %s: %w", catalog.Ref, err) + } + + for _, plugin := range config.Plugins { + name := plugin.Name() + if name == "" { + return fmt.Errorf("invalid plugin package %q: cannot extract plugin name", plugin.Package) + } + if existing, exists := plugins[name]; exists { + return fmt.Errorf("duplicate plugin name %q: found in both %q and %q", name, existing.Package, plugin.Package) } - allContent = append(allContent, content) + plugins[name] = plugin } - merged, err := p.merge(allContent) + return nil +} + +// BuildPatch creates a JSON patch for the default-config ConfigMap from accumulated plugins +func (p *Processor) BuildPatch(plugins PluginMap) ([]byte, error) { + // Convert map to slice + list := make([]model.DynaPlugin, 0, len(plugins)) + for _, plugin := range plugins { + list = append(list, plugin) + } + + merged, err := yaml.Marshal(model.DynaPluginsConfig{Plugins: list}) if err != nil { - return nil, fmt.Errorf("failed to merge catalogs: %w", err) + return nil, fmt.Errorf("failed to marshal plugins: %w", err) } // Build the inner ConfigMap that will be stored as the value in default-config. - // The default-config ConfigMap stores Kubernetes manifests as file values, - // so each value must be a complete YAML document (apiVersion, kind, etc.) innerConfigMap := map[string]interface{}{ "apiVersion": "v1", "kind": "ConfigMap", @@ -117,31 +140,3 @@ func (p *Processor) fetch(ctx context.Context, cat CatalogInput) ([]byte, error) return content, nil } - -// merge combines multiple catalog contents, validating no duplicate plugin names exist. -// Plugins are identified by name (last path segment of the package URL, without tag/digest). -// Returns an error if duplicate plugin names are found across catalogs. -func (p *Processor) merge(catalogs [][]byte) ([]byte, error) { - var allPlugins []model.DynaPlugin - seen := make(map[string]string) // plugin name -> package URL (for error reporting) - - for _, content := range catalogs { - var catalog model.DynaPluginsConfig - if err := yaml.Unmarshal(content, &catalog); err != nil { - return nil, fmt.Errorf("failed to parse catalog: %w", err) - } - for _, plugin := range catalog.Plugins { - name := plugin.Name() - if name == "" { - return nil, fmt.Errorf("invalid plugin package %q: cannot extract plugin name", plugin.Package) - } - if existingPkg, exists := seen[name]; exists { - return nil, fmt.Errorf("duplicate plugin name %q: found in both %q and %q", name, existingPkg, plugin.Package) - } - allPlugins = append(allPlugins, plugin) - seen[name] = plugin.Package - } - } - - return yaml.Marshal(model.DynaPluginsConfig{Plugins: allPlugins}) -} diff --git a/pkg/catalog/processor_test.go b/pkg/catalog/processor_test.go index 1661cbc60..528774ef7 100644 --- a/pkg/catalog/processor_test.go +++ b/pkg/catalog/processor_test.go @@ -11,13 +11,42 @@ import ( "github.com/redhat-developer/rhdh-operator/pkg/model" ) +// addPluginsFromYAML is a test helper that parses YAML and adds plugins to the map +func addPluginsFromYAML(t *testing.T, plugins PluginMap, content string) error { + var config model.DynaPluginsConfig + if err := yaml.Unmarshal([]byte(content), &config); err != nil { + return err + } + for _, plugin := range config.Plugins { + name := plugin.Name() + if name == "" { + t.Fatalf("invalid plugin package %q: cannot extract plugin name", plugin.Package) + } + if existing, exists := plugins[name]; exists { + return &duplicatePluginError{name: name, existingPkg: existing.Package, newPkg: plugin.Package} + } + plugins[name] = plugin + } + return nil +} + +type duplicatePluginError struct { + name string + existingPkg string + newPkg string +} + +func (e *duplicatePluginError) Error() string { + return "duplicate plugin name " + e.name + ": found in both " + e.existingPkg + " and " + e.newPkg +} + func TestNewProcessor(t *testing.T) { p := NewProcessor() assert.NotNil(t, p) } -func TestMerge_SingleCatalog(t *testing.T) { - p := NewProcessor() +func TestPluginMap_SingleCatalog(t *testing.T) { + plugins := make(PluginMap) catalog := `plugins: - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" @@ -25,18 +54,13 @@ func TestMerge_SingleCatalog(t *testing.T) { - package: "oci://registry.example.com/rhdh/plugin-kubernetes:1.0" disabled: false ` - - result, err := p.merge([][]byte{[]byte(catalog)}) + err := addPluginsFromYAML(t, plugins, catalog) require.NoError(t, err) - - var merged model.DynaPluginsConfig - require.NoError(t, yaml.Unmarshal(result, &merged)) - - assert.Len(t, merged.Plugins, 2) + assert.Len(t, plugins, 2) } -func TestMerge_MultipleCatalogs(t *testing.T) { - p := NewProcessor() +func TestPluginMap_MultipleCatalogs(t *testing.T) { + plugins := make(PluginMap) catalog1 := `plugins: - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" @@ -44,25 +68,19 @@ func TestMerge_MultipleCatalogs(t *testing.T) { - package: "oci://registry.example.com/rhdh/plugin-kubernetes:1.0" disabled: false ` - catalog2 := `plugins: - package: "oci://registry.example.com/rhdh/plugin-argocd:1.0" disabled: false - package: "oci://registry.example.com/rhdh/plugin-search:1.0" disabled: false ` - - result, err := p.merge([][]byte{[]byte(catalog1), []byte(catalog2)}) - require.NoError(t, err) - - var merged model.DynaPluginsConfig - require.NoError(t, yaml.Unmarshal(result, &merged)) - - assert.Len(t, merged.Plugins, 4) + require.NoError(t, addPluginsFromYAML(t, plugins, catalog1)) + require.NoError(t, addPluginsFromYAML(t, plugins, catalog2)) + assert.Len(t, plugins, 4) } -func TestMerge_DuplicateSameURL_Fails(t *testing.T) { - p := NewProcessor() +func TestPluginMap_DuplicateSameURL_Fails(t *testing.T) { + plugins := make(PluginMap) catalog := `plugins: - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" @@ -70,15 +88,14 @@ func TestMerge_DuplicateSameURL_Fails(t *testing.T) { - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" disabled: true ` - - _, err := p.merge([][]byte{[]byte(catalog)}) + err := addPluginsFromYAML(t, plugins, catalog) assert.Error(t, err) assert.Contains(t, err.Error(), "duplicate plugin name") assert.Contains(t, err.Error(), "plugin-techdocs") } -func TestMerge_DuplicateDifferentURL_Fails(t *testing.T) { - p := NewProcessor() +func TestPluginMap_DuplicateDifferentURL_Fails(t *testing.T) { + plugins := make(PluginMap) // Same plugin name but different registries/tags catalog := `plugins: @@ -87,72 +104,42 @@ func TestMerge_DuplicateDifferentURL_Fails(t *testing.T) { - package: "oci://other-registry.com/path/plugin-techdocs@sha256:abc123" disabled: false ` - - _, err := p.merge([][]byte{[]byte(catalog)}) + err := addPluginsFromYAML(t, plugins, catalog) assert.Error(t, err) assert.Contains(t, err.Error(), "duplicate plugin name") assert.Contains(t, err.Error(), "plugin-techdocs") } -func TestMerge_DuplicateAcrossCatalogs_Fails(t *testing.T) { - p := NewProcessor() +func TestPluginMap_DuplicateAcrossCatalogs_Fails(t *testing.T) { + plugins := make(PluginMap) catalog1 := `plugins: - package: "oci://registry.example.com/rhdh/plugin-techdocs:1.0" disabled: false ` - catalog2 := `plugins: - package: "oci://other-registry.com/rhdh/plugin-techdocs:2.0" disabled: false ` - - _, err := p.merge([][]byte{[]byte(catalog1), []byte(catalog2)}) + require.NoError(t, addPluginsFromYAML(t, plugins, catalog1)) + err := addPluginsFromYAML(t, plugins, catalog2) assert.Error(t, err) assert.Contains(t, err.Error(), "duplicate plugin name") assert.Contains(t, err.Error(), "plugin-techdocs") } -func TestMerge_EmptyCatalog(t *testing.T) { - p := NewProcessor() +func TestPluginMap_EmptyCatalog(t *testing.T) { + plugins := make(PluginMap) catalog := `plugins: []` - result, err := p.merge([][]byte{[]byte(catalog)}) + err := addPluginsFromYAML(t, plugins, catalog) require.NoError(t, err) - - var merged model.DynaPluginsConfig - require.NoError(t, yaml.Unmarshal(result, &merged)) - - assert.Empty(t, merged.Plugins) -} - -func TestMerge_InvalidYAML(t *testing.T) { - p := NewProcessor() - - invalidYAML := `this is not: valid: yaml: [}` - - _, err := p.merge([][]byte{[]byte(invalidYAML)}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse catalog") -} - -func TestMerge_InvalidPluginPackage(t *testing.T) { - p := NewProcessor() - - // Plugin with unsupported URL format that results in empty name - catalog := `plugins: - - package: "invalid-format" - disabled: false -` - - _, err := p.merge([][]byte{[]byte(catalog)}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "cannot extract plugin name") + assert.Empty(t, plugins) } -func TestMerge_PreservesPluginConfig(t *testing.T) { - p := NewProcessor() +func TestPluginMap_PreservesPluginConfig(t *testing.T) { + plugins := make(PluginMap) catalog := `plugins: - package: "oci://registry.example.com/rhdh/plugin-github:1.0" @@ -163,92 +150,82 @@ func TestMerge_PreservesPluginConfig(t *testing.T) { github: organization: my-org ` - - result, err := p.merge([][]byte{[]byte(catalog)}) + err := addPluginsFromYAML(t, plugins, catalog) require.NoError(t, err) - - var merged model.DynaPluginsConfig - require.NoError(t, yaml.Unmarshal(result, &merged)) - - assert.Len(t, merged.Plugins, 1) - assert.NotNil(t, merged.Plugins[0].PluginConfig) + assert.Len(t, plugins, 1) + assert.NotNil(t, plugins["plugin-github"].PluginConfig) } -func TestCatalogInput(t *testing.T) { - input := CatalogInput{ - Ref: "oci://registry.example.com/rhdh/plugin-catalog-index:v1", - DockerConfig: []byte(`{"auths":{}}`), - CACert: []byte("-----BEGIN CERTIFICATE-----"), - SkipTLSVerify: true, +func TestBuildPatch(t *testing.T) { + p := NewProcessor() + plugins := make(PluginMap) + + plugins["plugin-test"] = model.DynaPlugin{ + Package: "oci://registry.example.com/rhdh/plugin-test:1.0", + Disabled: false, } - assert.Equal(t, "oci://registry.example.com/rhdh/plugin-catalog-index:v1", input.Ref) - assert.NotNil(t, input.DockerConfig) - assert.NotNil(t, input.CACert) - assert.True(t, input.SkipTLSVerify) -} + patchBytes, err := p.BuildPatch(plugins) + require.NoError(t, err) -func TestConstants(t *testing.T) { - assert.Equal(t, "dynamic-plugins.default.yaml", CatalogFileName) -} + // Verify JSON structure + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(patchBytes, &parsed)) -func TestProcessOutputFormat(t *testing.T) { - // Verifies the JSON patch structure contains a ConfigMap YAML (not raw plugin config). - // The default-config ConfigMap stores Kubernetes manifests as file values, - // so each value must be a complete ConfigMap YAML with apiVersion, kind, metadata, and data. + data, ok := parsed["data"].(map[string]interface{}) + require.True(t, ok) - // Simulate what Process() outputs - pluginConfig := `plugins: - - package: "oci://registry.example.com/rhdh/plugin-test:1.0" - disabled: false -` - innerConfigMap := map[string]interface{}{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]interface{}{ - "name": "default-dynamic-plugins", - "annotations": map[string]string{ - "rhdh.redhat.com/managed-by": "DevHubPluginCatalog", - }, - }, - "data": map[string]string{ - model.DynamicPluginsFile: pluginConfig, - }, - } + // Verify the content is a ConfigMap YAML + dpContent := data[model.DynamicPluginsFile].(string) + var configMap map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(dpContent), &configMap)) - innerYAML, err := yaml.Marshal(innerConfigMap) - require.NoError(t, err) + assert.Equal(t, "v1", configMap["apiVersion"]) + assert.Equal(t, "ConfigMap", configMap["kind"]) +} - patchData := map[string]interface{}{ - "data": map[string]string{ - model.DynamicPluginsFile: string(innerYAML), - }, - } +func TestBuildPatch_Empty(t *testing.T) { + p := NewProcessor() + plugins := make(PluginMap) - jsonBytes, err := json.Marshal(patchData) + patchBytes, err := p.BuildPatch(plugins) require.NoError(t, err) + // Verify JSON structure var parsed map[string]interface{} - require.NoError(t, json.Unmarshal(jsonBytes, &parsed)) + require.NoError(t, json.Unmarshal(patchBytes, &parsed)) data, ok := parsed["data"].(map[string]interface{}) require.True(t, ok) - // Verify the content is a ConfigMap YAML + // Verify the content is a ConfigMap YAML with empty plugins dpContent := data[model.DynamicPluginsFile].(string) var configMap map[string]interface{} - require.NoError(t, yaml.Unmarshal([]byte(dpContent), &configMap), "dynamic-plugins.yaml content should be a ConfigMap YAML") - - assert.Equal(t, "v1", configMap["apiVersion"]) - assert.Equal(t, "ConfigMap", configMap["kind"]) + require.NoError(t, yaml.Unmarshal([]byte(dpContent), &configMap)) cmData, ok := configMap["data"].(map[interface{}]interface{}) - require.True(t, ok, "ConfigMap should have data field") + require.True(t, ok) - // Verify the inner content is parseable as DynaPluginsConfig innerContent := cmData[model.DynamicPluginsFile].(string) var config model.DynaPluginsConfig require.NoError(t, yaml.Unmarshal([]byte(innerContent), &config)) - assert.Len(t, config.Plugins, 1) - assert.Equal(t, "oci://registry.example.com/rhdh/plugin-test:1.0", config.Plugins[0].Package) + assert.Empty(t, config.Plugins) +} + +func TestCatalogInput(t *testing.T) { + input := CatalogInput{ + Ref: "oci://registry.example.com/rhdh/plugin-catalog-index:v1", + DockerConfig: []byte(`{"auths":{}}`), + CACert: []byte("-----BEGIN CERTIFICATE-----"), + SkipTLSVerify: true, + } + + assert.Equal(t, "oci://registry.example.com/rhdh/plugin-catalog-index:v1", input.Ref) + assert.NotNil(t, input.DockerConfig) + assert.NotNil(t, input.CACert) + assert.True(t, input.SkipTLSVerify) +} + +func TestConstants(t *testing.T) { + assert.Equal(t, "dynamic-plugins.default.yaml", CatalogFileName) } diff --git a/pkg/model/dynamic-plugins.go b/pkg/model/dynamic-plugins.go index 54ee7b26d..9fa4dad2e 100644 --- a/pkg/model/dynamic-plugins.go +++ b/pkg/model/dynamic-plugins.go @@ -145,12 +145,13 @@ func (p *DynamicPlugins) addToModel(model *BackstageModel, backstage api.Backsta packages := []string{} for _, plugin := range pluginsData { if !plugin.IsDisabled() { - p.enabledPlugins = append(p.enabledPlugins, plugin) // Skip local paths - they're built into the image and don't need downloading + // TODO temporary workaround to not to fail until wrappers removed if strings.HasPrefix(plugin.Package, "./") || strings.HasPrefix(plugin.Package, "/") { - klog.V(1).Infof("skipping local path plugin %q (built into image)", plugin.Package) + klog.Warningf("Skipping local path plugin %q (built into image)", plugin.Package) continue } + p.enabledPlugins = append(p.enabledPlugins, plugin) packages = append(packages, plugin.Package) } } From 33938c360359f760de962aa536bfe10756ef822c Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 27 Aug 2026 11:59:06 +0300 Subject: [PATCH 18/28] fixes --- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 2d43b2eeb..8ca694fc7 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-27T08:47:22Z" + createdAt: "2026-08-27T08:58:42Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index f81072114..8a7bb05d6 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-27T08:47:19Z" + createdAt: "2026-08-27T08:58:39Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed From 40f43d9ce96c4ea9ed387987dae41aa90bcad55e Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 27 Aug 2026 12:08:31 +0300 Subject: [PATCH 19/28] add files --- .../rhdh.redhat.com_devhubplugincatalogs.yaml | 186 ++++++++++++++++++ .../rhdh.redhat.com_devhubplugincatalogs.yaml | 186 ++++++++++++++++++ .../rhdh.redhat.com_devhubplugincatalogs.yaml | 180 +++++++++++++++++ config/samples/devhubplugincatalog.yaml | 54 +++++ 4 files changed, 606 insertions(+) create mode 100644 bundle/backstage.io/manifests/rhdh.redhat.com_devhubplugincatalogs.yaml create mode 100644 bundle/rhdh/manifests/rhdh.redhat.com_devhubplugincatalogs.yaml create mode 100644 config/crd/bases/rhdh.redhat.com_devhubplugincatalogs.yaml create mode 100644 config/samples/devhubplugincatalog.yaml diff --git a/bundle/backstage.io/manifests/rhdh.redhat.com_devhubplugincatalogs.yaml b/bundle/backstage.io/manifests/rhdh.redhat.com_devhubplugincatalogs.yaml new file mode 100644 index 000000000..79a094007 --- /dev/null +++ b/bundle/backstage.io/manifests/rhdh.redhat.com_devhubplugincatalogs.yaml @@ -0,0 +1,186 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + creationTimestamp: null + name: devhubplugincatalogs.rhdh.redhat.com +spec: + group: rhdh.redhat.com + names: + kind: DevHubPluginCatalog + listKind: DevHubPluginCatalogList + plural: devhubplugincatalogs + shortNames: + - dhpc + singular: devhubplugincatalog + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.source.ref + name: Ref + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha5 + schema: + openAPIV3Schema: + description: |- + DevHubPluginCatalog is the Schema for the devhubplugincatalogs API. + It defines a source for plugin catalogs that can be fetched from OCI registries. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: DevHubPluginCatalogSpec defines the desired state of DevHubPluginCatalog + properties: + source: + description: Source defines where to fetch the catalog from + properties: + certificateAuthority: + description: CertificateAuthority is a reference to a ConfigMap + key containing CA certificate + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + pullSecret: + description: PullSecret is a reference to a Secret containing + registry credentials + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + ref: + description: |- + Ref is the OCI artifact reference containing the catalog + Example: oci://registry.redhat.io/rhdh/plugin-catalog:1.5 + type: string + skipTLSVerify: + default: false + description: SkipTLSVerify skips TLS certificate verification + (not recommended for production) + type: boolean + required: + - ref + type: object + required: + - source + type: object + status: + description: DevHubPluginCatalogStatus defines the observed state of DevHubPluginCatalog + properties: + conditions: + description: Conditions represent the latest available observations + of the catalog's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/bundle/rhdh/manifests/rhdh.redhat.com_devhubplugincatalogs.yaml b/bundle/rhdh/manifests/rhdh.redhat.com_devhubplugincatalogs.yaml new file mode 100644 index 000000000..79a094007 --- /dev/null +++ b/bundle/rhdh/manifests/rhdh.redhat.com_devhubplugincatalogs.yaml @@ -0,0 +1,186 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + creationTimestamp: null + name: devhubplugincatalogs.rhdh.redhat.com +spec: + group: rhdh.redhat.com + names: + kind: DevHubPluginCatalog + listKind: DevHubPluginCatalogList + plural: devhubplugincatalogs + shortNames: + - dhpc + singular: devhubplugincatalog + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.source.ref + name: Ref + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha5 + schema: + openAPIV3Schema: + description: |- + DevHubPluginCatalog is the Schema for the devhubplugincatalogs API. + It defines a source for plugin catalogs that can be fetched from OCI registries. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: DevHubPluginCatalogSpec defines the desired state of DevHubPluginCatalog + properties: + source: + description: Source defines where to fetch the catalog from + properties: + certificateAuthority: + description: CertificateAuthority is a reference to a ConfigMap + key containing CA certificate + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + pullSecret: + description: PullSecret is a reference to a Secret containing + registry credentials + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + ref: + description: |- + Ref is the OCI artifact reference containing the catalog + Example: oci://registry.redhat.io/rhdh/plugin-catalog:1.5 + type: string + skipTLSVerify: + default: false + description: SkipTLSVerify skips TLS certificate verification + (not recommended for production) + type: boolean + required: + - ref + type: object + required: + - source + type: object + status: + description: DevHubPluginCatalogStatus defines the observed state of DevHubPluginCatalog + properties: + conditions: + description: Conditions represent the latest available observations + of the catalog's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/config/crd/bases/rhdh.redhat.com_devhubplugincatalogs.yaml b/config/crd/bases/rhdh.redhat.com_devhubplugincatalogs.yaml new file mode 100644 index 000000000..076fd303a --- /dev/null +++ b/config/crd/bases/rhdh.redhat.com_devhubplugincatalogs.yaml @@ -0,0 +1,180 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: devhubplugincatalogs.rhdh.redhat.com +spec: + group: rhdh.redhat.com + names: + kind: DevHubPluginCatalog + listKind: DevHubPluginCatalogList + plural: devhubplugincatalogs + shortNames: + - dhpc + singular: devhubplugincatalog + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.source.ref + name: Ref + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha5 + schema: + openAPIV3Schema: + description: |- + DevHubPluginCatalog is the Schema for the devhubplugincatalogs API. + It defines a source for plugin catalogs that can be fetched from OCI registries. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: DevHubPluginCatalogSpec defines the desired state of DevHubPluginCatalog + properties: + source: + description: Source defines where to fetch the catalog from + properties: + certificateAuthority: + description: CertificateAuthority is a reference to a ConfigMap + key containing CA certificate + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + pullSecret: + description: PullSecret is a reference to a Secret containing + registry credentials + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + ref: + description: |- + Ref is the OCI artifact reference containing the catalog + Example: oci://registry.redhat.io/rhdh/plugin-catalog:1.5 + type: string + skipTLSVerify: + default: false + description: SkipTLSVerify skips TLS certificate verification + (not recommended for production) + type: boolean + required: + - ref + type: object + required: + - source + type: object + status: + description: DevHubPluginCatalogStatus defines the observed state of DevHubPluginCatalog + properties: + conditions: + description: Conditions represent the latest available observations + of the catalog's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/samples/devhubplugincatalog.yaml b/config/samples/devhubplugincatalog.yaml new file mode 100644 index 000000000..28f4b4d99 --- /dev/null +++ b/config/samples/devhubplugincatalog.yaml @@ -0,0 +1,54 @@ +# DevHubPluginCatalog defines a source for plugin catalogs fetched from OCI registries. +# The operator fetches catalogs from all DevHubPluginCatalog resources, merges them, +# and makes them available to Backstage instances. +# +# This is a cluster-scoped resource. +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: default-catalog +spec: + source: + # OCI artifact URL containing the plugin catalog + # The artifact must contain a dynamic-plugins.default.yaml file + ref: oci://quay.io/rhdh/plugin-catalog-index:next + +--- +# Example with private registry authentication +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: private-catalog +spec: + source: + ref: oci://registry.example.com/rhdh/plugin-catalog:v1.0 + # Reference to a Secret containing .dockerconfigjson for registry authentication + # The Secret must exist in the operator namespace + pullSecret: + name: private-registry-creds + +--- +# Example with custom CA certificate +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: internal-catalog +spec: + source: + ref: oci://internal-registry.corp.example.com/rhdh/plugin-catalog:latest + # Reference to a ConfigMap containing the CA certificate + # The ConfigMap must exist in the operator namespace + certificateAuthority: + name: internal-ca + key: ca.crt # Optional, defaults to "ca.crt" + +--- +# Example with TLS verification disabled (not recommended for production) +apiVersion: rhdh.redhat.com/v1alpha5 +kind: DevHubPluginCatalog +metadata: + name: dev-catalog +spec: + source: + ref: oci://dev-registry.local:5000/rhdh/plugin-catalog:dev + skipTLSVerify: true From e600ab00c9d4fbd5ef24f8b4abdb571dc571bc38 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 31 Aug 2026 12:58:35 +0300 Subject: [PATCH 20/28] fix --- Makefile | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 6 +++--- dist/rhdh/install.yaml | 2 +- go.sum | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index ce6b65366..f9ad5e1ba 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ PROFILE ?= rhdh # Enable operator dynamic plugins processing (default: true) OPERATOR_DP_PROCESSING ?= true # Install dynamic plugins image (required when OPERATOR_DP_PROCESSING=true) -INSTALL_DP_IMAGE ?= quay.io/rhdh-community/rhdh-plugin-installer:next +RELATED_IMAGE_plugin_installer ?= quay.io/rhdh-community/rhdh-plugin-installer:next PROFILE_SHORT := $(shell echo $(PROFILE) | cut -d. -f1) # VERSION defines the project version for the bundle. diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 8ca694fc7..a9334fa45 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-27T08:58:42Z" + createdAt: "2026-08-31T09:57:05Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index d8df9b9a2..51613314a 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-27T08:58:39Z" + createdAt: "2026-08-31T09:57:01Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed @@ -346,7 +346,7 @@ spec: - name: RELATED_IMAGE_backstage value: quay.io/rhdh-community/rhdh:next - name: RELATED_IMAGE_plugin_installer - value: quay.io/gazarenk/rhdh-plugin-installer:next + value: quay.io/rhdh-community/rhdh-plugin-installer:next - name: POD_NAME valueFrom: fieldRef: @@ -490,7 +490,7 @@ spec: name: postgresql - image: quay.io/rhdh-community/rhdh:next name: backstage - - image: quay.io/gazarenk/rhdh-plugin-installer:next + - image: quay.io/rhdh-community/rhdh-plugin-installer:next name: plugin-installer replaces: rhdh-operator.v1.10.0 version: 2.0.0 diff --git a/dist/rhdh/install.yaml b/dist/rhdh/install.yaml index bd21e384d..bdbefd7f2 100644 --- a/dist/rhdh/install.yaml +++ b/dist/rhdh/install.yaml @@ -4241,7 +4241,7 @@ spec: - name: RELATED_IMAGE_backstage value: quay.io/rhdh-community/rhdh:next - name: RELATED_IMAGE_plugin_installer - value: 'quay.io/gazarenk/rhdh-plugin-installer:next' + value: 'quay.io/rhdh-community/rhdh-plugin-installer:next' - name: POD_NAME valueFrom: fieldRef: diff --git a/go.sum b/go.sum index 3aee05f17..42421bff5 100644 --- a/go.sum +++ b/go.sum @@ -153,8 +153,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/openshift/api v0.0.0-20260826204111-38053d30e7e7 h1:EnJK16NLj/h2BmgP/FX1oUd49D5rAzJLS4oKb7HLHv8= -github.com/openshift/api v0.0.0-20260826204111-38053d30e7e7/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= +github.com/openshift/api v0.0.0-20260827203510-c7d4aa14a764 h1:BHMZiomwq9UhKBKKzngC6bVbR1FPrK+/roKD8YwCTRs= +github.com/openshift/api v0.0.0-20260827203510-c7d4aa14a764/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e h1:k89oIo2EjX0PRSdi1kesktCyWp50SC9WwKurvupvRGs= github.com/openshift/controller-runtime-common v0.0.0-20260428152732-64ee174f5e2e/go.mod h1:XGabTMnNbz0M5Oa7IbscZp/jmcc7aHobvOCUWwkzKvM= github.com/openshift/library-go v0.0.0-20260213153706-03f1709971c5 h1:9Pe6iVOMjt9CdA/vaKBNUSoEIjIe1po5Ha3ABRYXLJI= From d6b839be2aa63e33c6446e515562c877df70037b Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 31 Aug 2026 22:35:02 +0300 Subject: [PATCH 21/28] fix --- api/v1alpha5/groupversion_info.go | 8 ++------ api/v1alpha5/zz_generated.deepcopy.go | 2 +- .../backstage-operator.clusterserviceversion.yaml | 2 +- .../backstage-operator.clusterserviceversion.yaml | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/api/v1alpha5/groupversion_info.go b/api/v1alpha5/groupversion_info.go index ed97c21d0..694e59b0e 100644 --- a/api/v1alpha5/groupversion_info.go +++ b/api/v1alpha5/groupversion_info.go @@ -4,9 +4,8 @@ package v1alpha5 import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" ) var ( @@ -14,10 +13,7 @@ var ( GroupVersion = schema.GroupVersion{Group: "rhdh.redhat.com", Version: "v1alpha5"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme - SchemeBuilder = runtime.NewSchemeBuilder(func(s *runtime.Scheme) error { - metav1.AddToGroupVersion(s, GroupVersion) - return nil - }) + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index c950646a4..b3805d13e 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -8,7 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index a9334fa45..2117804b8 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-31T09:57:05Z" + createdAt: "2026-08-31T19:34:27Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 51613314a..9e2cecd38 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-31T09:57:01Z" + createdAt: "2026-08-31T19:34:24Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed From 0ca33d9e192b96713e92cf1db89aec83f7f71783 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Fri, 4 Sep 2026 14:00:44 +0300 Subject: [PATCH 22/28] fix --- ...kstage-operator.clusterserviceversion.yaml | 2 +- ...kstage-operator.clusterserviceversion.yaml | 2 +- go.mod | 5 ++++- go.sum | 20 +++++++++++++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 2117804b8..4e4c30d1b 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-31T19:34:27Z" + createdAt: "2026-09-04T11:00:16Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 9e2cecd38..802e1d0f7 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-31T19:34:24Z" + createdAt: "2026-09-04T11:00:12Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/go.mod b/go.mod index 91fbbab8a..ca3ec972b 100644 --- a/go.mod +++ b/go.mod @@ -62,7 +62,6 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.30.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -77,12 +76,15 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/openshift/library-go v0.0.0-20260807194649-ee0a87843dda // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -118,6 +120,7 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/apiserver v0.36.4 // indirect k8s.io/component-base v0.36.4 // indirect k8s.io/kube-openapi v0.0.0-20260519202549-bbf5c5577288 // indirect diff --git a/go.sum b/go.sum index 8b05ff1ac..d5599481f 100644 --- a/go.sum +++ b/go.sum @@ -15,10 +15,16 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= @@ -92,6 +98,8 @@ github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4y github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs= +github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -109,8 +117,8 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -137,6 +145,10 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/openshift/api v0.0.0-20260901194050-81278704edb0 h1:9PTDE/0weDetokFkqYdHtZQDcULZhJSm5gNF3GceH4A= github.com/openshift/api v0.0.0-20260901194050-81278704edb0/go.mod h1:k6qH5QOVa5GDln2VVm8Jz4NV3Z7R2SATHFLwGS6Wh3M= github.com/openshift/controller-runtime-common v0.0.0-20260813135806-e1187ec555fc h1:7v9PXV3WR5h8h4x44yfzFKwF0KrdiG6YTKV+26YIcfo= @@ -161,6 +173,8 @@ github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05Zp github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -258,6 +272,8 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.36.4 h1:RxrvqCL6vgH5/+UnTeu1IIFqYmGfy0hnyrod1rn35Oo= k8s.io/api v0.36.4/go.mod h1:S2B3orCFBDhrgyWbLeuKcT2QdHIpQesBkCYSlWtwUOw= k8s.io/apiextensions-apiserver v0.36.4 h1:SfvCVt+4CqKWvzuVytYDT5g9hyb9MztoiYELIkPVrFc= From 145363f0f5276998e6097d141931ea93ffa14724 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Fri, 4 Sep 2026 14:10:06 +0300 Subject: [PATCH 23/28] fix --- api/v1alpha5/groupversion_info.go | 8 ++++++-- api/v1alpha5/zz_generated.deepcopy.go | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/v1alpha5/groupversion_info.go b/api/v1alpha5/groupversion_info.go index 694e59b0e..ed97c21d0 100644 --- a/api/v1alpha5/groupversion_info.go +++ b/api/v1alpha5/groupversion_info.go @@ -4,8 +4,9 @@ package v1alpha5 import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/scheme" ) var ( @@ -13,7 +14,10 @@ var ( GroupVersion = schema.GroupVersion{Group: "rhdh.redhat.com", Version: "v1alpha5"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme - SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + SchemeBuilder = runtime.NewSchemeBuilder(func(s *runtime.Scheme) error { + metav1.AddToGroupVersion(s, GroupVersion) + return nil + }) // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index b3805d13e..c950646a4 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -8,7 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. From 83953285b040184a70f0b23f38b5d4efae8925d9 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Fri, 4 Sep 2026 14:45:33 +0300 Subject: [PATCH 24/28] fix --- api/v1alpha5/backstage_types.go | 6 +++++- api/v1alpha5/devhubplugincatalog_types.go | 6 +++++- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/api/v1alpha5/backstage_types.go b/api/v1alpha5/backstage_types.go index ef51e3080..b4f583a91 100644 --- a/api/v1alpha5/backstage_types.go +++ b/api/v1alpha5/backstage_types.go @@ -3,6 +3,7 @@ package v1alpha5 import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" ) @@ -409,5 +410,8 @@ func (s *BackstageSpec) IsMonitoringEnabled() bool { } func init() { - SchemeBuilder.Register(&Backstage{}, &BackstageList{}) + SchemeBuilder.Register(func(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, &Backstage{}, &BackstageList{}) + return nil + }) } diff --git a/api/v1alpha5/devhubplugincatalog_types.go b/api/v1alpha5/devhubplugincatalog_types.go index 2b1cb6c51..a8484da4f 100644 --- a/api/v1alpha5/devhubplugincatalog_types.go +++ b/api/v1alpha5/devhubplugincatalog_types.go @@ -3,6 +3,7 @@ package v1alpha5 import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" ) // DevHubPluginCatalogSpec defines the desired state of DevHubPluginCatalog @@ -80,5 +81,8 @@ type DevHubPluginCatalogList struct { } func init() { - SchemeBuilder.Register(&DevHubPluginCatalog{}, &DevHubPluginCatalogList{}) + SchemeBuilder.Register(func(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, &DevHubPluginCatalog{}, &DevHubPluginCatalogList{}) + return nil + }) } diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 4e4c30d1b..8a78d27aa 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-09-04T11:00:16Z" + createdAt: "2026-09-04T11:45:01Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 802e1d0f7..cbad9f596 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-09-04T11:00:12Z" + createdAt: "2026-09-04T11:44:58Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed From 7ca8d6e5a8e5c970fa7fb3465b0c1aa1589f1fab Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 8 Sep 2026 11:02:46 +0300 Subject: [PATCH 25/28] fix --- config/samples/devhubplugincatalog.yaml | 3 +- pkg/catalog/processor.go | 21 +++-- pkg/catalog/processor_test.go | 2 +- pkg/fetcher/oci.go | 14 +++- pkg/fetcher/oci_test.go | 99 ++++++++++++++++++------ plugin-installer/install_plugins.sh | 7 +- plugin-installer/install_plugins_test.sh | 79 +++++++++++++++++++ 7 files changed, 187 insertions(+), 38 deletions(-) diff --git a/config/samples/devhubplugincatalog.yaml b/config/samples/devhubplugincatalog.yaml index 28f4b4d99..d1e89dc62 100644 --- a/config/samples/devhubplugincatalog.yaml +++ b/config/samples/devhubplugincatalog.yaml @@ -1,8 +1,7 @@ # DevHubPluginCatalog defines a source for plugin catalogs fetched from OCI registries. # The operator fetches catalogs from all DevHubPluginCatalog resources, merges them, # and makes them available to Backstage instances. -# -# This is a cluster-scoped resource. + apiVersion: rhdh.redhat.com/v1alpha5 kind: DevHubPluginCatalog metadata: diff --git a/pkg/catalog/processor.go b/pkg/catalog/processor.go index 1f927c5b2..68ca114aa 100644 --- a/pkg/catalog/processor.go +++ b/pkg/catalog/processor.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "gopkg.in/yaml.v2" @@ -15,8 +16,10 @@ import ( ) const ( - // CatalogFileName is the expected file in OCI artifacts - CatalogFileName = "dynamic-plugins.default.yaml" + // DpdyFile is the expected file in OCI artifacts + DpdyFile = "dynamic-plugins.default.yaml" + // OCIFetchTimeout is the timeout for fetching OCI artifacts + OCIFetchTimeout = 2 * time.Minute ) // Processor handles fetching, processing, and merging plugin catalogs @@ -107,8 +110,12 @@ func (p *Processor) BuildPatch(plugins PluginMap) ([]byte, error) { return json.Marshal(patchData) } -// fetch downloads a single catalog +// fetch downloads a single catalog with a timeout to prevent hanging on unresponsive registries func (p *Processor) fetch(ctx context.Context, cat CatalogInput) ([]byte, error) { + // Add timeout to prevent hanging on slow/unresponsive registries + fetchCtx, cancel := context.WithTimeout(ctx, OCIFetchTimeout) + defer cancel() + var opts []fetcher.OCIOption if cat.SkipTLSVerify { opts = append(opts, fetcher.WithInsecure()) @@ -129,12 +136,16 @@ func (p *Processor) fetch(ctx context.Context, cat CatalogInput) ([]byte, error) defer func() { _ = os.RemoveAll(tempDir) }() ref := strings.TrimPrefix(cat.Ref, "oci://") - if err := ociFetcher.Fetch(ctx, ref, tempDir); err != nil { + if err := ociFetcher.Fetch(fetchCtx, ref, tempDir); err != nil { return nil, fmt.Errorf("failed to fetch OCI artifact: %w", err) } - content, err := os.ReadFile(filepath.Join(tempDir, CatalogFileName)) + content, err := os.ReadFile(filepath.Join(tempDir, DpdyFile)) if err != nil { + if os.IsNotExist(err) { + // File doesn't exist - skip it, return empty content + return []byte{}, nil + } return nil, fmt.Errorf("failed to read catalog file: %w", err) } diff --git a/pkg/catalog/processor_test.go b/pkg/catalog/processor_test.go index 528774ef7..dac816ad2 100644 --- a/pkg/catalog/processor_test.go +++ b/pkg/catalog/processor_test.go @@ -227,5 +227,5 @@ func TestCatalogInput(t *testing.T) { } func TestConstants(t *testing.T) { - assert.Equal(t, "dynamic-plugins.default.yaml", CatalogFileName) + assert.Equal(t, "dynamic-plugins.default.yaml", DpdyFile) } diff --git a/pkg/fetcher/oci.go b/pkg/fetcher/oci.go index 428ac47e7..e24de3ab0 100644 --- a/pkg/fetcher/oci.go +++ b/pkg/fetcher/oci.go @@ -59,7 +59,12 @@ func WithInsecure() OCIOption { func WithCACert(caCert []byte) OCIOption { return func(c *OCIFetcher) { pool := x509.NewCertPool() - pool.AppendCertsFromPEM(caCert) + if !pool.AppendCertsFromPEM(caCert) { + // Invalid CA cert - log error but don't configure custom transport + // This will fall back to system CA pool, preventing silent failures + // The fetch will fail with a clear TLS verification error if needed + return + } c.transport = &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{RootCAs: pool}, @@ -71,9 +76,12 @@ func WithCACert(caCert []byte) OCIOption { func WithDockerConfig(dockerConfig []byte) OCIOption { return func(c *OCIFetcher) { kc, err := newDockerConfigKeychain(dockerConfig) - if err == nil { - c.keychain = kc + if err != nil { + // Invalid docker config - fall back to default keychain + // This prevents silent failures; fetch will fail with clear auth error if credentials are needed + return } + c.keychain = kc } } diff --git a/pkg/fetcher/oci_test.go b/pkg/fetcher/oci_test.go index d26ea573b..0815efc13 100644 --- a/pkg/fetcher/oci_test.go +++ b/pkg/fetcher/oci_test.go @@ -31,16 +31,25 @@ func TestWithInsecure(t *testing.T) { } func TestWithCACert(t *testing.T) { - // Create a test CA cert (self-signed, for testing) + // Valid self-signed CA cert for testing caCert := []byte(`-----BEGIN CERTIFICATE----- -MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl -c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM -BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCFzP9GVMF/odlY0x -fXH5XKPF6a9kKZl7e8V7H6KYQO7MYHl7aNQp7XZf0g/iYn/lFxVKcGFz/fQMt7oB -AgMBAAGjUzBRMB0GA1UdDgQWBBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAfBgNVHSME -GDAWgBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG -SIb3DQEBCwUAA0EA0GXpF1JguGJ2I7m3pCnYdU3lfQEb7y0e6pZyfMz5EPuV1JJh -k5mN+kTl5kmYJsYLsL7Q3v5K5ng+q3lQiPqE/w== +MIIDAzCCAeugAwIBAgIUIzY7ufM5PVWGCBw22V3JZPNkhTwwDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI2MDkwODA3NTczN1oXDTI3MDkwODA3 +NTczN1owETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAysJqbjbazjOtfFGwyju4iL3GPpV7EiLHC+ylqssjtEOeOmH7x/nL +lakUw8d5wNerMtsha6L6CXvrloKbNgGMmZ6XNrvXcgHzxAq8Lm62Gm+6jc/M3ASV +uEuMLOAdwvuBrzZpeMwrpBSgBSETkKIcrLGiUbCW14wRjkM9xdtSKrzAwlvp0AIh +mm0N2H+AI8wi23VBmqP/dlh6PxqIUxNftPqputee+dOPDyoRL41TrYhruTCTViU5 +p9Lw7fkCXeP5p7m5B4b6Fe/HZxqz3DLukXFlRjUtmX/9xwrLZFV15NZDRZ94LIpw +EGzeZcSyqZKNSUnfoQtmFph5br505XiNfwIDAQABo1MwUTAdBgNVHQ4EFgQU9136 +wHKbv30UeC7Syauw58dlDRowHwYDVR0jBBgwFoAU9136wHKbv30UeC7Syauw58dl +DRowDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATSuRRn2bfs23 +Ixg9AOjdmrtNciWJ4lWZsbBmxkMSfhbvKgwatbvZUINdeF2MHxP0Ne/Omb9ToHpi +4G3+BSHAB0V4gBIgti/vQI/R5OfW/f5xu1VgEeLZ/f5FTIMlxtcHr1ybbaxg2e8j +UPls5wo0I4FlxCe2qLRWP2xiHSB/UwnjUjbw/UGEd6q2vKaDozuHWQctojRxDi1t +grubJzad+j1FeXEDmJuM7ZN91U5oEbycHH1Nj7RYQGWjT6i2mXIijorvb5WjOKUp +uSe02gSkNpStED1tN4YzA7z1S/tyLw8rQeAQL3CfAz9tMZHcv6cW0nkCMXpNNJc7 +xEeZn/ELwg== -----END CERTIFICATE-----`) fetcher := NewOCIFetcher(WithCACert(caCert)) @@ -51,6 +60,18 @@ k5mN+kTl5kmYJsYLsL7Q3v5K5ng+q3lQiPqE/w== assert.NotNil(t, transport.TLSClientConfig.RootCAs) } +func TestWithCACert_Invalid(t *testing.T) { + // Invalid CA cert - should fall back to default transport + invalidCert := []byte(`-----BEGIN CERTIFICATE----- +INVALID_CERTIFICATE_DATA +-----END CERTIFICATE-----`) + + fetcher := NewOCIFetcher(WithCACert(invalidCert)) + + // Should fall back to default transport (not set custom transport) + assert.Equal(t, http.DefaultTransport, fetcher.transport) +} + func TestWithPluginValidation(t *testing.T) { fetcher := NewOCIFetcher(WithPluginValidation()) assert.True(t, fetcher.validatePlugin) @@ -171,14 +192,23 @@ func TestAuthToAuthenticator_MalformedAuthString(t *testing.T) { func TestMultipleOptions(t *testing.T) { caCert := []byte(`-----BEGIN CERTIFICATE----- -MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl -c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM -BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCFzP9GVMF/odlY0x -fXH5XKPF6a9kKZl7e8V7H6KYQO7MYHl7aNQp7XZf0g/iYn/lFxVKcGFz/fQMt7oB -AgMBAAGjUzBRMB0GA1UdDgQWBBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAfBgNVHSME -GDAWgBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG -SIb3DQEBCwUAA0EA0GXpF1JguGJ2I7m3pCnYdU3lfQEb7y0e6pZyfMz5EPuV1JJh -k5mN+kTl5kmYJsYLsL7Q3v5K5ng+q3lQiPqE/w== +MIIDAzCCAeugAwIBAgIUIzY7ufM5PVWGCBw22V3JZPNkhTwwDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI2MDkwODA3NTczN1oXDTI3MDkwODA3 +NTczN1owETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAysJqbjbazjOtfFGwyju4iL3GPpV7EiLHC+ylqssjtEOeOmH7x/nL +lakUw8d5wNerMtsha6L6CXvrloKbNgGMmZ6XNrvXcgHzxAq8Lm62Gm+6jc/M3ASV +uEuMLOAdwvuBrzZpeMwrpBSgBSETkKIcrLGiUbCW14wRjkM9xdtSKrzAwlvp0AIh +mm0N2H+AI8wi23VBmqP/dlh6PxqIUxNftPqputee+dOPDyoRL41TrYhruTCTViU5 +p9Lw7fkCXeP5p7m5B4b6Fe/HZxqz3DLukXFlRjUtmX/9xwrLZFV15NZDRZ94LIpw +EGzeZcSyqZKNSUnfoQtmFph5br505XiNfwIDAQABo1MwUTAdBgNVHQ4EFgQU9136 +wHKbv30UeC7Syauw58dlDRowHwYDVR0jBBgwFoAU9136wHKbv30UeC7Syauw58dl +DRowDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATSuRRn2bfs23 +Ixg9AOjdmrtNciWJ4lWZsbBmxkMSfhbvKgwatbvZUINdeF2MHxP0Ne/Omb9ToHpi +4G3+BSHAB0V4gBIgti/vQI/R5OfW/f5xu1VgEeLZ/f5FTIMlxtcHr1ybbaxg2e8j +UPls5wo0I4FlxCe2qLRWP2xiHSB/UwnjUjbw/UGEd6q2vKaDozuHWQctojRxDi1t +grubJzad+j1FeXEDmJuM7ZN91U5oEbycHH1Nj7RYQGWjT6i2mXIijorvb5WjOKUp +uSe02gSkNpStED1tN4YzA7z1S/tyLw8rQeAQL3CfAz9tMZHcv6cW0nkCMXpNNJc7 +xEeZn/ELwg== -----END CERTIFICATE-----`) dockerConfig := createDockerConfig(t, map[string]authEntry{ @@ -204,16 +234,35 @@ k5mN+kTl5kmYJsYLsL7Q3v5K5ng+q3lQiPqE/w== assert.Len(t, kc.auths, 1) } +func TestWithDockerConfig_Invalid(t *testing.T) { + // Invalid JSON - should fall back to default keychain + invalidConfig := []byte(`not valid json`) + + fetcher := NewOCIFetcher(WithDockerConfig(invalidConfig)) + + // Should fall back to default keychain + assert.Equal(t, authn.DefaultKeychain, fetcher.keychain) +} + func TestInsecureOverridesCACert(t *testing.T) { caCert := []byte(`-----BEGIN CERTIFICATE----- -MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl -c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM -BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96FCFzP9GVMF/odlY0x -fXH5XKPF6a9kKZl7e8V7H6KYQO7MYHl7aNQp7XZf0g/iYn/lFxVKcGFz/fQMt7oB -AgMBAAGjUzBRMB0GA1UdDgQWBBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAfBgNVHSME -GDAWgBQBJ7R4DTvnR0lf7FQ1A6f7M5l1kjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG -SIb3DQEBCwUAA0EA0GXpF1JguGJ2I7m3pCnYdU3lfQEb7y0e6pZyfMz5EPuV1JJh -k5mN+kTl5kmYJsYLsL7Q3v5K5ng+q3lQiPqE/w== +MIIDAzCCAeugAwIBAgIUIzY7ufM5PVWGCBw22V3JZPNkhTwwDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI2MDkwODA3NTczN1oXDTI3MDkwODA3 +NTczN1owETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAysJqbjbazjOtfFGwyju4iL3GPpV7EiLHC+ylqssjtEOeOmH7x/nL +lakUw8d5wNerMtsha6L6CXvrloKbNgGMmZ6XNrvXcgHzxAq8Lm62Gm+6jc/M3ASV +uEuMLOAdwvuBrzZpeMwrpBSgBSETkKIcrLGiUbCW14wRjkM9xdtSKrzAwlvp0AIh +mm0N2H+AI8wi23VBmqP/dlh6PxqIUxNftPqputee+dOPDyoRL41TrYhruTCTViU5 +p9Lw7fkCXeP5p7m5B4b6Fe/HZxqz3DLukXFlRjUtmX/9xwrLZFV15NZDRZ94LIpw +EGzeZcSyqZKNSUnfoQtmFph5br505XiNfwIDAQABo1MwUTAdBgNVHQ4EFgQU9136 +wHKbv30UeC7Syauw58dlDRowHwYDVR0jBBgwFoAU9136wHKbv30UeC7Syauw58dl +DRowDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATSuRRn2bfs23 +Ixg9AOjdmrtNciWJ4lWZsbBmxkMSfhbvKgwatbvZUINdeF2MHxP0Ne/Omb9ToHpi +4G3+BSHAB0V4gBIgti/vQI/R5OfW/f5xu1VgEeLZ/f5FTIMlxtcHr1ybbaxg2e8j +UPls5wo0I4FlxCe2qLRWP2xiHSB/UwnjUjbw/UGEd6q2vKaDozuHWQctojRxDi1t +grubJzad+j1FeXEDmJuM7ZN91U5oEbycHH1Nj7RYQGWjT6i2mXIijorvb5WjOKUp +uSe02gSkNpStED1tN4YzA7z1S/tyLw8rQeAQL3CfAz9tMZHcv6cW0nkCMXpNNJc7 +xEeZn/ELwg== -----END CERTIFICATE-----`) // WithInsecure applied after WithCACert should override diff --git a/plugin-installer/install_plugins.sh b/plugin-installer/install_plugins.sh index 3ae7c57d0..9bf73158d 100755 --- a/plugin-installer/install_plugins.sh +++ b/plugin-installer/install_plugins.sh @@ -635,9 +635,12 @@ download_plugin() { url=$(echo "${input_line}" | awk '{print $1}') integrity=$(echo "${input_line}" | awk '{print $2}') - # Extract plugin name from URL + # Extract plugin name from URL (strip protocol, tag, and digest) + # For OCI: oci://registry/path/name:tag@digest -> name + # For NPM: @scope/plugin-name@version -> plugin-name + # Order: remove digest, extract last path component, then remove version local plugin_name - plugin_name=$(echo "${url}" | sed 's|oci://||' | sed 's|https\?://||' | sed 's|file://||' | sed 's|file:||' | sed 's|@sha256:.*||' | sed 's|@.*||' | awk -F'/' '{print $NF}') + plugin_name=$(echo "${url}" | sed 's|oci://||' | sed 's|https\?://||' | sed 's|file://||' | sed 's|file:||' | sed 's|@sha256:.*||' | awk -F'/' '{print $NF}' | sed 's|@.*||' | sed 's|:.*||') local plugin_dir="${output_dir}/${plugin_name}" diff --git a/plugin-installer/install_plugins_test.sh b/plugin-installer/install_plugins_test.sh index a6ce587b5..23e2bb21b 100755 --- a/plugin-installer/install_plugins_test.sh +++ b/plugin-installer/install_plugins_test.sh @@ -430,6 +430,73 @@ test_termination_msg_on_missing_input() { fi } +# ============================================================================ +# Tests: plugin name extraction +# ============================================================================ + +extract_plugin_name() { + local url="$1" + # Extract plugin name from URL (same logic as in install_plugins.sh line 643) + # Order: remove digest, extract last path component, then remove version + echo "${url}" | sed 's|oci://||' | sed 's|https\?://||' | sed 's|file://||' | sed 's|file:||' | sed 's|@sha256:.*||' | awk -F'/' '{print $NF}' | sed 's|@.*||' | sed 's|:.*||' +} + +test_plugin_name_oci_with_tag_and_digest() { + local url="oci://ghcr.io/org/repo/backstage-plugin-foo:v1.0.0@sha256:abc123def456" + local result + result=$(extract_plugin_name "${url}") + assert_equals "backstage-plugin-foo" "${result}" "OCI URL with tag and digest" +} + +test_plugin_name_oci_with_tag_only() { + local url="oci://ghcr.io/org/repo/backstage-plugin-bar:latest" + local result + result=$(extract_plugin_name "${url}") + assert_equals "backstage-plugin-bar" "${result}" "OCI URL with tag only" +} + +test_plugin_name_oci_with_digest_only() { + local url="oci://ghcr.io/org/repo/backstage-plugin-baz@sha256:abc123def456" + local result + result=$(extract_plugin_name "${url}") + assert_equals "backstage-plugin-baz" "${result}" "OCI URL with digest only" +} + +test_plugin_name_oci_no_tag_or_digest() { + local url="oci://ghcr.io/org/repo/backstage-plugin-qux" + local result + result=$(extract_plugin_name "${url}") + assert_equals "backstage-plugin-qux" "${result}" "OCI URL with no tag or digest" +} + +test_plugin_name_oci_with_port() { + local url="oci://registry.example.com:5000/path/to/my-plugin:v2.0.0@sha256:123abc" + local result + result=$(extract_plugin_name "${url}") + assert_equals "my-plugin" "${result}" "OCI URL with registry port" +} + +test_plugin_name_http_url() { + local url="https://example.com/path/to/plugin-archive.tgz" + local result + result=$(extract_plugin_name "${url}") + assert_equals "plugin-archive.tgz" "${result}" "HTTP URL" +} + +test_plugin_name_npm_scoped_with_version() { + local url="@backstage/plugin-catalog@1.2.3" + local result + result=$(extract_plugin_name "${url}") + assert_equals "plugin-catalog" "${result}" "NPM scoped package with version" +} + +test_plugin_name_npm_unscoped_with_version() { + local url="my-plugin@2.0.0" + local result + result=$(extract_plugin_name "${url}") + assert_equals "my-plugin" "${result}" "NPM unscoped package with version" +} + # ============================================================================ # Tests: validate_plugin_artifact() # ============================================================================ @@ -573,6 +640,18 @@ main() { run_test "termination msg on missing input" test_termination_msg_on_missing_input echo "" + # plugin name extraction tests + echo "--- plugin name extraction tests ---" + run_test "OCI with tag and digest" test_plugin_name_oci_with_tag_and_digest + run_test "OCI with tag only" test_plugin_name_oci_with_tag_only + run_test "OCI with digest only" test_plugin_name_oci_with_digest_only + run_test "OCI no tag or digest" test_plugin_name_oci_no_tag_or_digest + run_test "OCI with registry port" test_plugin_name_oci_with_port + run_test "HTTP URL" test_plugin_name_http_url + run_test "NPM scoped with version" test_plugin_name_npm_scoped_with_version + run_test "NPM unscoped with version" test_plugin_name_npm_unscoped_with_version + echo "" + # validate_plugin_artifact tests echo "--- validate_plugin_artifact() tests ---" run_test "rejects non-plugin image (alpine)" test_validate_rejects_non_plugin_image From 9c6cf17a38c377294168ad1115c5d9027e73d08b Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 8 Sep 2026 11:23:07 +0300 Subject: [PATCH 26/28] bundle --- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 8a78d27aa..49a08a7bb 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-09-04T11:45:01Z" + createdAt: "2026-09-08T08:22:37Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index cbad9f596..233c3019c 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-09-04T11:44:58Z" + createdAt: "2026-09-08T08:22:33Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed From c99fdbc94a04fec92bfd2b413505b3419e4f2378 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 8 Sep 2026 11:29:59 +0300 Subject: [PATCH 27/28] bundle --- ...kstage-operator.clusterserviceversion.yaml | 14 +++++++++- ...kstage-operator.clusterserviceversion.yaml | 26 +++++++++---------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 49a08a7bb..f1b85db69 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-09-08T08:22:37Z" + createdAt: "2026-09-08T08:29:37Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 @@ -131,6 +131,18 @@ spec: - patch - update - watch + - apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - rhdh.redhat.com resources: diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 233c3019c..deab3b316 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -42,7 +42,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-09-08T08:22:33Z" + createdAt: "2026-09-08T08:29:34Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed @@ -185,6 +185,18 @@ spec: - patch - update - watch + - apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - rhdh.redhat.com resources: @@ -246,18 +258,6 @@ spec: - patch - update - watch - - apiGroups: - - networking.k8s.io - resources: - - networkpolicies - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - apiGroups: - tekton.dev resources: From 1009830a937c27d697e8b3575d65bbacfeb5f782 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 8 Sep 2026 20:32:19 +0300 Subject: [PATCH 28/28] bundle --- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 4 ++-- dist/rhdh/install.yaml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index f1b85db69..b3b8d753b 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-09-08T08:29:37Z" + createdAt: "2026-09-08T17:31:38Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index deab3b316..788cf5bf4 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -41,8 +41,8 @@ metadata: capabilities: Seamless Upgrades categories: Developer Tools certified: "true" - containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-09-08T08:29:34Z" + containerImage: registry.redhat.io/rhdh/rhdh-rhel10-operator:2.0 + createdAt: "2026-09-08T17:31:35Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/dist/rhdh/install.yaml b/dist/rhdh/install.yaml index 713cfe90d..442c11043 100644 --- a/dist/rhdh/install.yaml +++ b/dist/rhdh/install.yaml @@ -4134,7 +4134,7 @@ data: default: "true" steps: - name: flatten - image: registry.access.redhat.com/ubi9-minimal + image: registry.access.redhat.com/ubi10-minimal workingDir: $(workspaces.workflow-source.path) script: | #!/usr/bin/env sh @@ -4173,7 +4173,7 @@ data: type: string steps: - name: build-manifests - image: registry.access.redhat.com/ubi9-minimal + image: registry.access.redhat.com/ubi10-minimal workingDir: $(workspaces.workflow-source.path)/flat/$(params.workflowId) script: | #!/usr/bin/env sh @@ -4201,7 +4201,7 @@ data: type: string steps: - name: build-gitops - image: registry.access.redhat.com/ubi9-minimal + image: registry.access.redhat.com/ubi10-minimal workingDir: $(workspaces.workflow-gitops.path)/workflow-gitops script: | #!/usr/bin/env sh