diff --git a/.gitignore b/.gitignore index 38c7c8c..34d3b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ *.gen.go .launchr/ .compose/ -.plasmactl +.plasmactl \ No newline at end of file diff --git a/Makefile b/Makefile index 22c4e8d..719d1de 100644 --- a/Makefile +++ b/Makefile @@ -110,6 +110,23 @@ test: .install-gotestfmt echo "$(BOLD)$(RED)๐Ÿงช โŒ Some tests failed$(RESET)" @echo +# Run all tests with race detector +.PHONY: test-race +test-race: .install-gotestfmt + $(call print_step,"Running all tests with race detector...") + @go test -json -race -v ./... | $(GOTESTFMT_BIN) -hide all && \ + echo "$(BOLD)$(GREEN)๐Ÿงช โœ… All tests passed (race detector clean)$(RESET)" || \ + echo "$(BOLD)$(RED)๐Ÿงช โŒ Some tests failed$(RESET)" + @echo + +# Run integration tests keeping work dir for inspection +# Usage: make test-integration [TEST=build_lock] +.PHONY: test-integration +test-integration: .install-gotestfmt + $(call print_step,"Running integration tests โ€” keeping work dir...") + @go test -json -v -run "TestCompose$(if $(TEST),/$(TEST),)" ./test/ -args -testwork | $(GOTESTFMT_BIN) + @echo + # Run short tests .PHONY: test-short test-short: .install-gotestfmt @@ -137,6 +154,13 @@ install: all @cp $(LOCAL_BIN)/launchr $(GOBIN)/launchr $(call print_success,"๐Ÿš€ launchr installed to $(GOBIN)/launchr") +# Install launchr without running tests +.PHONY: install-bin +install-bin: deps build + $(call print_step,"Installing launchr to GOPATH...") + @cp $(LOCAL_BIN)/launchr $(GOBIN)/launchr + $(call print_success,"๐Ÿš€ launchr installed to $(GOBIN)/launchr") + # Install and run linters .PHONY: lint lint: .install-lint .lint-fix @@ -189,19 +213,22 @@ help: $(call print_header) @echo "$(BOLD)$(WHITE)Available targets:$(RESET)" @echo "" - @echo " $(BOLD)$(GREEN)all$(RESET) ๐ŸŽฏ Run deps, test, and build" - @echo " $(BOLD)$(GREEN)deps$(RESET) ๐Ÿ“ฆ Install go dependencies" - @echo " $(BOLD)$(GREEN)test$(RESET) ๐Ÿงช Run all tests" - @echo " $(BOLD)$(GREEN)test-short$(RESET) โšก Run short tests only" - @echo " $(BOLD)$(GREEN)build$(RESET) ๐Ÿ”จ Build launchr binary" - @echo " $(BOLD)$(GREEN)install$(RESET) ๐Ÿš€ Install launchr to GOPATH" - @echo " $(BOLD)$(GREEN)lint$(RESET) ๐Ÿ” Run linters with auto-fix" - @echo " $(BOLD)$(GREEN)clean$(RESET) ๐Ÿงน Clean build artifacts" - @echo " $(BOLD)$(GREEN)help$(RESET) โ“ Show this help message" + @echo " $(BOLD)$(GREEN)all$(RESET) ๐ŸŽฏ Run deps, test, and build" + @echo " $(BOLD)$(GREEN)deps$(RESET) ๐Ÿ“ฆ Install go dependencies" + @echo " $(BOLD)$(GREEN)test$(RESET) ๐Ÿงช Run all tests" + @echo " $(BOLD)$(GREEN)test-race$(RESET) ๐Ÿ Run all tests with race detector" + @echo " $(BOLD)$(GREEN)test-integration$(RESET) ๐Ÿ”ฌ Run integration tests keeping work dir (TEST=name to filter)" + @echo " $(BOLD)$(GREEN)test-short$(RESET) โšก Run short tests only" + @echo " $(BOLD)$(GREEN)build$(RESET) ๐Ÿ”จ Build launchr binary" + @echo " $(BOLD)$(GREEN)install$(RESET) ๐Ÿš€ Install launchr to GOPATH (with tests)" + @echo " $(BOLD)$(GREEN)install-bin$(RESET) ๐Ÿš€ Install launchr to GOPATH (without tests)" + @echo " $(BOLD)$(GREEN)lint$(RESET) ๐Ÿ” Run linters with auto-fix" + @echo " $(BOLD)$(GREEN)clean$(RESET) ๐Ÿงน Clean build artifacts" + @echo " $(BOLD)$(GREEN)help$(RESET) โ“ Show this help message" @echo "" @echo "$(BOLD)$(CYAN)Environment variables:$(RESET)" - @echo " $(BOLD)$(YELLOW)DEBUG=1$(RESET) Enable debug build" - @echo " $(BOLD)$(YELLOW)BIN=path$(RESET) Custom binary output path" + @echo " $(BOLD)$(YELLOW)DEBUG=1$(RESET) Enable debug build" + @echo " $(BOLD)$(YELLOW)BIN=path$(RESET) Custom binary output path" @echo "" # Default target shows help diff --git a/README.md b/README.md index 63f27c8..d5de196 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,139 @@ ## Composition Tool Specification -The composition tool is a command-line tool that helps developers manage -dependencies for their projects. It allows developers to specify the dependencies for -a project in a "plasma-compose.yaml" file, and then fetches and installs those dependencies -in a structured and organized way. - -The tool works by recursively fetching and processing the "plasma-compose.yaml" files for each package -and its dependencies, and then merging the resulting filesystems into a single filesystem. +The composition tool is a Launchr plugin that manages platform composition. It fetches dependencies defined in a +`plasma-compose.yaml` file from Git, HTTP, or local path sources, resolves them recursively, and merges their contents +into a unified build directory (`.compose/build`) using configurable merge strategies. ### CLI -The composition tool is invoked from the command line with the following syntax: +``` launchr compose [options] -Where options are: - -* -w, --working-dir : The directory where temporary files should be stored during the - composition process. Default is the .compose/packages -* -s, --skip-not-versioned : Skip not versioned files from source directory (git only) -* --conflicts-verbosity: Log files conflicts in format "[current-package] - path to file > Selected - from [domain, other package or current-package]" -* --interactive: Interactive mode allows to submit user credentials during action (default: true) +``` -Example usage - `launchr compose -w=./folder/something -s=1 or -s=true --conflicts-verbosity` +Options: -It's important to note that: if same file is present locally and also brought by a package, default strategy is that -local file will be taken and package file -ignored. [Different strategies](https://github.com/launchrctl/compose/blob/main/example/compose.example.yaml#L18-L35) -can be difined to customize this behavior to your needs. +- `-w, --working-dir` โ€” directory where downloaded packages are stored (default: `.compose/packages`) +- `-s, --skip-not-versioned` โ€” skip files not tracked by git when processing the platform directory +- `--conflicts-verbosity` โ€” log file conflicts: for each conflict prints `[winning-package] - path > Selected from [source]` +- `--clean` โ€” remove the packages directory before running (build directory is always recreated) +- `--interactive` โ€” allow interactive credential prompts (default: `true`) -### `plasma-compose.yaml` File Format +Example: -The "plasma-compose.yaml" file is a text file that specifies the dependencies for a package, along with any necessary -metadata and sources for those dependencies. -The file format includes the following elements: +``` +launchr compose --skip-not-versioned --conflicts-verbosity +launchr compose --clean +``` -- name: The name of the package. -- version: The version number of the package. -- source: The source for the package, including the type of source (Git, HTTP), URL or file path, merge strategy and - other metadata. -- dependencies: A list of required dependencies. +### Conflict resolution -List of strategies: +When the same file exists in multiple packages (or in the platform directory and a package), the **last writer wins**: +the package listed later in `plasma-compose.yaml` takes precedence. The platform directory is always processed last, +so platform files win over all packages by default. -- overwrite-local-file -- remove-extra-local-files -- ignore-extra-package-files -- filter-package-files +Merge strategies let you override this behavior per package and per path. -Example: +### `plasma-compose.yaml` File Format ```yaml -name: example dependencies: - - name: compose-example + - name: my-package source: - type: git - ref: master # branch or tag name - url: https://github.com/example/compose-example.git + type: git # git | http | path + url: https://github.com/example/my-package.git + ref: main # branch, tag, or commit (git); file path (path) strategy: - name: remove-extra-local-files - path: - - path/to/remove-extra-local-files + paths: + - path/to/dir/ - name: ignore-extra-package-files - path: - - library/inventories/platform_nodes/configuration/dev.yaml - - library/inventories/platform_nodes/configuration/prod.yaml - - library/inventories/platform_nodes/configuration/whatever.yaml + paths: + - config/local.yaml ``` -### Fetching and Installing Dependencies +#### Source types -The composition tool fetches and installs dependencies for a package by recursively processing the "plasma-compose.yaml" -files for each package and its dependencies. The tool follows these general steps: +| Type | Description | +|--------|--------------------------------------------------| +| `git` | Clone/fetch a Git repository (default) | +| `http` | Download and extract a `.tar.gz` or `.zip` archive | +| `path` | Copy from a local directory path | -1. Check if package exists locally and is up-to-date. If it's not, remove it from packages dir and proceed to next step. -2. Fetch the package from the specified location. -3. Extract the package contents to a packages directory. -4. Process the "plasma-compose.yaml" file for the package, fetching and installing any necessary dependencies - recursively. -5. Merge the package filesystem into the final platform filesystem. -6. Repeat steps 1-5 for each package and its dependencies. +#### Version conflict detection -During this process, the composition tool keeps track of the dependencies for each package. +If the same package name is required by two different packages with a different type, URL, or ref, the build fails +with a `version conflict` error. Packages with identical type, URL, and ref are deduplicated and downloaded once. -### Plasma-compose commands +### Merge Strategies -it's possible to manipulate plasma-compose.yaml file using commands: +Strategies are declared per dependency in `plasma-compose.yaml`. Each strategy applies to a list of paths (file or +directory prefixes). -- plasmactl compose:add -- plasmactl compose:update -- plasmactl compose:delete +#### `remove-extra-local-files` -For `compose:add` and `compose:update` there are 2 ways to submit data. With or without flags. -Passing `--package` and `--url` to add command will automatically update plasma-compose file. -For update command only `--package` required to update from CLI. +Removes files from the platform directory that match the given paths before merging. Use this to clean up platform +files that should be fully replaced by a package. -For `compose:delete` it's possible to pass list of packaged to delete. +```yaml +strategy: + - name: remove-extra-local-files + paths: + - generated/ +``` -In other cases, user will be prompted to CLI form to fill necessary data of packages. +#### `ignore-extra-package-files` -Examples of usage +Skips files from the package that match the given paths. Files outside the listed paths are merged normally. +Use this to prevent a package from overwriting specific local files. +```yaml +strategy: + - name: ignore-extra-package-files + paths: + - config/local.yaml + - inventories/dev.yaml ``` -launchr compose:add --url some-url --type http -launchr compose:add --package package-name --url some-url --ref v1.0.0 -launchr compose:update --package package-name --url some-url --ref v1.0.0 -launchr compose:add --package package-name --url some-url --ref v1.0.0 --strategy overwrite-local-file --strategy-path "path1|path2" -launchr compose:add --package package-name --url some-url --ref branch --strategy overwrite-local-file,remove-extra-local-files --strategy-path "path1|path2,path3|path4" -launchr compose:add --package package-name --url some-url --ref v1.0.0 --strategy overwrite-local-file --strategy-path "path1|path2" --strategy remove-extra-local-files --strategy-path "path3|path4" +#### `filter-package-files` + +Whitelist: only files from the package that match the given paths are included; all other package files are dropped. +Within the matching paths, last-writer-wins still applies (a later package or the platform can still overwrite). + +```yaml +strategy: + - name: filter-package-files + paths: + - roles/ + - playbooks/site.yml +``` + +> **Deprecated:** `overwrite-local-file` โ€” this strategy had no effect since the default conflict resolution became +> last-writer-wins. It is accepted but ignored, and will be removed in a future version. + +### Fetching and Installing Dependencies + +The tool resolves and installs dependencies by: + +1. Recursively reading `plasma-compose.yaml` files starting from the root. +2. For each package: checking if the local copy is up-to-date; fetching it if not. +3. If the fetched package contains its own `plasma-compose.yaml`, its dependencies are resolved first (depth-first). +4. Packages are deduplicated: the same package (identical type + URL + ref) is downloaded only once. +5. After all packages are fetched, files are merged into `.compose/build` in topological order (dependencies before + dependents, YAML declaration order preserved for siblings). + +### Plasma-compose commands + +Manage the `plasma-compose.yaml` file using subcommands: + +- `launchr compose:add` โ€” add a new dependency +- `launchr compose:update` โ€” update an existing dependency +- `launchr compose:delete` โ€” remove one or more dependencies + +For `compose:add` and `compose:update`, pass flags directly or run interactively without flags: + +``` +launchr compose:add --url https://github.com/example/pkg.git --type git +launchr compose:add --package my-package --url https://github.com/example/pkg.git --ref v1.0.0 +launchr compose:update --package my-package --ref v2.0.0 +launchr compose:delete my-package ``` \ No newline at end of file diff --git a/compose/builder.go b/compose/builder.go index 6b69370..0699404 100644 --- a/compose/builder.go +++ b/compose/builder.go @@ -16,9 +16,9 @@ import ( ) const ( - // DependencyRoot is a dependencies graph main node - DependencyRoot = "root" - gitPrefix = ".git" + // DependencyPlatform is the platform node in the dependencies graph, always processed last + DependencyPlatform = "platform" + gitPrefix = ".git" ) var excludedFolders = map[string]struct{}{".compose": {}} @@ -35,13 +35,11 @@ type mergeStrategy struct { const ( undefinedStrategy mergeStrategyType = iota - overwriteLocalFile mergeStrategyType = 1 - removeExtraLocalFiles mergeStrategyType = 2 - ignoreExtraPackageFiles mergeStrategyType = 3 - filterPackageFiles mergeStrategyType = 4 + removeExtraLocalFiles mergeStrategyType = 1 + ignoreExtraPackageFiles mergeStrategyType = 2 + filterPackageFiles mergeStrategyType = 3 noConflict mergeConflictResolve = iota - resolveToLocal mergeConflictResolve = 1 - resolveToPackage mergeConflictResolve = 2 + resolveToPackage mergeConflictResolve = 1 localStrategy mergeStrategyTarget = 1 packageStrategy mergeStrategyTarget = 2 ) @@ -107,7 +105,7 @@ func identifyStrategy(name string) (mergeStrategyType, mergeStrategyTarget) { switch name { case StrategyOverwriteLocal: - s = overwriteLocalFile + // deprecated: no-op, default conflict resolution is last-writer-wins case StrategyRemoveExtraLocal: s = removeExtraLocalFiles t = localStrategy @@ -131,6 +129,7 @@ type Builder struct { skipNotVersioned bool logConflicts bool packages []*Package + requiredBy map[string][]string } type fsEntry struct { @@ -141,7 +140,7 @@ type fsEntry struct { From string } -func createBuilder(c *Composer, targetDir, sourceDir string, packages []*Package) *Builder { +func createBuilder(c *Composer, targetDir, sourceDir string, packages []*Package, requiredBy map[string][]string) *Builder { return &Builder{ c.WithLogger, c.WithTerm, @@ -151,6 +150,7 @@ func createBuilder(c *Composer, targetDir, sourceDir string, packages []*Package c.options.SkipNotVersioned, c.options.ConflictsVerbosity, packages, + requiredBy, } } @@ -197,63 +197,20 @@ func (b *Builder) build(ctx context.Context) error { } ls, ps := retrieveStrategies(b.packages) - baseFs := os.DirFS(b.platformDir) - entriesMap := make(map[string]*fsEntry) - var entriesTree []*fsEntry - - // @todo move to function - err = fs.WalkDir(baseFs, ".", func(path string, d fs.DirEntry, err error) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - if err != nil { - return err - } - - root := rgxPathRoot.FindString(path) - if _, ok := excludedFolders[root]; ok { - return nil + for _, pkg := range b.packages { + for _, s := range pkg.GetStrategies() { + if s.Name == StrategyOverwriteLocal { + b.Term().Warning().Printfln("strategy %q in package %q is deprecated and has no effect, default conflict resolution is now last-writer-wins", StrategyOverwriteLocal, pkg.GetName()) } - - if !d.IsDir() { - filename := filepath.Base(path) - if _, ok := excludedFiles[filename]; ok { - return nil - } - } - - // Apply strategies that target local files - for _, localStrategy := range ls { - if localStrategy.s == removeExtraLocalFiles { - if ensureStrategyPrefixPath(path, localStrategy.paths) { - return nil - } - } - } - - // Add .git folder into entriesTree whenever CheckVersioned or not - if checkVersioned && !strings.HasPrefix(path, gitPrefix) { - if _, ok := versionedMap[path]; !ok { - return nil - } - } - - finfo, _ := d.Info() - entry := &fsEntry{Prefix: b.platformDir, Path: path, Entry: finfo, Excluded: false, From: "domain repo"} - entriesTree = append(entriesTree, entry) - entriesMap[path] = entry - return nil } - }) - - if err != nil { - return err } + entriesMap := make(map[string]*fsEntry) + var entriesTree []*fsEntry + graph := buildDependenciesGraph(b.packages) - items, _ := graph.TopSort(DependencyRoot) + items, _ := graph.TopSort(DependencyPlatform) targetsMap := getTargetsMap(b.packages) if b.logConflicts { @@ -266,7 +223,56 @@ func (b *Builder) build(ctx context.Context) error { return ctx.Err() default: pkgName := items[i] - if pkgName != DependencyRoot { + switch pkgName { + case DependencyPlatform: + baseFs := os.DirFS(b.platformDir) + err = fs.WalkDir(baseFs, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + root := rgxPathRoot.FindString(path) + if _, ok := excludedFolders[root]; ok { + return nil + } + + if !d.IsDir() { + filename := filepath.Base(path) + if _, ok := excludedFiles[filename]; ok { + return nil + } + } + + for _, localStrategy := range ls { + if localStrategy.s == removeExtraLocalFiles { + if ensureStrategyPrefixPath(path, localStrategy.paths) { + return nil + } + } + } + + if checkVersioned && !strings.HasPrefix(path, gitPrefix) { + if _, ok := versionedMap[path]; !ok { + return nil + } + } + + finfo, _ := d.Info() + entry := &fsEntry{Prefix: b.platformDir, Path: path, Entry: finfo, Excluded: false, From: DependencyPlatform} + var conflictReslv mergeConflictResolve + entriesTree, conflictReslv = addEntries(entriesTree, entriesMap, entry, path) + + if b.logConflicts && !d.IsDir() { + b.logConflictResolve(conflictReslv, path, DependencyPlatform, entriesMap[path]) + } + + return nil + }) + + if err != nil { + return err + } + default: pkgPath := filepath.Join(b.sourceDir, pkgName, targetsMap[pkgName]) packageFs := os.DirFS(pkgPath) strategies, ok := ps[pkgName] @@ -280,6 +286,12 @@ func (b *Builder) build(ctx context.Context) error { return nil } + if !d.IsDir() { + if _, excluded := excludedFiles[filepath.Base(path)]; excluded { + return nil + } + } + var conflictReslv mergeConflictResolve finfo, _ := d.Info() entry := &fsEntry{Prefix: pkgPath, Path: path, Entry: finfo, Excluded: false, From: pkgName} @@ -341,7 +353,29 @@ func (b *Builder) build(ctx context.Context) error { } } - return nil + // Write lock file in topological order (same order used for file merging). + // items contains DependencyPlatform as last entry โ€” exclude it. + pkgByName := make(map[string]*Package, len(b.packages)) + for _, pkg := range b.packages { + pkgByName[pkg.GetName()] = pkg + } + sortedPackages := make([]*Package, 0, len(b.packages)) + for _, name := range items { + if name == DependencyPlatform { + continue + } + if pkg, ok := pkgByName[name]; ok { + sortedPackages = append(sortedPackages, pkg) + } + } + + var lock *Lock + lock, err = buildLock(sortedPackages, b.platformDir, b.sourceDir, b.requiredBy) + if err != nil { + return err + } + + return writeLock(lock, b.targetDir) } func (b *Builder) logConflictResolve(resolveto mergeConflictResolve, path, pkgName string, entry *fsEntry) { @@ -362,16 +396,18 @@ func getTargetsMap(packages []*Package) map[string]string { } func addEntries(entriesTree []*fsEntry, entriesMap map[string]*fsEntry, entry *fsEntry, path string) ([]*fsEntry, mergeConflictResolve) { - conflictResolve := noConflict - if _, ok := entriesMap[path]; !ok { + existing, ok := entriesMap[path] + if !ok { entriesTree = append(entriesTree, entry) entriesMap[path] = entry - } else { - // Be default all conflicts auto-resolved to local. - conflictResolve = resolveToLocal + return entriesTree, noConflict } - return entriesTree, conflictResolve + // Last writer wins: overwrite existing entry in-place so entriesTree pointer stays valid. + existing.Prefix = entry.Prefix + existing.Entry = entry.Entry + existing.From = entry.From + return entriesTree, resolveToPackage } func addStrategyEntries(strategies []*mergeStrategy, entriesTree []*fsEntry, entriesMap map[string]*fsEntry, entry *fsEntry, path string) ([]*fsEntry, mergeConflictResolve) { @@ -380,27 +416,9 @@ func addStrategyEntries(strategies []*mergeStrategy, entriesTree []*fsEntry, ent // Apply strategies package strategies for _, ms := range strategies { switch ms.s { - case overwriteLocalFile: - // Skip strategy if filepath does not match strategy Paths - if !ensureStrategyPrefixPath(path, ms.paths) { - continue - } - - if localMapEntry, ok := entriesMap[path]; !ok { - entriesTree = append(entriesTree, entry) - entriesMap[path] = entry - } else if ensureStrategyPrefixPath(path, ms.paths) { - localMapEntry.Prefix = entry.Prefix - localMapEntry.Entry = entry.Entry - localMapEntry.From = entry.From - - // Strategy replaces local Paths by package one. - conflictResolve = resolveToPackage - } case filterPackageFiles: - if _, ok := entriesMap[path]; !ok && (ensureStrategyPrefixPath(path, ms.paths) || (entry.Entry.IsDir() && ensureStrategyContainsPath(path, ms.paths))) { - entriesTree = append(entriesTree, entry) - entriesMap[path] = entry + if ensureStrategyPrefixPath(path, ms.paths) || (entry.Entry.IsDir() && ensureStrategyContainsPath(path, ms.paths)) { + entriesTree, conflictResolve = addEntries(entriesTree, entriesMap, entry, path) } case ignoreExtraPackageFiles: @@ -455,12 +473,30 @@ func buildDependenciesGraph(packages []*Package) *topsort.Graph { } } - for n, k := range packageNames { - if k { - _ = graph.AddEdge(DependencyRoot, n) + // Platform is the topsort root โ€” all top-level packages are its dependencies, + // so they are processed first and platform always comes last. + graph.AddNode(DependencyPlatform) + + // Collect top-level packages in YAML declaration order (preserved by the packages slice). + // Later in YAML = processed later = wins with last-writer-wins. + var topLevel []string + seen := make(map[string]bool) + for _, a := range packages { + n := a.GetName() + if packageNames[n] && !seen[n] { + topLevel = append(topLevel, n) + seen[n] = true } } + for _, n := range topLevel { + _ = graph.AddEdge(DependencyPlatform, n) + } + // Enforce YAML order between siblings: topLevel[i] processed before topLevel[i+1]. + for i := 1; i < len(topLevel); i++ { + _ = graph.AddEdge(topLevel[i], topLevel[i-1]) + } + return graph } diff --git a/compose/builder_test.go b/compose/builder_test.go new file mode 100644 index 0000000..9f3f3ec --- /dev/null +++ b/compose/builder_test.go @@ -0,0 +1,577 @@ +package compose + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +// newTestBuilder creates a Builder pointing at platformDir and targetDir with given packages. +// It creates an empty source directory for each package so build() can walk them. +func newTestBuilder(t *testing.T, platformDir, targetDir string, packages []*Package) *Builder { + t.Helper() + sourceDir := t.TempDir() + for _, pkg := range packages { + dir := filepath.Join(sourceDir, pkg.GetName(), pkg.GetTarget()) + if err := os.MkdirAll(dir, 0750); err != nil { + t.Fatal(err) + } + } + return &Builder{ + platformDir: platformDir, + targetDir: targetDir, + sourceDir: sourceDir, + packages: packages, + requiredBy: map[string][]string{}, + } +} + +// writePlatformFile creates a file inside platformDir at the given relative path. +func writePlatformFile(t *testing.T, platformDir, relPath, content string) { + t.Helper() + full := filepath.Join(platformDir, relPath) + if err := os.MkdirAll(filepath.Dir(full), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0600); err != nil { + t.Fatal(err) + } +} + +// fakeFileInfo implements fs.FileInfo for testing. +type fakeFileInfo struct { + name string + isDir bool +} + +func (f fakeFileInfo) Name() string { return f.name } +func (f fakeFileInfo) Size() int64 { return 0 } +func (f fakeFileInfo) Mode() fs.FileMode { return 0644 } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return f.isDir } +func (f fakeFileInfo) Sys() any { return nil } + +func makeEntry(prefix, path, from string) *fsEntry { + return &fsEntry{Prefix: prefix, Path: path, Entry: fakeFileInfo{name: path}, From: from} +} + +func indexOf(items []string, item string) int { + for i, v := range items { + if v == item { + return i + } + } + return -1 +} + +func makeStrategy(s mergeStrategyType, paths []string) []*mergeStrategy { + return []*mergeStrategy{{s: s, t: packageStrategy, paths: cleanStrategyPaths(paths)}} +} + +// buildItems simulates what build() does: topsort from a platform. +func buildItems(packages []*Package) []string { + graph := buildDependenciesGraph(packages) + items, _ := graph.TopSort(DependencyPlatform) + return items +} + +// --- buildDependenciesGraph --- + +func TestDependencyGraph(t *testing.T) { + tests := []struct { + name string + packages []*Package + check func(t *testing.T, items []string) + }{ + { + name: "no packages โ€” platform is last", + packages: nil, + check: func(t *testing.T, items []string) { + if items[len(items)-1] != DependencyPlatform { + t.Errorf("platform should be last, got %v", items) + } + }, + }, + { + name: "flat packages โ€” platform is last", + packages: []*Package{{Name: "pkg1"}, {Name: "pkg2"}}, + check: func(t *testing.T, items []string) { + if items[len(items)-1] != DependencyPlatform { + t.Errorf("platform should be last, got %v", items) + } + }, + }, + { + name: "dependency processed before dependent", + // pkgA depends on pkgB โ€” pkgB first, pkgA after, platform last. + // With last-wins, this means pkgA's files override pkgB's files. + packages: []*Package{ + {Name: "pkgA", Dependencies: []string{"pkgB"}}, + {Name: "pkgB"}, + }, + check: func(t *testing.T, items []string) { + idxA, idxB, idxP := indexOf(items, "pkgA"), indexOf(items, "pkgB"), indexOf(items, DependencyPlatform) + if idxA == -1 || idxB == -1 { + t.Fatalf("packages missing in sort result: %v", items) + } + if idxB > idxA { + t.Errorf("pkgB (dependency, %d) should come before pkgA (%d) in %v", idxB, idxA, items) + } + if idxA > idxP { + t.Errorf("pkgA (%d) should come before platform (%d) in %v", idxA, idxP, items) + } + }, + }, + { + name: "top-level packages follow YAML order โ€” last declared wins", + packages: []*Package{ + {Name: "first"}, + {Name: "second"}, + {Name: "third"}, + }, + check: func(t *testing.T, items []string) { + idxFirst, idxSecond, idxThird, idxP := + indexOf(items, "first"), indexOf(items, "second"), indexOf(items, "third"), indexOf(items, DependencyPlatform) + if idxFirst >= idxSecond || idxSecond >= idxThird || idxThird >= idxP { + t.Errorf("expected first < second < third < platform, got %v", items) + } + }, + }, + { + // Simulates a realistic plasma-compose.yaml: + // + // dependencies: + // - name: pkg-a # depends on lib-common + // - name: pkg-b # depends on lib-common (shared), listed after pkg-a + // - name: pkg-c # independent, last in YAML โ†’ wins all conflicts + // + // lib-common itself depends on lib-base, which depends on lib-core (depth=3). + // packages slice reflects downloadManager output: deps appended before dependents. + // + // Expected processing order: + // lib-core โ†’ lib-base โ†’ lib-common โ†’ pkg-a โ†’ pkg-b โ†’ pkg-c โ†’ platform + // + // pkg-c wins over pkg-b wins over pkg-a (YAML order, last-wins). + // pkg-a and pkg-b both win over their shared lib-common. + name: "deep deps + shared lib + YAML order", + packages: []*Package{ + {Name: "lib-core"}, + {Name: "lib-base", Dependencies: []string{"lib-core"}}, + {Name: "lib-common", Dependencies: []string{"lib-base"}}, + {Name: "pkg-a", Dependencies: []string{"lib-common"}}, + {Name: "pkg-b", Dependencies: []string{"lib-common"}}, + {Name: "pkg-c"}, + }, + check: func(t *testing.T, items []string) { + idx := func(n string) int { return indexOf(items, n) } + + order := []struct{ before, after string }{ + // dependency chain + {"lib-core", "lib-base"}, + {"lib-base", "lib-common"}, + {"lib-common", "pkg-a"}, + {"lib-common", "pkg-b"}, + // YAML order between siblings + {"pkg-a", "pkg-b"}, + {"pkg-b", "pkg-c"}, + // platform always last + {"pkg-c", DependencyPlatform}, + } + + for _, o := range order { + a, b := idx(o.before), idx(o.after) + if a == -1 || b == -1 { + t.Errorf("%q or %q missing in %v", o.before, o.after, items) + } else if a > b { + t.Errorf("expected %q (%d) before %q (%d) in %v", o.before, a, o.after, b, items) + } + } + }, + }, + { + name: "all packages before platform", + packages: []*Package{ + {Name: "base"}, + {Name: "infra", Dependencies: []string{"base"}}, + {Name: "app", Dependencies: []string{"infra"}}, + }, + check: func(t *testing.T, items []string) { + idxPlatform := indexOf(items, DependencyPlatform) + for _, name := range []string{"base", "infra", "app"} { + idx := indexOf(items, name) + if idx == -1 { + t.Errorf("package %q missing in sort result", name) + } else if idx > idxPlatform { + t.Errorf("package %q (%d) should come before platform (%d)", name, idx, idxPlatform) + } + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.check(t, buildItems(tt.packages)) + }) + } +} + +// --- addEntries (last writer wins) --- + +func TestAddEntries(t *testing.T) { + tests := []struct { + name string + existing *fsEntry // nil = empty state + incoming *fsEntry + wantResolve mergeConflictResolve + wantTreeLen int + wantFrom string + wantPrefix string + wantTree0From string // if set, checks tree[0].From (pointer update) + }{ + { + name: "new path โ€” added to tree", + existing: nil, + incoming: makeEntry("prefix", "file.txt", "pkg1"), + wantResolve: noConflict, + wantTreeLen: 1, + wantFrom: "pkg1", + wantPrefix: "prefix", + }, + { + name: "existing path โ€” last wins", + existing: makeEntry("prefix1", "file.txt", "pkg1"), + incoming: makeEntry("prefix2", "file.txt", "pkg2"), + wantResolve: resolveToPackage, + wantTreeLen: 1, + wantFrom: "pkg2", + wantPrefix: "prefix2", + wantTree0From: "pkg2", + }, + { + name: "platform overrides package", + existing: makeEntry("pkg-prefix", "config.yaml", "pkg1"), + incoming: makeEntry("platform-prefix", "config.yaml", DependencyPlatform), + wantResolve: resolveToPackage, + wantTreeLen: 1, + wantFrom: DependencyPlatform, + wantPrefix: "platform-prefix", + wantTree0From: DependencyPlatform, + }, + } + + // Extra: three packages on the same file โ€” each overwrites the previous. + t.Run("three packages โ€” last always wins", func(t *testing.T) { + const lastPkg = "pkg3" + var tree []*fsEntry + m := map[string]*fsEntry{} + + for _, from := range []string{"pkg1", "pkg2", lastPkg} { + tree, _ = addEntries(tree, m, makeEntry("prefix-"+from, "file.txt", from), "file.txt") + } + + if len(tree) != 1 { + t.Errorf("tree len: got %d, want 1", len(tree)) + } + if m["file.txt"].From != lastPkg { + t.Errorf("From: got %q, want %s", m["file.txt"].From, lastPkg) + } + if tree[0].From != lastPkg { + t.Errorf("tree[0].From: got %q, want %s", tree[0].From, lastPkg) + } + }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree []*fsEntry + m := map[string]*fsEntry{} + path := tt.incoming.Path + + if tt.existing != nil { + tree = append(tree, tt.existing) + m[path] = tt.existing + } + + tree, resolve := addEntries(tree, m, tt.incoming, path) + + if resolve != tt.wantResolve { + t.Errorf("resolve: got %d, want %d", resolve, tt.wantResolve) + } + if len(tree) != tt.wantTreeLen { + t.Errorf("tree len: got %d, want %d", len(tree), tt.wantTreeLen) + } + if m[path].From != tt.wantFrom { + t.Errorf("From: got %q, want %q", m[path].From, tt.wantFrom) + } + if m[path].Prefix != tt.wantPrefix { + t.Errorf("Prefix: got %q, want %q", m[path].Prefix, tt.wantPrefix) + } + if tt.wantTree0From != "" && tree[0].From != tt.wantTree0From { + t.Errorf("tree[0].From (pointer): got %q, want %q", tree[0].From, tt.wantTree0From) + } + }) + } +} + +// --- addStrategyEntries --- + +func TestAddStrategyEntries(t *testing.T) { + tests := []struct { + name string + strategy mergeStrategyType + stratPaths []string + existing *fsEntry // nil = empty state + incoming *fsEntry + wantTreeLen int + wantFrom string // expected m[path].From; empty = entry not in map + wantNotInMap bool + }{ + { + name: "filter โ€” matching path added if absent", + strategy: filterPackageFiles, + stratPaths: []string{"include/"}, + existing: nil, + incoming: makeEntry("prefix", "include/file.txt", "pkg1"), + wantTreeLen: 1, + wantFrom: "pkg1", + }, + { + name: "filter โ€” matching path overwrites existing (last-wins within whitelist)", + strategy: filterPackageFiles, + stratPaths: []string{"include/"}, + existing: makeEntry("prefix1", "include/file.txt", "pkg1"), + incoming: makeEntry("prefix2", "include/file.txt", "pkg2"), + wantTreeLen: 1, + wantFrom: "pkg2", // last wins + }, + { + name: "filter โ€” non-matching path skipped", + strategy: filterPackageFiles, + stratPaths: []string{"include/"}, + existing: nil, + incoming: makeEntry("prefix", "other/file.txt", "pkg1"), + wantTreeLen: 0, + wantNotInMap: true, + }, + { + name: "ignore โ€” matching path skipped", + strategy: ignoreExtraPackageFiles, + stratPaths: []string{"vendor/"}, + existing: nil, + incoming: makeEntry("prefix", "vendor/lib.go", "pkg1"), + wantTreeLen: 0, + wantNotInMap: true, + }, + { + name: "ignore โ€” matching path already in map โ€” skips incoming, keeps existing", + strategy: ignoreExtraPackageFiles, + stratPaths: []string{"vendor/"}, + existing: makeEntry("prefix1", "vendor/lib.go", "pkg1"), + incoming: makeEntry("prefix2", "vendor/lib.go", "pkg2"), + wantTreeLen: 1, + wantFrom: "pkg1", // existing stays, incoming ignored + }, + { + name: "ignore โ€” non-matching path falls through (added)", + strategy: ignoreExtraPackageFiles, + stratPaths: []string{"vendor/"}, + existing: nil, + incoming: makeEntry("prefix", "src/file.go", "pkg1"), + wantTreeLen: 1, + wantFrom: "pkg1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var tree []*fsEntry + m := map[string]*fsEntry{} + path := tt.incoming.Path + + if tt.existing != nil { + tree = append(tree, tt.existing) + m[path] = tt.existing + } + + strategies := makeStrategy(tt.strategy, tt.stratPaths) + tree, _ = addStrategyEntries(strategies, tree, m, tt.incoming, path) + + if len(tree) != tt.wantTreeLen { + t.Errorf("tree len: got %d, want %d", len(tree), tt.wantTreeLen) + } + if tt.wantNotInMap { + if _, ok := m[path]; ok { + t.Errorf("entry should not be in map for path %q", path) + } + } else if m[path].From != tt.wantFrom { + t.Errorf("From: got %q, want %q", m[path].From, tt.wantFrom) + } + }) + } +} + +// --- remove-extra-local-files (platform walk filter) --- + +func TestRemoveExtraLocalFiles(t *testing.T) { + tests := []struct { + name string + stratPaths []string + platformFiles []string + wantInBuild []string + wantExcluded []string + }{ + { + name: "matching directory excluded from build", + stratPaths: []string{"scripts/"}, + platformFiles: []string{ + "scripts/run.sh", + "scripts/deploy.sh", + "config/base.yml", + "README.md", + }, + wantInBuild: []string{"config/base.yml", "README.md"}, + wantExcluded: []string{"scripts/run.sh", "scripts/deploy.sh"}, + }, + { + name: "non-matching files not affected", + stratPaths: []string{"tmp/"}, + platformFiles: []string{ + "tmp/cache.bin", + "src/main.go", + }, + wantInBuild: []string{"src/main.go"}, + wantExcluded: []string{"tmp/cache.bin"}, + }, + { + name: "multiple excluded paths", + stratPaths: []string{"scripts/", "tmp/"}, + platformFiles: []string{ + "scripts/run.sh", + "tmp/cache.bin", + "config/app.yml", + }, + wantInBuild: []string{"config/app.yml"}, + wantExcluded: []string{"scripts/run.sh", "tmp/cache.bin"}, + }, + { + name: "empty strategy paths โ€” nothing excluded", + stratPaths: []string{}, + platformFiles: []string{ + "scripts/run.sh", + "config/app.yml", + }, + wantInBuild: []string{"scripts/run.sh", "config/app.yml"}, + wantExcluded: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + platformDir := t.TempDir() + targetDir := t.TempDir() + + for _, f := range tt.platformFiles { + writePlatformFile(t, platformDir, f, "content") + } + + pkg := &Package{ + Name: "test-pkg", + Source: Source{ + Type: PathType, + Strategies: []Strategy{ + {Name: StrategyRemoveExtraLocal, Paths: tt.stratPaths}, + }, + }, + } + + b := newTestBuilder(t, platformDir, targetDir, []*Package{pkg}) + if err := b.build(context.Background()); err != nil { + t.Fatalf("build() error: %v", err) + } + + for _, f := range tt.wantInBuild { + if _, err := os.Stat(filepath.Join(targetDir, f)); err != nil { + t.Errorf("expected %q in build, but not found", f) + } + } + for _, f := range tt.wantExcluded { + if _, err := os.Stat(filepath.Join(targetDir, f)); !os.IsNotExist(err) { + t.Errorf("expected %q to be excluded from build, but it exists", f) + } + } + }) + } +} + +// --- diamond dependency in build --- + +func TestBuildDiamond(t *testing.T) { + // Graph: platform + // / \ + // pkg-a pkg-b + // \ / + // lib-common + // + // Topsort: lib-common โ†’ pkg-a โ†’ pkg-b โ†’ platform + // shared.txt conflict resolution: pkg-b wins (last in YAML). + + platformDir := t.TempDir() + targetDir := t.TempDir() + sourceDir := t.TempDir() + + // Write package source files. + writeSourceFile := func(pkg, ref, relPath, content string) { + t.Helper() + full := filepath.Join(sourceDir, pkg, ref, relPath) + if err := os.MkdirAll(filepath.Dir(full), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + + writeSourceFile("lib-common", "latest", "lib/common.txt", "lib-common content") + writeSourceFile("lib-common", "latest", "shared.txt", "from lib-common") + writeSourceFile("pkg-a", "latest", "pkg-a.txt", "pkg-a content") + writeSourceFile("pkg-a", "latest", "shared.txt", "from pkg-a") + writeSourceFile("pkg-b", "latest", "pkg-b.txt", "pkg-b content") + writeSourceFile("pkg-b", "latest", "shared.txt", "from pkg-b") + + packages := []*Package{ + {Name: "lib-common"}, + {Name: "pkg-a", Dependencies: []string{"lib-common"}}, + {Name: "pkg-b", Dependencies: []string{"lib-common"}}, + } + + b := &Builder{ + platformDir: platformDir, + targetDir: targetDir, + sourceDir: sourceDir, + packages: packages, + requiredBy: map[string][]string{}, + } + + if err := b.build(context.Background()); err != nil { + t.Fatalf("build() error: %v", err) + } + + // Unique files from each package must appear. + for _, f := range []string{"lib/common.txt", "pkg-a.txt", "pkg-b.txt"} { + if _, err := os.Stat(filepath.Join(targetDir, f)); err != nil { + t.Errorf("expected %q in build: %v", f, err) + } + } + + // shared.txt: pkg-b is last in YAML โ†’ wins. + content, err := os.ReadFile(filepath.Clean(filepath.Join(targetDir, "shared.txt"))) + if err != nil { + t.Fatalf("shared.txt not found: %v", err) + } + if string(content) != "from pkg-b" { + t.Errorf("shared.txt: got %q, want %q", string(content), "from pkg-b") + } +} diff --git a/compose/compose.go b/compose/compose.go index 74851a1..62a7c4d 100644 --- a/compose/compose.go +++ b/compose/compose.go @@ -164,7 +164,7 @@ func (c *Composer) RunInstall() error { kw.SetLogger(c.Log()) kw.SetTerm(c.Term()) dm := CreateDownloadManager(kw) - packages, err := dm.Download(ctx, c.getCompose(), packagesDir) + packages, requiredBy, err := dm.Download(ctx, c.getCompose(), packagesDir) if err != nil { return err } @@ -174,6 +174,7 @@ func (c *Composer) RunInstall() error { buildDir, packagesDir, packages, + requiredBy, ) return builder.build(ctx) } diff --git a/compose/downloadManager.go b/compose/downloadManager.go index b93a3bd..60a1b4b 100644 --- a/compose/downloadManager.go +++ b/compose/downloadManager.go @@ -2,9 +2,12 @@ package compose import ( "context" + "errors" + "fmt" "io" "os" "path/filepath" + "strings" ) const ( @@ -12,6 +15,8 @@ const ( GitType = "git" // HTTPType is const for http source type download. HTTPType = "http" + // PathType is const for local filesystem source type. + PathType = "path" ) // Downloader interface @@ -38,6 +43,8 @@ func (m DownloadManager) getDownloaderForPackage(downloadType string) Downloader switch downloadType { case HTTPType: return newHTTP(m.kw) + case PathType: + return newPath() case GitType: fallthrough default: @@ -45,19 +52,57 @@ func (m DownloadManager) getDownloaderForPackage(downloadType string) Downloader } } -// Download packages using compose file -func (m DownloadManager) Download(ctx context.Context, c *YamlCompose, targetDir string) ([]*Package, error) { +// resolvedPkg tracks a resolved package for conflict and cycle detection. +type resolvedPkg struct { + pkgType string + url string + ref string + requiredBy []string +} + +// depStack tracks the current recursion path for cycle detection. +// It maintains insertion order so error messages show the exact dependency chain. +type depStack struct { + items []string + set map[string]bool +} + +func newDepStack() *depStack { + return &depStack{set: make(map[string]bool)} +} + +func (s *depStack) push(name string) { + s.items = append(s.items, name) + s.set[name] = true +} + +func (s *depStack) pop(name string) { + s.items = s.items[:len(s.items)-1] + delete(s.set, name) +} + +func (s *depStack) has(name string) bool { + return s.set[name] +} + +func (s *depStack) path() string { + return strings.Join(s.items, " โ†’ ") +} + +// Download packages using compose file. +// Returns packages in topological order and a map of package name โ†’ required_by list. +func (m DownloadManager) Download(ctx context.Context, c *YamlCompose, targetDir string) ([]*Package, map[string][]string, error) { var packages []*Package - //credentials := []keyring.CredentialsItem{} err := EnsureDirExists(targetDir) if err != nil { - return packages, err + return packages, nil, err } kw := m.getKeyring() - packages, err = m.recursiveDownload(ctx, c, packages, nil, targetDir) + seen := make(map[string]resolvedPkg) + packages, err = m.recursiveDownload(ctx, c, packages, nil, targetDir, seen, newDepStack()) if err != nil { - return packages, err + return packages, nil, err } // store keyring credentials @@ -65,39 +110,74 @@ func (m DownloadManager) Download(ctx context.Context, c *YamlCompose, targetDir err = kw.keyringService.Save() } - return packages, err + requiredBy := make(map[string][]string, len(seen)) + for name, rp := range seen { + requiredBy[name] = rp.requiredBy + } + + return packages, requiredBy, err } -func (m DownloadManager) recursiveDownload(ctx context.Context, yc *YamlCompose, packages []*Package, parent *Package, targetDir string) ([]*Package, error) { +func (m DownloadManager) recursiveDownload(ctx context.Context, yc *YamlCompose, packages []*Package, parent *Package, targetDir string, seen map[string]resolvedPkg, stack *depStack) ([]*Package, error) { for _, d := range yc.Dependencies { select { case <-ctx.Done(): return packages, ctx.Err() default: - // build package from dependency struct - // add dependency if parent exists pkg := d.ToPackage(d.Name) + name := pkg.GetName() + ref := pkg.GetTarget() + if parent != nil { - parent.AddDependency(d.Name) + parent.AddDependency(name) } - url := pkg.GetURL() - if url == "" { + if pkg.GetURL() == "" { return packages, errNoURL } - packagePath := filepath.Join(targetDir, pkg.GetName(), pkg.GetTarget()) + // Cycle detection: package is already being processed up the call stack. + // Must run before the seen dedup check, otherwise cycles with the same ref + // would be silently skipped instead of raising an error. + if stack.has(name) { + return packages, fmt.Errorf("circular dependency detected: %s โ†’ %s", stack.path(), name) + } + + // Conflict detection: same package name required with different type, url, or ref. + if existing, ok := seen[name]; ok { + if existing.pkgType != pkg.GetType() || existing.url != pkg.GetURL() || existing.ref != ref { + return packages, fmt.Errorf( + "version conflict: package %q required as %s %s@%s by %q and as %s %s@%s by %q", + name, + existing.pkgType, existing.url, existing.ref, existing.requiredBy, + pkg.GetType(), pkg.GetURL(), ref, requiredBy(parent), + ) + } + // Same source already resolved โ€” accumulate the additional parent and skip. + existing.requiredBy = append(existing.requiredBy, requiredBy(parent)) + seen[name] = existing + continue + } + + seen[name] = resolvedPkg{pkgType: pkg.GetType(), url: pkg.GetURL(), ref: ref, requiredBy: []string{requiredBy(parent)}} + + packagePath := filepath.Join(targetDir, name, ref) err := m.downloadPackage(ctx, pkg, targetDir) if err != nil { return packages, err } - // If package has plasma-compose.yaml, proceed with it - if _, err = os.Stat(filepath.Join(packagePath, composeFile)); !os.IsNotExist(err) { + // If package has plasma-compose.yaml, recurse into it. + if _, statErr := os.Stat(filepath.Join(packagePath, composeFile)); !os.IsNotExist(statErr) { cfg, err := Lookup(os.DirFS(packagePath)) + if err != nil && !errors.Is(err, errComposeNotExists) { + return packages, fmt.Errorf("package %q: %w", name, err) + } if err == nil { - packages, err = m.recursiveDownload(ctx, cfg, packages, pkg, targetDir) + stack.push(name) + packages, err = m.recursiveDownload(ctx, cfg, packages, pkg, targetDir, seen, stack) + stack.pop(name) if err != nil { return packages, err } @@ -111,6 +191,14 @@ func (m DownloadManager) recursiveDownload(ctx context.Context, yc *YamlCompose, return packages, nil } +// requiredBy returns the name of the parent package, or "root" if declared in the root compose. +func requiredBy(parent *Package) string { + if parent == nil { + return "root" + } + return parent.GetName() +} + func (m DownloadManager) downloadPackage(ctx context.Context, pkg *Package, targetDir string) error { downloader := m.getDownloaderForPackage(pkg.GetType()) packagePath := filepath.Join(targetDir, pkg.GetName()) diff --git a/compose/downloadManager_test.go b/compose/downloadManager_test.go new file mode 100644 index 0000000..e21d042 --- /dev/null +++ b/compose/downloadManager_test.go @@ -0,0 +1,248 @@ +package compose + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// newTestDM creates a DownloadManager suitable for tests (no real keyring needed). +func newTestDM() DownloadManager { + return CreateDownloadManager(&keyringWrapper{}) +} + +// fixturePath returns the absolute path to a named fixture directory. +func fixturePath(name string) string { + abs, _ := filepath.Abs(filepath.Join("..", "test", "fixtures", "packages", name)) + return abs +} + +// writeTestCompose writes a plasma-compose.yaml into dir. +func writeTestCompose(t *testing.T, dir string, deps []Dependency) { + t.Helper() + c := YamlCompose{Dependencies: deps} + data, err := yaml.Marshal(c) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, composeFile), data, 0600); err != nil { + t.Fatal(err) + } +} + +// makePkg creates a temp dir, optionally copies a fixture into it, and optionally +// writes a plasma-compose.yaml with the given dependencies. +func makePkg(t *testing.T, fixtureName string, deps []Dependency) string { + t.Helper() + dir := t.TempDir() + if fixtureName != "" { + if err := copyDir(fixturePath(fixtureName), dir); err != nil { + t.Fatalf("copy fixture %q: %v", fixtureName, err) + } + } + if len(deps) > 0 { + writeTestCompose(t, dir, deps) + } + return dir +} + +// pathDep builds a Dependency with type "path" and ref "latest". +func pathDep(name, url string) Dependency { + return Dependency{Name: name, Source: Source{Type: PathType, URL: url}} +} + +// pathDepRef builds a Dependency with type "path" and a specific ref. +func pathDepRef(name, url, ref string) Dependency { + return Dependency{Name: name, Source: Source{Type: PathType, URL: url, Ref: ref}} +} + +// sortedPkgNames returns sorted package names from a slice. +func sortedPkgNames(pkgs []*Package) []string { + names := make([]string, len(pkgs)) + for i, p := range pkgs { + names[i] = p.GetName() + } + sort.Strings(names) + return names +} + +// --- TestDownloadManager --- + +func TestDownloadManager(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) *YamlCompose + wantNames []string // sorted expected package names; nil skips check + wantErr string // non-empty: expect error containing this substring + }{ + { + name: "flat packages โ€” both downloaded", + setup: func(_ *testing.T) *YamlCompose { + return &YamlCompose{Dependencies: []Dependency{ + pathDep("pkg-a", fixturePath("pkg-a")), + pathDep("pkg-b", fixturePath("pkg-b")), + }} + }, + wantNames: []string{"pkg-a", "pkg-b"}, + }, + { + name: "deep dependency chain โ€” all transitive deps downloaded", + // Root โ†’ pkg-a โ†’ lib-common โ†’ lib-base + setup: func(t *testing.T) *YamlCompose { + libBase := makePkg(t, "lib-base", nil) + libCommon := makePkg(t, "lib-common", []Dependency{ + pathDep("lib-base", libBase), + }) + pkgA := makePkg(t, "pkg-a", []Dependency{ + pathDep("lib-common", libCommon), + }) + return &YamlCompose{Dependencies: []Dependency{ + pathDep("pkg-a", pkgA), + }} + }, + wantNames: []string{"lib-base", "lib-common", "pkg-a"}, + }, + { + name: "shared dependency โ€” downloaded exactly once", + // pkg-a and pkg-b both depend on lib-common โ†’ lib-common appears once. + setup: func(t *testing.T) *YamlCompose { + libCommon := makePkg(t, "lib-common", nil) + pkgA := makePkg(t, "pkg-a", []Dependency{ + pathDep("lib-common", libCommon), + }) + pkgB := makePkg(t, "pkg-b", []Dependency{ + pathDep("lib-common", libCommon), + }) + return &YamlCompose{Dependencies: []Dependency{ + pathDep("pkg-a", pkgA), + pathDep("pkg-b", pkgB), + }} + }, + wantNames: []string{"lib-common", "pkg-a", "pkg-b"}, + }, + { + name: "version conflict โ€” same name, different refs", + setup: func(t *testing.T) *YamlCompose { + libCore := makePkg(t, "lib-core", nil) + pkgA := makePkg(t, "pkg-a", []Dependency{ + pathDepRef("lib-core", libCore, "v1"), + }) + pkgB := makePkg(t, "pkg-b", []Dependency{ + pathDepRef("lib-core", libCore, "v2"), + }) + return &YamlCompose{Dependencies: []Dependency{ + pathDep("pkg-a", pkgA), + pathDep("pkg-b", pkgB), + }} + }, + wantErr: "version conflict", + }, + { + name: "version conflict โ€” same name, different urls", + setup: func(t *testing.T) *YamlCompose { + libCoreA := makePkg(t, "lib-core", nil) + libCoreB := makePkg(t, "lib-core", nil) // different path = different source + pkgA := makePkg(t, "pkg-a", []Dependency{ + pathDep("lib-core", libCoreA), + }) + pkgB := makePkg(t, "pkg-b", []Dependency{ + pathDep("lib-core", libCoreB), + }) + return &YamlCompose{Dependencies: []Dependency{ + pathDep("pkg-a", pkgA), + pathDep("pkg-b", pkgB), + }} + }, + wantErr: "version conflict", + }, + { + name: "circular dependency โ€” error shows exact chain", + // pkg-circ-a โ†’ pkg-circ-b โ†’ pkg-circ-a + setup: func(t *testing.T) *YamlCompose { + dirA := t.TempDir() + dirB := t.TempDir() + writeTestCompose(t, dirA, []Dependency{pathDep("pkg-circ-b", dirB)}) + writeTestCompose(t, dirB, []Dependency{pathDep("pkg-circ-a", dirA)}) + return &YamlCompose{Dependencies: []Dependency{ + pathDep("pkg-circ-a", dirA), + }} + }, + wantErr: "pkg-circ-a โ†’ pkg-circ-b โ†’ pkg-circ-a", + }, + { + name: "malformed nested compose โ€” error includes package name", + setup: func(_ *testing.T) *YamlCompose { + return &YamlCompose{Dependencies: []Dependency{ + pathDep("pkg-bad", fixturePath("pkg-bad")), + }} + }, + wantErr: `"pkg-bad"`, + }, + { + name: "deep shared diamond โ€” lib-core downloaded once", + // pkg-a โ†’ lib-common โ†’ lib-base โ†’ lib-core + // pkg-b โ†’ lib-common (same version, shared) + // lib-core must appear exactly once. + setup: func(t *testing.T) *YamlCompose { + libCore := makePkg(t, "lib-core", nil) + libBase := makePkg(t, "lib-base", []Dependency{ + pathDep("lib-core", libCore), + }) + libCommon := makePkg(t, "lib-common", []Dependency{ + pathDep("lib-base", libBase), + }) + pkgA := makePkg(t, "pkg-a", []Dependency{ + pathDep("lib-common", libCommon), + }) + pkgB := makePkg(t, "pkg-b", []Dependency{ + pathDep("lib-common", libCommon), + }) + return &YamlCompose{Dependencies: []Dependency{ + pathDep("pkg-a", pkgA), + pathDep("pkg-b", pkgB), + }} + }, + wantNames: []string{"lib-base", "lib-common", "lib-core", "pkg-a", "pkg-b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + yc := tt.setup(t) + targetDir := t.TempDir() + dm := newTestDM() + + pkgs, _, err := dm.Download(context.Background(), yc, targetDir) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil (packages: %v)", tt.wantErr, sortedPkgNames(pkgs)) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.wantNames != nil { + got := sortedPkgNames(pkgs) + want := make([]string, len(tt.wantNames)) + copy(want, tt.wantNames) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Errorf("packages: got %v, want %v", got, want) + } + } + }) + } +} diff --git a/compose/lock.go b/compose/lock.go new file mode 100644 index 0000000..33f1ebc --- /dev/null +++ b/compose/lock.go @@ -0,0 +1,103 @@ +package compose + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const ( + // LockFile is the name of the lock file written to the build directory. + LockFile = "plasma-compose.lock" +) + +var errLockNotExists = errors.New("plasma-compose.lock not found, run compose first") + +// LockEntry represents a single resolved package in the lock file. +type LockEntry struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + URL string `yaml:"url"` + Ref string `yaml:"ref"` + Path string `yaml:"path"` + RequiredBy []string `yaml:"required_by"` +} + +// Lock stores the full resolved dependency list produced by compose. +type Lock struct { + Packages []LockEntry `yaml:"packages"` +} + +// LookupLock reads and parses the lock file from the given build directory fs. +// Returns errLockNotExists if the file is absent. +func LookupLock(fsys fs.FS) (*Lock, error) { + data, err := fs.ReadFile(fsys, LockFile) + if err != nil { + return nil, errLockNotExists + } + + var lock Lock + if err = yaml.Unmarshal(data, &lock); err != nil { + return nil, fmt.Errorf("plasma-compose.lock parsing failed: %w", err) + } + + return &lock, nil +} + +// writeLock serialises lock and writes it to targetDir/plasma-compose.lock. +func writeLock(lock *Lock, targetDir string) error { + data, err := yaml.Marshal(lock) + if err != nil { + return fmt.Errorf("could not marshal lock file: %w", err) + } + + // Ensure trailing newline so the file is consistent with text editors and + // can be compared with txtar fixture files (which always end with \n). + if len(data) > 0 && data[len(data)-1] != '\n' { + data = append(data, '\n') + } + + path := filepath.Join(targetDir, LockFile) + return os.WriteFile(path, data, 0600) +} + +// buildLock constructs a Lock from the resolved package list. +// packages must already be in topological order (as returned by build). +// platformDir is the project root โ€” paths in the lock are relative to it. +// packagesDir is the absolute path where packages are stored. +func buildLock(packages []*Package, platformDir, packagesDir string, requiredBy map[string][]string) (*Lock, error) { + lock := &Lock{Packages: make([]LockEntry, 0, len(packages))} + + for _, pkg := range packages { + ref := pkg.GetTarget() + + var pkgPath string + if pkg.GetType() == HTTPType { + // http packages have no ref subdirectory (current behaviour). + pkgPath = filepath.Join(packagesDir, pkg.GetName()) + } else { + pkgPath = filepath.Join(packagesDir, pkg.GetName(), ref) + } + + relPath, err := filepath.Rel(platformDir, pkgPath) + if err != nil { + return nil, fmt.Errorf("computing relative path for %q: %w", pkg.GetName(), err) + } + + entry := LockEntry{ + Name: pkg.GetName(), + Type: pkg.GetType(), + URL: pkg.GetURL(), + Ref: pkg.GetRef(), + Path: filepath.ToSlash(relPath), + RequiredBy: requiredBy[pkg.GetName()], + } + lock.Packages = append(lock.Packages, entry) + } + + return lock, nil +} diff --git a/compose/lock_test.go b/compose/lock_test.go new file mode 100644 index 0000000..1ef2463 --- /dev/null +++ b/compose/lock_test.go @@ -0,0 +1,125 @@ +package compose + +import ( + "os" + "path/filepath" + "testing" + "testing/fstest" +) + +func TestLookupLock(t *testing.T) { + const pkgA = "pkg-a" + const reqRoot = "root" + + validLock := `packages: + - name: pkg-a + type: git + url: https://github.com/example/pkg-a.git + ref: v1.0.0 + path: .compose/packages/pkg-a/v1.0.0 + required_by: + - root + - name: lib-common + type: git + url: https://github.com/example/lib-common.git + ref: v0.5.0 + path: .compose/packages/lib-common/v0.5.0 + required_by: + - pkg-a + - pkg-b +` + + t.Run("valid lock file โ€” parsed correctly", func(t *testing.T) { + fsys := fstest.MapFS{ + LockFile: {Data: []byte(validLock)}, + } + lock, err := LookupLock(fsys) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(lock.Packages) != 2 { + t.Fatalf("packages len: got %d, want 2", len(lock.Packages)) + } + + pkg := lock.Packages[0] + if pkg.Name != pkgA { + t.Errorf("name: got %q, want %s", pkg.Name, pkgA) + } + if pkg.Type != "git" { + t.Errorf("type: got %q, want git", pkg.Type) + } + if pkg.Ref != "v1.0.0" { + t.Errorf("ref: got %q, want v1.0.0", pkg.Ref) + } + if len(pkg.RequiredBy) != 1 || pkg.RequiredBy[0] != reqRoot { + t.Errorf("required_by: got %v, want [%s]", pkg.RequiredBy, reqRoot) + } + + lib := lock.Packages[1] + if len(lib.RequiredBy) != 2 || lib.RequiredBy[0] != pkgA || lib.RequiredBy[1] != "pkg-b" { + t.Errorf("required_by: got %v, want [pkg-a pkg-b]", lib.RequiredBy) + } + }) + + t.Run("missing lock file โ€” returns error", func(t *testing.T) { + fsys := fstest.MapFS{} + _, err := LookupLock(fsys) + if err == nil { + t.Fatal("expected error, got nil") + } + if err != errLockNotExists { + t.Errorf("error: got %v, want errLockNotExists", err) + } + }) + + t.Run("malformed lock file โ€” returns parse error", func(t *testing.T) { + fsys := fstest.MapFS{ + LockFile: {Data: []byte("packages: [\ninvalid yaml")}, + } + _, err := LookupLock(fsys) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("round-trip: buildLock โ†’ writeLock โ†’ LookupLock", func(t *testing.T) { + packages := []*Package{ + {Name: pkgA, Source: Source{Type: "git", URL: "https://github.com/example/pkg-a.git", Ref: "v1.0.0"}}, + {Name: "lib-common", Source: Source{Type: "git", URL: "https://github.com/example/lib-common.git", Ref: "v0.5.0"}}, + } + requiredBy := map[string][]string{ + pkgA: {reqRoot}, + "lib-common": {pkgA, "pkg-b"}, + } + + platformDir := t.TempDir() + packagesDir := filepath.Join(platformDir, ".compose", "packages") + targetDir := t.TempDir() + + lock, err := buildLock(packages, platformDir, packagesDir, requiredBy) + if err != nil { + t.Fatalf("buildLock: %v", err) + } + if err = writeLock(lock, targetDir); err != nil { + t.Fatalf("writeLock: %v", err) + } + + fsys := os.DirFS(targetDir) + got, err := LookupLock(fsys) + if err != nil { + t.Fatalf("LookupLock: %v", err) + } + if len(got.Packages) != 2 { + t.Fatalf("packages len: got %d, want 2", len(got.Packages)) + } + if got.Packages[0].Name != pkgA { + t.Errorf("packages[0].Name: got %q, want %s", got.Packages[0].Name, pkgA) + } + if got.Packages[1].Name != "lib-common" { + t.Errorf("packages[1].Name: got %q, want lib-common", got.Packages[1].Name) + } + if len(got.Packages[1].RequiredBy) != 2 { + t.Errorf("packages[1].RequiredBy: got %v, want [pkg-a pkg-b]", got.Packages[1].RequiredBy) + } + }) +} diff --git a/compose/path.go b/compose/path.go new file mode 100644 index 0000000..e9a49db --- /dev/null +++ b/compose/path.go @@ -0,0 +1,59 @@ +package compose + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +type pathDownloader struct{} + +func newPath() Downloader { + return &pathDownloader{} +} + +// EnsureLatest returns true if the destination already exists. +// Local paths have no remote version to check against. +func (p *pathDownloader) EnsureLatest(_ *Package, downloadPath string) (bool, error) { + if _, err := os.Stat(downloadPath); !os.IsNotExist(err) { + return true, nil + } + return false, nil +} + +// Download copies the local source directory to targetDir. +func (p *pathDownloader) Download(_ context.Context, pkg *Package, targetDir string) error { + srcPath := pkg.GetURL() + if srcPath == "" { + return errNoURL + } + + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + return fmt.Errorf("local path %q does not exist", srcPath) + } + + return copyDir(srcPath, targetDir) +} + +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + destPath := filepath.Join(dst, rel) + + if d.IsDir() { + return os.MkdirAll(destPath, dirPermissions) + } + + return fcopy(path, destPath) + }) +} diff --git a/go.mod b/go.mod index 146337a..7c8f221 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,10 @@ go 1.25.0 require ( dario.cat/mergo v1.0.2 github.com/charmbracelet/huh v0.7.0 - github.com/go-git/go-git/v5 v5.16.3 - github.com/launchrctl/keyring v0.7.0 + github.com/go-git/go-git/v5 v5.16.5 + github.com/launchrctl/keyring v0.9.1 github.com/launchrctl/launchr v0.22.0 + github.com/rogpeppe/go-internal v1.14.1 github.com/stevenle/topsort v0.2.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -131,15 +132,16 @@ require ( go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.42.0 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/net v0.44.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.31.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.13.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/protobuf v1.36.9 // indirect diff --git a/go.sum b/go.sum index e1600bd..fb5c943 100644 --- a/go.sum +++ b/go.sum @@ -175,8 +175,8 @@ github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UN github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8= -github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -354,8 +354,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/launchrctl/keyring v0.7.0 h1:IrmhjnguZrcNZHg2vSVajVdWvmxYh6tnpsu/Q4Rw8ck= -github.com/launchrctl/keyring v0.7.0/go.mod h1:HE8OurqAYUt3x0ArDEDa6w9TAL4mbUwxqp/PJr/EPJE= +github.com/launchrctl/keyring v0.9.1 h1:XaiP7vCavaitvhx+Pbq6WuUjCoSLj0Ba3CTWeYOD5uA= +github.com/launchrctl/keyring v0.9.1/go.mod h1:HE8OurqAYUt3x0ArDEDa6w9TAL4mbUwxqp/PJr/EPJE= github.com/launchrctl/launchr v0.22.0 h1:JJJgvaSBjaxwX7vfyZ+ujwt7kIXqp/V0xy5k3JgtMyU= github.com/launchrctl/launchr v0.22.0/go.mod h1:cLETGKQKp6WBg1uPQ2WmrHTjPcWTfseUI0R5NyODxUs= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= @@ -585,8 +585,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= @@ -600,8 +600,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -622,8 +622,8 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -640,8 +640,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -684,15 +684,15 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -702,8 +702,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= @@ -720,8 +720,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..0b53bac --- /dev/null +++ b/test/README.md @@ -0,0 +1,225 @@ +# Integration Tests + +This directory contains integration tests, test scripts, and test helpers designed to provide comprehensive end-to-end +testing capabilities for the application. + +## Overview + +Our integration testing framework extends the standard [testscript](https://github.com/rogpeppe/go-internal) +functionality with custom commands and enhancements tailored to our specific testing needs. These tests validate the +complete application workflow, from binary execution to complex text processing scenarios. + +## Custom Testscript Commands + +We have extended the standard testscript command set with several custom commands to enhance testing capabilities: + +### Text Processing Commands + +#### `txtproc` - Advanced Text Processing + +Provides flexible text processing capabilities for manipulating test files and outputs. + +**Available Operations:** + +| Operation | Description | Usage | +|-----------------|-------------------------------------|----------------------------------------------------------------------| +| `replace` | Replace literal text strings | `txtproc replace 'old_text' 'new_text' input.txt output.txt` | +| `replace-regex` | Replace using regular expressions | `txtproc replace-regex 'pattern' 'replacement' input.txt output.txt` | +| `remove-lines` | Remove lines matching a pattern | `txtproc remove-lines 'pattern' input.txt output.txt` | +| `remove-regex` | Remove text matching regex pattern | `txtproc remove-regex 'pattern' input.txt output.txt` | +| `extract-lines` | Extract lines matching a pattern | `txtproc extract-lines 'pattern' input.txt output.txt` | +| `extract-regex` | Extract text matching regex pattern | `txtproc extract-regex 'pattern' input.txt output.txt` | + +**Examples:** + +```bash +# Replace version numbers in output +txtproc replace 'v1.0.0' 'v2.0.0' app_output.txt expected.txt + +# Extract error messages using regex +txtproc extract-regex 'ERROR: .*' log.txt errors.txt + +# Remove debug lines from output +txtproc remove-lines 'DEBUG:' verbose_output.txt clean_output.txt +``` + +### Utility Commands + +#### `sleep` - Execution Delay + +Pauses test execution for a specified duration, useful for timing-sensitive tests or waiting for asynchronous +operations. + +**Usage:** + +```bash +sleep +``` + +**Supported Duration Formats:** + +- `ns` - nanoseconds +- `us` - microseconds +- `ms` - milliseconds +- `s` - seconds +- `m` - minutes +- `h` - hours + +**Examples:** + +```bash +# Wait for 1 second +sleep 1s + +# Wait for 500 milliseconds +sleep 500ms + +# Wait for 2 minutes +sleep 2m + +# Wait for background process +sleep 100ms +``` + +#### `dlv` - Debug Integration + +Runs the specified binary with [Delve](https://github.com/go-delve/delve) debugger support for interactive debugging +during tests. + +**Usage:** + +```bash +dlv +``` + +**Prerequisites:** + +- Binary must be compiled with debug headers (`-gcflags="all=-N -l"`) +- Delve must be installed in the testing environment + +**Examples:** + +```bash +# Debug the main application +dlv launchr +``` + +## Command Overrides + +### Enhanced `kill` Command + +We override the default testscript `kill` command to provide broader signal support beyond the standard implementation. + +**Enhanced Features:** + +- Support for additional POSIX signals +- Cross-platform signal handling +- Improved process termination reliability + +**Usage:** + +```bash +# Standard termination +kill bg-name + +# Graceful shutdown with SIGTERM +kill -TERM bg-name +``` + +## Running Integration Tests + +### Inspect test working directory (debug) + +Use `-testwork` to keep the temporary directory after the test run. The path is printed in the output as `WORK=...`. + +```bash +# All integration tests โ€” keeps $WORK dir for inspection +make test-integration + +# Filter to a specific test by name (filename without .txtar) +make test-integration TEST=build_lock +``` + +After the run, inspect `$WORK/platform/.compose/build/` for the resulting build artifacts. + +## Integration Tests + +All tests live in `testdata/compose/` as `.txtar` files. + +### Download + +| File | What it tests | +|-----------------------------------|--------------------------------------------------------------------------------------------| +| `download_flat.txtar` | Two flat packages via `path` source โ€” both appear in the build | +| `download_deep_deps.txtar` | Deep chain `root โ†’ pkg-a โ†’ lib-common โ†’ lib-base` โ€” all transitive deps downloaded | +| `download_version_conflict.txtar` | Same package required with different `ref` by two packages โ€” error with `version conflict` | + +### Build + +| File | What it tests | +|-----------------------------------|-----------------------------------------------------------------------------------------| +| `build_conflict_last_wins.txtar` | Same file in two packages โ€” package declared last in YAML wins | +| `build_platform_wins.txtar` | Same file in package and platform dir โ€” platform always wins | +| `build_diamond.txtar` | Diamond dep (`pkg-a` and `pkg-b` both depend on `lib-common`) โ€” dedup + last-wins order | +| `build_conflicts_verbosity.txtar` | `--conflicts-verbosity` flag โ€” conflict lines printed to stdout | +| `build_clean.txtar` | `--clean` flag โ€” packages dir removed; build dir always recreated (stale files gone) | +| `build_lock.txtar` | lock file written to build dir with transitive deps, topological order, correct paths | + +### Strategies + +| File | What it tests | +|---------------------------------------|----------------------------------------------------------------------------------------------------| +| `strategy_filter.txtar` | `filter-package-files` โ€” only whitelisted paths from package enter the build | +| `strategy_filter_last_wins.txtar` | `filter-package-files` + last-wins โ€” later package overwrites within its whitelist | +| `strategy_ignore.txtar` | `ignore-extra-package-files` โ€” matching package files are skipped entirely | +| `strategy_ignore_keeps_earlier.txtar` | `ignore-extra-package-files` โ€” earlier package file preserved when later package ignores that path | +| `strategy_remove_local.txtar` | `remove-extra-local-files` โ€” matching platform files excluded from the build | + +## Writing Integration Tests + +### Basic Test Structure + +Integration tests use the [txtar format](https://pkg.go.dev/github.com/rogpeppe/go-internal/txtar) to bundle test +scripts with their required files. See [examples](./testdata). + +### Best Practices + +#### Test Organization + +- Group related tests in logical directories +- Use descriptive test file names +- Include both positive and negative test cases + +#### File Management + +- Keep test files small and focused +- Use meaningful fixture names +- Clean up temporary files when possible + +#### Error Handling + +- Test error conditions explicitly +- Verify error messages and exit codes +- Use `! exec` for commands expected to fail + +#### Performance Considerations + +- Use `sleep` judiciously to avoid slow tests +- Prefer deterministic waits over arbitrary delays +- Consider using `make test-short` for development + +## Contributing + +When adding new integration tests: + +1. Follow the existing naming conventions +2. Include comprehensive test documentation +3. Test on multiple platforms when relevant +4. Add appropriate error handling +5. Update this README if adding new custom commands + +## Related Resources + +- [Testscript Documentation](https://github.com/rogpeppe/go-internal/tree/master/testscript) +- [Txtar Format Specification](https://pkg.go.dev/github.com/rogpeppe/go-internal/txtar) +- [Delve Debugger](https://github.com/go-delve/delve) diff --git a/test/compose_test.go b/test/compose_test.go new file mode 100644 index 0000000..a02ec04 --- /dev/null +++ b/test/compose_test.go @@ -0,0 +1,26 @@ +// Package test contains testscript-based integration tests for the compose plugin. +package test + +import ( + "testing" + + "github.com/rogpeppe/go-internal/testscript" + + _ "github.com/launchrctl/compose" // registers the compose plugin via init() + "github.com/launchrctl/launchr" + launchrtest "github.com/launchrctl/launchr/test" +) + +func TestMain(m *testing.M) { + testscript.Main(m, map[string]func(){ + "launchr": launchr.RunAndExit, + }) +} + +func TestCompose(t *testing.T) { + testscript.Run(t, testscript.Params{ + Dir: "testdata/compose", + Cmds: launchrtest.CmdsTestScript(), + RequireExplicitExec: true, + }) +} diff --git a/test/fixtures/packages/lib-base/lib-base.txt b/test/fixtures/packages/lib-base/lib-base.txt new file mode 100644 index 0000000..b8b6250 --- /dev/null +++ b/test/fixtures/packages/lib-base/lib-base.txt @@ -0,0 +1 @@ +lib-base content diff --git a/test/fixtures/packages/lib-common/lib-common.txt b/test/fixtures/packages/lib-common/lib-common.txt new file mode 100644 index 0000000..34c4d12 --- /dev/null +++ b/test/fixtures/packages/lib-common/lib-common.txt @@ -0,0 +1 @@ +lib-common content diff --git a/test/fixtures/packages/lib-core/lib-core.txt b/test/fixtures/packages/lib-core/lib-core.txt new file mode 100644 index 0000000..f4444d7 --- /dev/null +++ b/test/fixtures/packages/lib-core/lib-core.txt @@ -0,0 +1 @@ +lib-core content diff --git a/test/fixtures/packages/pkg-a/pkg-a.txt b/test/fixtures/packages/pkg-a/pkg-a.txt new file mode 100644 index 0000000..bb3ab9a --- /dev/null +++ b/test/fixtures/packages/pkg-a/pkg-a.txt @@ -0,0 +1 @@ +pkg-a content diff --git a/test/fixtures/packages/pkg-b/pkg-b.txt b/test/fixtures/packages/pkg-b/pkg-b.txt new file mode 100644 index 0000000..c06af64 --- /dev/null +++ b/test/fixtures/packages/pkg-b/pkg-b.txt @@ -0,0 +1 @@ +pkg-b content diff --git a/test/fixtures/packages/pkg-bad/plasma-compose.yaml b/test/fixtures/packages/pkg-bad/plasma-compose.yaml new file mode 100644 index 0000000..9a99a00 --- /dev/null +++ b/test/fixtures/packages/pkg-bad/plasma-compose.yaml @@ -0,0 +1 @@ +dependencies: [unclosed bracket diff --git a/test/testdata/compose/build_clean.txtar b/test/testdata/compose/build_clean.txtar new file mode 100644 index 0000000..dfa057e --- /dev/null +++ b/test/testdata/compose/build_clean.txtar @@ -0,0 +1,41 @@ +# --clean removes the packages dir before the build. +# The build dir is always recreated (even without --clean). +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +# First run โ€” without --clean, packages are downloaded. +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stdout 'Cleaning packages dir' +exists platform/.compose/build/app.yml +exists platform/.compose/packages + +# Add a stale file to the build dir manually. +cp platform/app.yml platform/.compose/build/stale.txt + +# Second run without --clean: build dir is recreated โ†’ stale.txt is gone. +# packages dir is not touched. +exec sh -c 'cd platform && launchr compose --interactive=false' +! stdout 'Cleaning packages dir' +exists platform/.compose/build/app.yml +! exists platform/.compose/build/stale.txt + +# Third run with --clean: packages dir is also cleaned and recreated. +exec sh -c 'cd platform && launchr compose --interactive=false --clean=true' +stdout 'Cleaning packages dir' +exists platform/.compose/build/app.yml + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + +-- src/pkg-a/app.yml -- +app: config + +-- platform/app.yml -- +platform file diff --git a/test/testdata/compose/build_conflict_last_wins.txtar b/test/testdata/compose/build_conflict_last_wins.txtar new file mode 100644 index 0000000..29d2754 --- /dev/null +++ b/test/testdata/compose/build_conflict_last_wins.txtar @@ -0,0 +1,32 @@ +# File conflict between two packages: last package in YAML order wins. +# Both pkg-a and pkg-b have shared.txt โ€” pkg-b is declared last, so its +# version must be present in the build output. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +# pkg-b is declared after pkg-a โ†’ its shared.txt wins +grep 'from pkg-b' platform/.compose/build/shared.txt + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + - name: pkg-b + source: + type: path + url: ../src/pkg-b + +-- src/pkg-a/shared.txt -- +from pkg-a + +-- src/pkg-b/shared.txt -- +from pkg-b diff --git a/test/testdata/compose/build_conflicts_verbosity.txtar b/test/testdata/compose/build_conflicts_verbosity.txtar new file mode 100644 index 0000000..f8e17df --- /dev/null +++ b/test/testdata/compose/build_conflicts_verbosity.txtar @@ -0,0 +1,40 @@ +# --conflicts-verbosity prints conflict information for files provided by multiple packages. +# pkg-a and pkg-b both provide shared.txt โ€” pkg-b wins (last-wins). +# The output must contain a conflict line for shared.txt. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false --conflicts-verbosity=true' +stdout 'Creating composition' +stdout 'Conflicting files' +stdout 'shared\.txt' +! stderr . + +# pkg-b won +grep 'from pkg-b' platform/.compose/build/shared.txt + +# file with no conflict โ€” must still appear in the build +exists platform/.compose/build/only-in-pkg-a.txt + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + - name: pkg-b + source: + type: path + url: ../src/pkg-b + +-- src/pkg-a/shared.txt -- +from pkg-a + +-- src/pkg-a/only-in-pkg-a.txt -- +unique to pkg-a + +-- src/pkg-b/shared.txt -- +from pkg-b diff --git a/test/testdata/compose/build_diamond.txtar b/test/testdata/compose/build_diamond.txtar new file mode 100644 index 0000000..d159dd8 --- /dev/null +++ b/test/testdata/compose/build_diamond.txtar @@ -0,0 +1,88 @@ +# Diamond dependency: pkg-a and pkg-b both depend on lib-common. +# +# Graph: platform +# / \ +# pkg-a pkg-b +# \ / +# lib-common +# +# Topsort order: lib-common โ†’ pkg-a โ†’ pkg-b โ†’ platform +# lib-common is downloaded once (dedup in download). +# On conflict: pkg-b is declared last โ†’ wins (last-wins). +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/src/lib-common/ โ€” shared transitive dep +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +# lib-common: unique file appears in the build +exists platform/.compose/build/lib/common.txt + +# pkg-a: unique file appears in the build +exists platform/.compose/build/pkg-a.txt + +# pkg-b: unique file appears in the build +exists platform/.compose/build/pkg-b.txt + +# shared.txt: lib-common โ†’ pkg-a โ†’ pkg-b, pkg-b wins (last in YAML) +grep 'from pkg-b' platform/.compose/build/shared.txt + +# override.txt: overwritten by pkg-a then pkg-b โ†’ pkg-b wins +grep 'from pkg-b' platform/.compose/build/override.txt + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + - name: pkg-b + source: + type: path + url: ../src/pkg-b + +-- src/pkg-a/plasma-compose.yaml -- +dependencies: + - name: lib-common + source: + type: path + url: ../src/lib-common + +-- src/pkg-b/plasma-compose.yaml -- +dependencies: + - name: lib-common + source: + type: path + url: ../src/lib-common + +-- src/lib-common/lib/common.txt -- +lib-common content + +-- src/lib-common/shared.txt -- +from lib-common + +-- src/lib-common/override.txt -- +from lib-common + +-- src/pkg-a/pkg-a.txt -- +pkg-a content + +-- src/pkg-a/shared.txt -- +from pkg-a + +-- src/pkg-a/override.txt -- +from pkg-a + +-- src/pkg-b/pkg-b.txt -- +pkg-b content + +-- src/pkg-b/shared.txt -- +from pkg-b + +-- src/pkg-b/override.txt -- +from pkg-b diff --git a/test/testdata/compose/build_lock.txtar b/test/testdata/compose/build_lock.txtar new file mode 100644 index 0000000..e9c2777 --- /dev/null +++ b/test/testdata/compose/build_lock.txtar @@ -0,0 +1,101 @@ +# Lock file is written to .compose/build/plasma-compose.lock after a successful build. +# Uses a deep diamond dependency to verify: transitive packages at multiple levels +# are included, order is topological, and required_by is correct at each level. +# +# Graph: pkg-a โ”€โ”€โ” +# โ”œโ”€โ”€ lib-common โ”€โ”€ lib-base +# pkg-b โ”€โ”€โ”˜ +# +# Topsort order: lib-base โ†’ lib-common โ†’ pkg-a โ†’ pkg-b +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/src/lib-common/ โ€” transitive dep +# $WORK/src/lib-base/ โ€” transitive dep (level 3) +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +exists platform/.compose/build/plasma-compose.lock + +# full content check: verifies topological order, required_by at each level +# (lib-common has two parents: pkg-a and pkg-b), paths and types. +cmp platform/.compose/build/plasma-compose.lock expected_lock.yaml + +-- expected_lock.yaml -- +packages: + - name: lib-base + type: path + url: ../src/lib-base + ref: "" + path: .compose/packages/lib-base/latest + required_by: + - lib-common + - name: lib-common + type: path + url: ../src/lib-common + ref: "" + path: .compose/packages/lib-common/latest + required_by: + - pkg-a + - pkg-b + - name: pkg-a + type: path + url: ../src/pkg-a + ref: "" + path: .compose/packages/pkg-a/latest + required_by: + - root + - name: pkg-b + type: path + url: ../src/pkg-b + ref: "" + path: .compose/packages/pkg-b/latest + required_by: + - root +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + - name: pkg-b + source: + type: path + url: ../src/pkg-b + +-- src/pkg-a/plasma-compose.yaml -- +dependencies: + - name: lib-common + source: + type: path + url: ../src/lib-common + +-- src/pkg-b/plasma-compose.yaml -- +dependencies: + - name: lib-common + source: + type: path + url: ../src/lib-common + +-- src/lib-common/plasma-compose.yaml -- +dependencies: + - name: lib-base + source: + type: path + url: ../src/lib-base + +-- src/lib-base/base.txt -- +lib-base content + +-- src/lib-common/common.txt -- +lib-common content + +-- src/pkg-a/pkg-a.txt -- +pkg-a content + +-- src/pkg-b/pkg-b.txt -- +pkg-b content diff --git a/test/testdata/compose/build_platform_wins.txtar b/test/testdata/compose/build_platform_wins.txtar new file mode 100644 index 0000000..954cf27 --- /dev/null +++ b/test/testdata/compose/build_platform_wins.txtar @@ -0,0 +1,25 @@ +# Platform files override package files (platform is always processed last). +# Both pkg-a and the platform dir have shared.txt โ€” the platform version wins. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +grep 'from platform' platform/.compose/build/shared.txt + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + +-- src/pkg-a/shared.txt -- +from pkg-a + +-- platform/shared.txt -- +from platform diff --git a/test/testdata/compose/download_deep_deps.txtar b/test/testdata/compose/download_deep_deps.txtar new file mode 100644 index 0000000..4ba3376 --- /dev/null +++ b/test/testdata/compose/download_deep_deps.txtar @@ -0,0 +1,46 @@ +# Deep dependency chain: platform โ†’ pkg-a โ†’ lib-common โ†’ lib-base. +# All three transitive packages must appear in the build. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/lib-common/ โ€” transitive dep +# $WORK/src/lib-base/ โ€” transitive dep (level 3) +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +exists platform/.compose/build/pkg-a.txt +exists platform/.compose/build/lib-common.txt +exists platform/.compose/build/lib-base.txt + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + +-- src/pkg-a/pkg-a.txt -- +pkg-a content + +-- src/pkg-a/plasma-compose.yaml -- +dependencies: + - name: lib-common + source: + type: path + url: ../src/lib-common + +-- src/lib-common/lib-common.txt -- +lib-common content + +-- src/lib-common/plasma-compose.yaml -- +dependencies: + - name: lib-base + source: + type: path + url: ../src/lib-base + +-- src/lib-base/lib-base.txt -- +lib-base content diff --git a/test/testdata/compose/download_flat.txtar b/test/testdata/compose/download_flat.txtar new file mode 100644 index 0000000..4e44991 --- /dev/null +++ b/test/testdata/compose/download_flat.txtar @@ -0,0 +1,31 @@ +# Two flat packages (type: path, no nested deps). +# Both packages must appear in the build output. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +exists platform/.compose/build/pkg-a.txt +exists platform/.compose/build/pkg-b.txt + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + - name: pkg-b + source: + type: path + url: ../src/pkg-b + +-- src/pkg-a/pkg-a.txt -- +pkg-a content + +-- src/pkg-b/pkg-b.txt -- +pkg-b content diff --git a/test/testdata/compose/download_version_conflict.txtar b/test/testdata/compose/download_version_conflict.txtar new file mode 100644 index 0000000..abd891b --- /dev/null +++ b/test/testdata/compose/download_version_conflict.txtar @@ -0,0 +1,48 @@ +# Version conflict: pkg-a requires lib-core@v1, pkg-b requires lib-core@v2. +# The compose command must exit with an error. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/src/lib-core/ โ€” shared dep required at different refs +# $WORK/platform/ โ€” domain, compose runs here + +! exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'version conflict' +stdout 'lib-core' + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + - name: pkg-b + source: + type: path + url: ../src/pkg-b + +-- src/pkg-a/pkg-a.txt -- +pkg-a content + +-- src/pkg-a/plasma-compose.yaml -- +dependencies: + - name: lib-core + source: + type: path + url: ../src/lib-core + ref: v1 + +-- src/pkg-b/pkg-b.txt -- +pkg-b content + +-- src/pkg-b/plasma-compose.yaml -- +dependencies: + - name: lib-core + source: + type: path + url: ../src/lib-core + ref: v2 + +-- src/lib-core/lib-core.txt -- +lib-core content diff --git a/test/testdata/compose/strategy_filter.txtar b/test/testdata/compose/strategy_filter.txtar new file mode 100644 index 0000000..30150ae --- /dev/null +++ b/test/testdata/compose/strategy_filter.txtar @@ -0,0 +1,46 @@ +# filter-package-files: only files matching the listed paths are included in the build. +# pkg-a has a filter on config/ โ€” only config/ is taken, scripts/ is dropped. +# pkg-b has no strategy โ€” all files are included. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +# pkg-a: config/ passed the filter +exists platform/.compose/build/config/app.yml + +# pkg-a: scripts/ did not pass the filter โ€” not in the build +! exists platform/.compose/build/scripts/run.sh + +# pkg-b: no strategy โ€” all files included +exists platform/.compose/build/README.md + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + strategy: + - name: filter-package-files + path: + - config/ + - name: pkg-b + source: + type: path + url: ../src/pkg-b + +-- src/pkg-a/config/app.yml -- +app: config + +-- src/pkg-a/scripts/run.sh -- +#!/bin/sh +echo run + +-- src/pkg-b/README.md -- +pkg-b readme diff --git a/test/testdata/compose/strategy_filter_last_wins.txtar b/test/testdata/compose/strategy_filter_last_wins.txtar new file mode 100644 index 0000000..f5070fc --- /dev/null +++ b/test/testdata/compose/strategy_filter_last_wins.txtar @@ -0,0 +1,41 @@ +# filter-package-files + last-wins: pkg-b is declared later and overwrites pkg-a +# for files within its whitelist. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +# pkg-b is declared after pkg-a and has a filter on config/ โ†’ its version wins +grep 'from pkg-b' platform/.compose/build/config/app.yml + +# pkg-b does not include scripts/ in its filter โ†’ scripts/ from pkg-a remains +grep 'from pkg-a' platform/.compose/build/scripts/run.sh + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + - name: pkg-b + source: + type: path + url: ../src/pkg-b + strategy: + - name: filter-package-files + path: + - config/ + +-- src/pkg-a/config/app.yml -- +from pkg-a + +-- src/pkg-a/scripts/run.sh -- +from pkg-a + +-- src/pkg-b/config/app.yml -- +from pkg-b diff --git a/test/testdata/compose/strategy_ignore.txtar b/test/testdata/compose/strategy_ignore.txtar new file mode 100644 index 0000000..39bf4cd --- /dev/null +++ b/test/testdata/compose/strategy_ignore.txtar @@ -0,0 +1,34 @@ +# ignore-extra-package-files: files from the listed paths are skipped, +# even if no other package provides them. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +# config/ is not in the ignore list โ€” included +exists platform/.compose/build/config/app.yml + +# scripts/ is in the ignore list โ€” excluded +! exists platform/.compose/build/scripts/run.sh + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + strategy: + - name: ignore-extra-package-files + path: + - scripts/ + +-- src/pkg-a/config/app.yml -- +app: config + +-- src/pkg-a/scripts/run.sh -- +#!/bin/sh +echo run diff --git a/test/testdata/compose/strategy_ignore_keeps_earlier.txtar b/test/testdata/compose/strategy_ignore_keeps_earlier.txtar new file mode 100644 index 0000000..a7dbbb7 --- /dev/null +++ b/test/testdata/compose/strategy_ignore_keeps_earlier.txtar @@ -0,0 +1,44 @@ +# ignore-extra-package-files: pkg-b ignores config/ โ€” the file from pkg-a is preserved, +# even though pkg-b is declared later and would win by default. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/src/pkg-b/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +# pkg-b ignores config/ โ†’ file from pkg-a is not overwritten +grep 'from pkg-a' platform/.compose/build/config/app.yml + +# pkg-b does not ignore scripts/ โ†’ its version wins (last-wins) +grep 'from pkg-b' platform/.compose/build/scripts/run.sh + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + - name: pkg-b + source: + type: path + url: ../src/pkg-b + strategy: + - name: ignore-extra-package-files + path: + - config/ + +-- src/pkg-a/config/app.yml -- +from pkg-a + +-- src/pkg-a/scripts/run.sh -- +from pkg-a + +-- src/pkg-b/config/app.yml -- +from pkg-b + +-- src/pkg-b/scripts/run.sh -- +from pkg-b diff --git a/test/testdata/compose/strategy_remove_local.txtar b/test/testdata/compose/strategy_remove_local.txtar new file mode 100644 index 0000000..cd827d5 --- /dev/null +++ b/test/testdata/compose/strategy_remove_local.txtar @@ -0,0 +1,37 @@ +# remove-extra-local-files: platform files matching the listed paths are excluded from the build. +# scripts/ is declared in the package strategy โ†’ platform scripts/ is not included in the build. +# +# Layout: +# $WORK/src/pkg-a/ โ€” package source +# $WORK/platform/ โ€” domain, compose runs here + +exec sh -c 'cd platform && launchr compose --interactive=false' +stdout 'Creating composition' +! stderr . + +# config/ is not in the strategy โ†’ platform file is included +exists platform/.compose/build/config/app.yml + +# scripts/ is in the strategy โ†’ platform file is excluded +! exists platform/.compose/build/scripts/deploy.sh + +-- platform/plasma-compose.yaml -- +dependencies: + - name: pkg-a + source: + type: path + url: ../src/pkg-a + strategy: + - name: remove-extra-local-files + path: + - scripts/ + +-- src/pkg-a/README.md -- +pkg-a content + +-- platform/config/app.yml -- +app: platform config + +-- platform/scripts/deploy.sh -- +#!/bin/sh +echo deploy