Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,65 @@ shutdown:
timeout: 30s
```

### Runtime configuration composition

Dependency ranges remain `ns.dependency` registry entries and exact portable
versions remain in `wippy.lock`. For local development, a runtime profile can
replace a locked module with a checkout without changing the lock:

Pass `--config` more than once to compose any set of runtime configuration
files. Files use the same schema and merge from left to right, so later files
override matching leaves while preserving unrelated settings:

```sh
wippy run \
--config .wippy.yaml \
--config .wippy.dev.yaml \
--config config/postgres-history.yaml \
--config .wippy.workspace.yaml \
--profile workspace
```

Every explicitly named file must exist. With no `--config`, `.wippy.yaml`
remains the optional default. The first file defines the project directory used
to resolve relative runtime paths. Profiles are applied after all files merge,
in requested order, and CLI overrides such as `--set` are applied last.

Configuration filenames have no reserved meaning. Keep private or
machine-specific files out of version control using the repository's normal
ignore policy. For example, the final file above could contain:

```yaml
version: "1.0"

profiles:
workspace:
workspace:
replacements:
wippy/runtime: ../runtime
```

Use the same composition for commands that load dependencies:

```sh
wippy update --config .wippy.yaml --config .wippy.workspace.yaml --profile workspace
wippy install --config .wippy.yaml --config .wippy.workspace.yaml --profile workspace
```

The runtime configuration stack is not a publishing input, and the `workspace`
section is never exported in module metadata. A module can also restrict which
non-workspace runtime profiles are published from its `wippy.yaml` manifest:

```yaml
publish:
profiles:
include: [production]
```

Existing `replacements:` in `wippy.lock` remain readable for compatibility but
emit a deprecation warning. Move them to any runtime configuration file; new
workspace replacements should not be added to the portable lock.

## Requirements

- Go 1.26+
Expand Down
7 changes: 7 additions & 0 deletions boot/components/core/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package core

import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
Expand All @@ -16,6 +17,7 @@ import (
regapi "github.com/wippyai/runtime/api/registry"
bootpkg "github.com/wippyai/runtime/boot"
hubdeps "github.com/wippyai/runtime/boot/deps/hub"
"github.com/wippyai/runtime/boot/deps/lock"
"github.com/wippyai/runtime/system/registry"
regexp "github.com/wippyai/runtime/system/registry/expansion"
historymem "github.com/wippyai/runtime/system/registry/history/memory"
Expand Down Expand Up @@ -231,6 +233,11 @@ func newDependencyHandler(
Logger: logger,
Resolver: resolver,
}
workspaceReplacements, err := lock.WorkspaceReplacements(cfg)
if err != nil {
return nil, fmt.Errorf("load workspace replacements: %w", err)
}
opts.WorkspaceReplacements = workspaceReplacements

if registryCfg != nil {
opts.ResolveTimeout = registryCfg.GetDuration(RegistryDependencyResolveTimeout, 0)
Expand Down
5 changes: 3 additions & 2 deletions boot/deps/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ type PublishConfig struct {
}

type PublishProfilesConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
Source string `yaml:"source,omitempty"`
Enabled *bool `yaml:"enabled,omitempty"`
Source string `yaml:"source,omitempty"`
Include []string `yaml:"include,omitempty"`
}

func Load(dir string) (*ModuleConfig, error) {
Expand Down
19 changes: 19 additions & 0 deletions boot/deps/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ publish:
profiles:
enabled: false
source: config/profiles.yaml
include: [production, staging]
`
require.NoError(t, os.WriteFile(filepath.Join(dir, DefaultConfigFile), []byte(content), 0644))

Expand All @@ -311,6 +312,24 @@ publish:
require.NotNil(t, cfg.Publish.Profiles.Enabled)
assert.False(t, *cfg.Publish.Profiles.Enabled)
assert.Equal(t, "config/profiles.yaml", cfg.Publish.Profiles.Source)
assert.Equal(t, []string{"production", "staging"}, cfg.Publish.Profiles.Include)
}

func TestLoad_WithExplicitEmptyPublishProfileInclude(t *testing.T) {
dir := t.TempDir()
content := `
organization: myorg
module: mymod
publish:
profiles:
include: []
`
require.NoError(t, os.WriteFile(filepath.Join(dir, DefaultConfigFile), []byte(content), 0o644))

cfg, err := Load(dir)
require.NoError(t, err)
require.NotNil(t, cfg.Publish.Profiles.Include)
assert.Empty(t, cfg.Publish.Profiles.Include)
}

// --- EntryExcludes ---
Expand Down
85 changes: 43 additions & 42 deletions boot/deps/hub/dependency_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,23 @@ const (
)

type DependencyHandlerOptions struct {
Hub HubClient
Logger *zap.Logger
Resolver regapi.DependencyResolver
LockPath string
VendorDir string
ResolveTimeout time.Duration
DownloadTimeout time.Duration
Hub HubClient
Resolver regapi.DependencyResolver
Logger *zap.Logger
LockPath string
VendorDir string
WorkspaceReplacements []lock.Replacement
ResolveTimeout time.Duration
DownloadTimeout time.Duration
}

type DependencyHandler struct {
hub HubClient
resolver regapi.DependencyResolver
manifestCache *ManifestCache
logger *zap.Logger
resolver regapi.DependencyResolver
lockPath string
lock *lock.Lock
replacements map[string]lock.Replacement
vendorDir string
resolveTimeout time.Duration
downloadTimeout time.Duration
Expand Down Expand Up @@ -121,26 +123,41 @@ func NewDependencyHandler(opts DependencyHandlerOptions) (*DependencyHandler, er
}
}

vendorDir := opts.VendorDir
if vendorDir == "" && lockPath != "" {
if lockObj, err := lock.New(lockPath); err == nil {
lockDir := filepath.Dir(lockObj.Path())
vendorDir = filepath.Join(lockDir, lockObj.GetVendorPath())
var lockObj *lock.Lock
if lockPath != "" {
var err error
lockObj, err = lock.New(lockPath, lock.WithWorkspaceReplacements(opts.WorkspaceReplacements))
if err != nil {
return nil, err
}
}

vendorDir := opts.VendorDir
if vendorDir == "" && lockObj != nil {
lockDir := filepath.Dir(lockObj.Path())
vendorDir = filepath.Join(lockDir, lockObj.GetVendorPath())
}
if vendorDir == "" {
vendorDir = filepath.Join(".wippy", "vendor")
}

replacements := make(map[string]lock.Replacement)
if lockObj != nil {
for _, replacement := range lockObj.GetReplacements() {
replacements[replacement.From] = replacement
}
}

return &DependencyHandler{
hub: client,
manifestCache: NewManifestCache(client),
logger: logger,
resolver: opts.Resolver,
lockPath: lockPath,
vendorDir: vendorDir,
resolveTimeout: opts.ResolveTimeout,
downloadTimeout: opts.DownloadTimeout,
lock: lockObj,
replacements: replacements,
}, nil
}

Expand Down Expand Up @@ -842,18 +859,14 @@ func validateRootDependencyComponents(deps []desiredDependency, operationID rega

func (h *DependencyHandler) installedModuleVersions(ctx context.Context, transcoder payload.Transcoder, snapshot regapi.State) (map[string]string, error) {
versions := snapshotModuleVersions(snapshot)
if h.lockPath == "" {
return versions, nil
}
lockObj, err := lock.New(h.lockPath)
if err != nil {
if h.lock == nil {
return versions, nil
}
installedRoots, err := rootDependencyModules(ctx, transcoder, snapshot)
if err != nil {
return nil, err
}
for _, mod := range lockObj.GetModules() {
for _, mod := range h.lock.GetModules() {
if mod.Name == "" || mod.Version == "" {
continue
}
Expand Down Expand Up @@ -1957,20 +1970,16 @@ func (h *DependencyHandler) freshDownloadInfo(ctx context.Context, mod ResolvedM
}

func (h *DependencyHandler) replacementPath(moduleName string) (string, bool) {
if h.lockPath == "" {
return "", false
}
lockObj, err := lock.New(h.lockPath)
if err != nil {
return "", false
}
replacement, ok := lockObj.GetReplacement(moduleName)
replacement, ok := h.replacements[moduleName]
if !ok || strings.TrimSpace(replacement.To) == "" {
return "", false
}
path := replacement.To
if !filepath.IsAbs(path) {
path = filepath.Join(filepath.Dir(lockObj.Path()), path)
if h.lock == nil {
return "", false
}
path = filepath.Join(filepath.Dir(h.lock.Path()), path)
}
return path, true
}
Expand Down Expand Up @@ -2014,14 +2023,10 @@ func topLevelYAMLScalar(data []byte, key string) string {
}

func (h *DependencyHandler) shouldUnpackModules() bool {
if h.lockPath == "" {
if h.lock == nil {
return false
}
lockObj, err := lock.New(h.lockPath)
if err != nil {
return false
}
return lockObj.ShouldUnpackModules()
return h.lock.ShouldUnpackModules()
}

func (h *DependencyHandler) moduleUsesDirectoryMode(moduleName string) bool {
Expand Down Expand Up @@ -2171,14 +2176,10 @@ func sha256FileHex(path string) (string, error) {
}

func (h *DependencyHandler) lockedModuleDigests() map[string]string {
if h.lockPath == "" {
return nil
}
lockObj, err := lock.New(h.lockPath)
if err != nil {
if h.lock == nil {
return nil
}
modules := lockObj.GetModules()
modules := h.lock.GetModules()
if len(modules) == 0 {
return nil
}
Expand Down
35 changes: 35 additions & 0 deletions boot/deps/hub/resolution_hardening_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/wippyai/runtime/api/payload"
regapi "github.com/wippyai/runtime/api/registry"
"github.com/wippyai/runtime/boot/deps/graph"
"github.com/wippyai/runtime/boot/deps/lock"
"github.com/wippyai/wapp"
"go.uber.org/zap"
)
Expand All @@ -25,6 +26,40 @@ func hardeningRoot(id, component, version string) regapi.Entry {
}
}

func TestReplacementResolutionRevalidatesCurrentTree(t *testing.T) {
tmpDir := t.TempDir()
lockPath := filepath.Join(tmpDir, lock.DefaultFilename)
replacementPath := filepath.Join(tmpDir, "local-http")
require.NoError(t, os.MkdirAll(replacementPath, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(replacementPath, "entry.yaml"), []byte("first"), 0o600))
require.NoError(t, os.WriteFile(lockPath, []byte("directories:\n modules: .wippy\n src: ./src\n"), 0o600))

handler, err := NewDependencyHandler(DependencyHandlerOptions{
Hub: &fakeHub{},
Logger: zap.NewNop(),
LockPath: lockPath,
WorkspaceReplacements: []lock.Replacement{
{From: "acme/http", To: replacementPath},
},
})
require.NoError(t, err)

digest, size, err := digestDirectoryTree(replacementPath)
require.NoError(t, err)
module := ResolvedModule{
Org: "acme",
Name: "http",
Version: "v1.0.0",
Source: moduleSourceReplacementTreeV1,
Digest: digest,
SizeBytes: size,
}
require.True(t, handler.hasCurrentUnpackedModule(module))

require.NoError(t, os.WriteFile(filepath.Join(replacementPath, "entry.yaml"), []byte("changed"), 0o600))
require.False(t, handler.hasCurrentUnpackedModule(module), "history must not trust stale entry metadata after replacement content changes")
}

func hardeningModuleEntry(id, module, version string) regapi.Entry {
return regapi.Entry{
ID: regapi.ParseID(id),
Expand Down
11 changes: 10 additions & 1 deletion boot/deps/hub/unpack_effect.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"

regapi "github.com/wippyai/runtime/api/registry"
Expand Down Expand Up @@ -123,7 +124,15 @@ func (h *DependencyHandler) buildModuleFilesystemEffect(
}

func (h *DependencyHandler) hasCurrentUnpackedModule(mod ResolvedModule) bool {
if !h.shouldUnpackModules() || mod.Source == moduleSourceReplacementTreeV1 {
if mod.Source == moduleSourceReplacementTreeV1 {
path, ok := h.replacementPath(mod.Org + "/" + mod.Name)
if !ok {
return false
}
digest, size, err := digestDirectoryTree(path)
return err == nil && strings.EqualFold(digest, mod.Digest) && (mod.SizeBytes == 0 || size == mod.SizeBytes)
}
if !h.shouldUnpackModules() {
return true
}
name := graph.Name{Organization: mod.Org, Module: mod.Name}
Expand Down
4 changes: 4 additions & 0 deletions boot/deps/lock/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ func NewReplacementPathNotExistError(path string) apierror.Error {
return apierror.New(apierror.NotFound, fmt.Sprintf("replacement path %q does not exist", path))
}

func NewReplacementPathNotDirectoryError(path string) apierror.Error {
return apierror.New(apierror.Invalid, fmt.Sprintf("replacement path %q is not a directory", path))
}

func NewCheckReplacementPathError(path string, cause error) apierror.Error {
return apierror.New(apierror.Internal, fmt.Sprintf("failed to check replacement path %q", path)).WithCause(cause)
}
Expand Down
Loading