diff --git a/src/pkg/bundle/common.go b/src/pkg/bundle/common.go index d6a2548e..b059d498 100644 --- a/src/pkg/bundle/common.go +++ b/src/pkg/bundle/common.go @@ -15,6 +15,7 @@ import ( "github.com/defenseunicorns/uds-cli/src/config" "github.com/defenseunicorns/uds-cli/src/pkg/bundler/fetcher" + "github.com/defenseunicorns/uds-cli/src/pkg/utils" "github.com/defenseunicorns/uds-cli/src/types" "github.com/defenseunicorns/zarf/src/pkg/cluster" "github.com/defenseunicorns/zarf/src/pkg/message" @@ -154,7 +155,7 @@ func (b *Bundle) ValidateBundleResources(bundle *types.UDSBundle, spinner *messa } } else { // atm we don't support outputting a bundle with local pkgs outputting to OCI - if b.cfg.CreateOpts.Output != "" { + if utils.IsRegistryURL(b.cfg.CreateOpts.Output) { return fmt.Errorf("detected local Zarf package: %s, outputting to an OCI registry is not supported when using local Zarf packages", pkg.Name) } var fullPkgName string diff --git a/src/pkg/bundler/bundler.go b/src/pkg/bundler/bundler.go index 1ce7ae02..417530b3 100644 --- a/src/pkg/bundler/bundler.go +++ b/src/pkg/bundler/bundler.go @@ -5,6 +5,7 @@ package bundler import ( + "github.com/defenseunicorns/uds-cli/src/pkg/utils" "github.com/defenseunicorns/uds-cli/src/types" ) @@ -41,15 +42,15 @@ func NewBundler(opts *Options) *Bundler { // Create creates a bundle func (b *Bundler) Create() error { - if b.output == "" { - localBundle := NewLocalBundle(&LocalBundleOpts{Bundle: b.bundle, TmpDstDir: b.tmpDstDir, SourceDir: b.sourceDir}) - err := localBundle.create(nil) + if utils.IsRegistryURL(b.output) { + remoteBundle := NewRemoteBundle(&RemoteBundleOpts{Bundle: b.bundle, Output: b.output}) + err := remoteBundle.create(nil) if err != nil { return err } } else { - remoteBundle := NewRemoteBundle(&RemoteBundleOpts{Bundle: b.bundle, Output: b.output}) - err := remoteBundle.create(nil) + localBundle := NewLocalBundle(&LocalBundleOpts{Bundle: b.bundle, TmpDstDir: b.tmpDstDir, SourceDir: b.sourceDir, OutputDir: b.output}) + err := localBundle.create(nil) if err != nil { return err } diff --git a/src/pkg/bundler/localbundle.go b/src/pkg/bundler/localbundle.go index 5e4efc2e..768c0aea 100644 --- a/src/pkg/bundler/localbundle.go +++ b/src/pkg/bundler/localbundle.go @@ -18,6 +18,7 @@ import ( "github.com/defenseunicorns/uds-cli/src/types" "github.com/defenseunicorns/zarf/src/pkg/message" "github.com/defenseunicorns/zarf/src/pkg/oci" + "github.com/defenseunicorns/zarf/src/pkg/utils/helpers" "github.com/defenseunicorns/zarf/src/pkg/zoci" goyaml "github.com/goccy/go-yaml" "github.com/mholt/archiver/v4" @@ -32,6 +33,7 @@ type LocalBundleOpts struct { Bundle *types.UDSBundle TmpDstDir string SourceDir string + OutputDir string } // LocalBundle enables create ops with local bundles @@ -39,6 +41,7 @@ type LocalBundle struct { bundle *types.UDSBundle tmpDstDir string sourceDir string + outputDir string } // NewLocalBundle creates a new local bundle @@ -47,6 +50,7 @@ func NewLocalBundle(opts *LocalBundleOpts) *LocalBundle { bundle: opts.Bundle, tmpDstDir: opts.TmpDstDir, sourceDir: opts.SourceDir, + outputDir: opts.OutputDir, } } @@ -159,8 +163,11 @@ func (lo *LocalBundle) create(signature []byte) error { return err } + if lo.outputDir == "" { + lo.outputDir = lo.sourceDir + } // tarball the bundle - err = writeTarball(bundle, artifactPathMap, lo.sourceDir) + err = writeTarball(bundle, artifactPathMap, lo.outputDir) if err != nil { return err } @@ -207,14 +214,18 @@ func pushManifestConfig(store *ocistore.Store, metadata types.UDSMetadata, build } // writeTarball builds and writes a bundle tarball to disk based on a file map -func writeTarball(bundle *types.UDSBundle, artifactPathMap types.PathMap, sourceDir string) error { +func writeTarball(bundle *types.UDSBundle, artifactPathMap types.PathMap, outputDir string) error { format := archiver.CompressedArchive{ Compression: archiver.Zstd{}, Archival: archiver.Tar{}, } filename := fmt.Sprintf("%s%s-%s-%s.tar.zst", config.BundlePrefix, bundle.Metadata.Name, bundle.Metadata.Architecture, bundle.Metadata.Version) - dst := filepath.Join(sourceDir, filename) + if !helpers.IsDir(outputDir) { + os.MkdirAll(outputDir, 0755) + } + + dst := filepath.Join(outputDir, filename) _ = os.RemoveAll(dst) diff --git a/src/pkg/utils/utils.go b/src/pkg/utils/utils.go index 2f941205..fe88cdef 100644 --- a/src/pkg/utils/utils.go +++ b/src/pkg/utils/utils.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "github.com/defenseunicorns/uds-cli/src/config" @@ -138,3 +139,46 @@ func ToLocalFile(t any, filePath string) error { func IsRemotePkg(pkg types.Package) bool { return pkg.Repository != "" } + +func hasScheme(s string) bool { + return strings.Contains(s, "://") +} + +// hasDomain checks if a string contains a domain. +// It assumes the domain is at the beginning of a URL and there is no scheme (e.g., oci://). +func hasDomain(s string) bool { + dotIndex := strings.Index(s, ".") + firstSlashIndex := strings.Index(s, "/") + + // dot exists; dot is not first char; not preceded by any / if / exists + return dotIndex != -1 && dotIndex != 0 && (firstSlashIndex == -1 || firstSlashIndex > dotIndex) +} + +func hasPort(s string) bool { + // look for colon and port (e.g localhost:31999) + colonIndex := strings.Index(s, ":") + firstSlashIndex := strings.Index(s, "/") + endIndex := firstSlashIndex + if firstSlashIndex == -1 { + endIndex = len(s) - 1 + } + if colonIndex != -1 { + port := s[colonIndex+1 : endIndex] + + // port valid number ? + _, err := strconv.Atoi(port) + if err == nil { + return true + } + } + return false +} + +// IsRegistryURL checks if a string is a URL +func IsRegistryURL(s string) bool { + if hasScheme(s) || hasDomain(s) || hasPort(s) { + return true + } + + return false +} diff --git a/src/pkg/utils/utils_test.go b/src/pkg/utils/utils_test.go new file mode 100644 index 00000000..a0589cf2 --- /dev/null +++ b/src/pkg/utils/utils_test.go @@ -0,0 +1,93 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_IsRegistryURL(t *testing.T) { + type args struct { + output string + } + tests := []struct { + name string + description string + args args + wantResult bool + }{ + { + name: "HasScheme", + description: "Output has a scheme ://", + args: args{output: "oci://ghcr.io/defenseunicorns/dev"}, + wantResult: true, + }, + { + name: "HasDomain", + description: "Output has no scheme but has domain", + args: args{output: "ghcr.io/defenseunicorns/dev"}, + wantResult: true, + }, + { + name: "HasMultiDomain", + description: "Output has no scheme but has domain in form of example.example.com", + args: args{output: "registry.example.io/defenseunicorns/dev"}, + wantResult: true, + }, + { + name: "HasDomainAndNoPath", + description: "Output has no scheme but has domain in form of example.example.com", + args: args{output: "registry.example.io"}, + wantResult: true, + }, + { + name: "HasPort", + description: "Output has no scheme or domain (with .) but has port", + args: args{output: "localhost:31999"}, + wantResult: true, + }, + { + name: "HasPortWithTrailingSlash", + description: "Output has no scheme or domain (with .) but has port with trailing /", + args: args{output: "localhost:31999/path"}, + wantResult: true, + }, + { + name: "IsLocalPath", + description: "Output is to local path", + args: args{output: "local/path"}, + wantResult: false, + }, + { + name: "IsCurrentDirectory", + description: "Output is current directory", + args: args{output: "."}, + wantResult: false, + }, + { + name: "IsHiddenDirectory", + description: "Output is a hidden directory", + args: args{output: ".dev"}, + wantResult: false, + }, + { + name: "IsHiddenDirectoryWithSlashPrefix", + description: "Output is a hidden directory nested in path", + args: args{output: "/pathto/.dev"}, + wantResult: false, + }, + { + name: "HasRareDotInLocalDirectoryPath", + description: "Output is a hidden directory nested in path", + args: args{output: "/pathto/test.dev/"}, + wantResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualResult := IsRegistryURL(tt.args.output) + require.Equal(t, tt.wantResult, actualResult) + }) + } +} diff --git a/src/test/e2e/bundle_test.go b/src/test/e2e/bundle_test.go index d17c94c3..4170b2d7 100644 --- a/src/test/e2e/bundle_test.go +++ b/src/test/e2e/bundle_test.go @@ -320,6 +320,22 @@ func TestBundleWithYmlFile(t *testing.T) { remove(t, bundlePath) } +func TestLocalBundleWithOutput(t *testing.T) { + path := "src/test/packages/nginx" + args := strings.Split(fmt.Sprintf("zarf package create %s -o %s --confirm", path, path), " ") + _, _, err := e2e.UDS(args...) + require.NoError(t, err) + + bundleDir := "src/test/bundles/09-uds-bundle-yml" + destDir := "src/test/test/" + bundlePath := filepath.Join(destDir, fmt.Sprintf("uds-bundle-yml-example-%s-0.0.1.tar.zst", e2e.Arch)) + createLocalWithOuputFlag(t, bundleDir, destDir, e2e.Arch) + + cmd := strings.Split(fmt.Sprintf("inspect %s", bundlePath), " ") + _, _, err = e2e.UDS(cmd...) + require.NoError(t, err) +} + func TestLocalBundleWithNoSBOM(t *testing.T) { path := "src/test/packages/nginx" args := strings.Split(fmt.Sprintf("zarf package create %s -o %s --skip-sbom --confirm", path, path), " ") diff --git a/src/test/e2e/commands_test.go b/src/test/e2e/commands_test.go index a89e99bb..55b75bc0 100644 --- a/src/test/e2e/commands_test.go +++ b/src/test/e2e/commands_test.go @@ -36,6 +36,12 @@ func createLocalError(bundlePath string, arch string) (stderr string) { return stderr } +func createLocalWithOuputFlag(t *testing.T, bundlePath string, destPath string, arch string) { + cmd := strings.Split(fmt.Sprintf("create %s -o %s --insecure --confirm -a %s", bundlePath, destPath, arch), " ") + _, _, err := e2e.UDS(cmd...) + require.NoError(t, err) +} + func createRemoteInsecure(t *testing.T, bundlePath, registry, arch string) { cmd := strings.Split(fmt.Sprintf("create %s -o %s --confirm --insecure -a %s", bundlePath, registry, arch), " ") _, _, err := e2e.UDS(cmd...)