diff --git a/boot/deps/config/config.go b/boot/deps/config/config.go index ae447b36f..221eb69fd 100644 --- a/boot/deps/config/config.go +++ b/boot/deps/config/config.go @@ -18,21 +18,31 @@ const ( type ModuleConfig struct { ExcludeMeta map[string][]string `yaml:"exclude_meta,omitempty"` - Organization string `yaml:"organization"` - ModuleName string `yaml:"module"` - Version string `yaml:"version"` + Metadata map[string]any `yaml:"metadata,omitempty"` + Publish PublishConfig `yaml:"publish,omitempty"` + Repository string `yaml:"repository,omitempty"` Description string `yaml:"description,omitempty"` License string `yaml:"license,omitempty"` - Repository string `yaml:"repository,omitempty"` + Version string `yaml:"version"` Homepage string `yaml:"homepage,omitempty"` + ModuleName string `yaml:"module"` + Organization string `yaml:"organization"` Keywords []string `yaml:"keywords,omitempty"` Authors []string `yaml:"authors,omitempty"` Include []string `yaml:"include,omitempty"` Exclude []string `yaml:"exclude,omitempty"` - Metadata map[string]any `yaml:"metadata,omitempty"` Embed []string `yaml:"embed,omitempty"` } +type PublishConfig struct { + Profiles PublishProfilesConfig `yaml:"profiles,omitempty"` +} + +type PublishProfilesConfig struct { + Enabled *bool `yaml:"enabled,omitempty"` + Source string `yaml:"source,omitempty"` +} + func Load(dir string) (*ModuleConfig, error) { path := filepath.Join(dir, DefaultConfigFile) return LoadFrom(path) diff --git a/boot/deps/config/config_test.go b/boot/deps/config/config_test.go index 5f6547313..0d95e3de1 100644 --- a/boot/deps/config/config_test.go +++ b/boot/deps/config/config_test.go @@ -294,6 +294,25 @@ metadata: assert.Equal(t, "debug", loggerMap["level"]) } +func TestLoad_WithPublishProfileConfig(t *testing.T) { + dir := t.TempDir() + content := ` +organization: myorg +module: mymod +publish: + profiles: + enabled: false + source: config/profiles.yaml +` + require.NoError(t, os.WriteFile(filepath.Join(dir, DefaultConfigFile), []byte(content), 0644)) + + cfg, err := Load(dir) + require.NoError(t, err) + require.NotNil(t, cfg.Publish.Profiles.Enabled) + assert.False(t, *cfg.Publish.Profiles.Enabled) + assert.Equal(t, "config/profiles.yaml", cfg.Publish.Profiles.Source) +} + // --- EntryExcludes --- func TestEntryExcludes(t *testing.T) { diff --git a/cmd/wippy/cmd/publish.go b/cmd/wippy/cmd/publish.go index 53facf5e3..bf834b1d9 100644 --- a/cmd/wippy/cmd/publish.go +++ b/cmd/wippy/cmd/publish.go @@ -514,6 +514,9 @@ func packModule(ctx context.Context, app *appinit.Context, cfg *config.ModuleCon } metadata[trimmed] = value } + if err := addPublishedRuntimeProfileMetadata(metadata, srcDir, cfg.Publish.Profiles); err != nil { + return nil, NewPublishConfigError(err) + } packWriter := wapp.NewWriter() diff --git a/cmd/wippy/cmd/publish_profiles.go b/cmd/wippy/cmd/publish_profiles.go new file mode 100644 index 000000000..b62f76540 --- /dev/null +++ b/cmd/wippy/cmd/publish_profiles.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/wippyai/runtime/api/attrs" + "github.com/wippyai/runtime/api/boot" + "github.com/wippyai/runtime/boot/deps/config" + "github.com/wippyai/runtime/cmd/internal/bootconfig" +) + +const ( + publishRuntimeMetadataKey = "runtime" + publishRuntimeProfilesMetadataKey = "profiles" + publishRuntimeVarsMetadataKey = "vars" +) + +func addPublishedRuntimeProfileMetadata(metadata attrs.Bag, configDir string, profileCfg config.PublishProfilesConfig) error { + if hasNestedRuntimeMetadata(metadata, publishRuntimeProfilesMetadataKey) { + return fmt.Errorf("wippy.yaml metadata.runtime.profiles is not supported; declare publishable profiles in .wippy.yaml or publish.profiles.source") + } + + if profileCfg.Enabled != nil && !*profileCfg.Enabled { + return nil + } + + source := strings.TrimSpace(profileCfg.Source) + if source == "" { + source = defaultConfigFile + } + if !filepath.IsAbs(source) { + source = filepath.Join(configDir, source) + } + + cfg, err := bootconfig.Load(source) + if err != nil { + return fmt.Errorf("load runtime profile source %s: %w", source, err) + } + if cfg == nil { + return nil + } + + profiles, err := runtimeProfilesFromConfig(cfg) + if err != nil { + return err + } + if len(profiles) == 0 { + return nil + } + + if hasNestedRuntimeMetadata(metadata, publishRuntimeVarsMetadataKey) { + return fmt.Errorf("runtime vars are defined in both %s and wippy.yaml metadata; keep profile variables in the profile source", source) + } + + runtime, err := runtimeMetadataMap(metadata) + if err != nil { + return err + } + runtime[publishRuntimeProfilesMetadataKey] = profiles + + vars := runtimeSectionFromConfig(cfg, publishRuntimeVarsMetadataKey) + if len(vars) > 0 { + runtime[publishRuntimeVarsMetadataKey] = vars + } + + return nil +} + +func runtimeProfilesFromConfig(cfg boot.Config) (map[string]any, error) { + profiles := make(map[string]any) + if cfg == nil { + return profiles, nil + } + + for _, key := range cfg.Keys() { + if !strings.HasPrefix(key, "profiles.") { + continue + } + + rest := strings.TrimPrefix(key, "profiles.") + profileName, rest, ok := strings.Cut(rest, ".") + if !ok || profileName == "" || rest == "" { + return nil, fmt.Errorf("invalid runtime profile key %q", key) + } + section, subkey, ok := strings.Cut(rest, ".") + if !ok || section == "" || subkey == "" { + return nil, fmt.Errorf("invalid runtime profile key %q", key) + } + + profileMap, ok := profiles[profileName].(map[string]any) + if !ok { + profileMap = make(map[string]any) + profiles[profileName] = profileMap + } + + sectionMap, ok := profileMap[section].(map[string]any) + if !ok { + sectionMap = make(map[string]any) + profileMap[section] = sectionMap + } + + value, _ := cfg.Get(key) + sectionMap[subkey] = value + } + + return profiles, nil +} + +func runtimeSectionFromConfig(cfg boot.Config, section string) map[string]any { + values := make(map[string]any) + if cfg == nil { + return values + } + + prefix := section + "." + for _, key := range cfg.Keys() { + if !strings.HasPrefix(key, prefix) { + continue + } + value, _ := cfg.Get(key) + values[strings.TrimPrefix(key, prefix)] = value + } + return values +} + +func runtimeMetadataMap(metadata attrs.Bag) (map[string]any, error) { + if metadata == nil { + return nil, fmt.Errorf("metadata bag is nil") + } + + raw, exists := metadata[publishRuntimeMetadataKey] + if !exists { + runtime := make(map[string]any) + metadata[publishRuntimeMetadataKey] = runtime + return runtime, nil + } + + switch typed := raw.(type) { + case map[string]any: + return typed, nil + case attrs.Bag: + runtime := map[string]any(typed) + metadata[publishRuntimeMetadataKey] = runtime + return runtime, nil + default: + return nil, fmt.Errorf("wippy.yaml metadata.runtime must be a map when publishing runtime profiles") + } +} + +func hasNestedRuntimeMetadata(metadata attrs.Bag, nestedKey string) bool { + if metadata == nil { + return false + } + + dotted := publishRuntimeMetadataKey + "." + nestedKey + for key := range metadata { + if key == dotted || strings.HasPrefix(key, dotted+".") { + return true + } + } + + raw, exists := metadata[publishRuntimeMetadataKey] + if !exists { + return false + } + switch typed := raw.(type) { + case map[string]any: + _, exists = typed[nestedKey] + return exists + case attrs.Bag: + _, exists = typed[nestedKey] + return exists + default: + return false + } +} diff --git a/cmd/wippy/cmd/publish_profiles_test.go b/cmd/wippy/cmd/publish_profiles_test.go new file mode 100644 index 000000000..43ef038b7 --- /dev/null +++ b/cmd/wippy/cmd/publish_profiles_test.go @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/wippyai/runtime/api/attrs" + "github.com/wippyai/runtime/boot/deps/config" +) + +func TestAddPublishedRuntimeProfileMetadataExportsAllAppProfiles(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +vars: + db_host: localhost +profiles: + local: + override: + "app:db:kind": db.sql.sqlite + prod: + vars: + db_host: db.internal + override: + "app:db:kind": db.sql.postgres + disable: + namespaces: + add: + - app.dev.** +`) + + metadata := attrs.Bag{ + "runtime": map[string]any{ + "logger": map[string]any{ + "level": "info", + }, + }, + } + + require.NoError(t, addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{})) + + runtime := requireMap(t, metadata["runtime"]) + require.Equal(t, map[string]any{"level": "info"}, requireMap(t, runtime["logger"])) + + vars := requireMap(t, runtime["vars"]) + require.Equal(t, "localhost", vars["db_host"]) + + profiles := requireMap(t, runtime["profiles"]) + local := requireMap(t, profiles["local"]) + localOverride := requireMap(t, local["override"]) + require.Equal(t, "db.sql.sqlite", localOverride["app:db:kind"]) + + prod := requireMap(t, profiles["prod"]) + prodVars := requireMap(t, prod["vars"]) + require.Equal(t, "db.internal", prodVars["db_host"]) + prodOverride := requireMap(t, prod["override"]) + require.Equal(t, "db.sql.postgres", prodOverride["app:db:kind"]) + prodDisable := requireMap(t, prod["disable"]) + require.Equal(t, []any{"app.dev.**"}, prodDisable["namespaces.add"]) +} + +func TestAddPublishedRuntimeProfileMetadataUsesConfiguredSource(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, "profiles/public.yaml", `version: "1.0" +profiles: + public: + override: + "app:http:addr": ":8080" +`) + + metadata := attrs.Bag{} + require.NoError(t, addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{Source: "profiles/public.yaml"})) + + runtime := requireMap(t, metadata["runtime"]) + profiles := requireMap(t, runtime["profiles"]) + require.Contains(t, profiles, "public") +} + +func TestAddPublishedRuntimeProfileMetadataDisabled(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +profiles: + local: + override: + "app:db:kind": db.sql.sqlite +`) + + enabled := false + metadata := attrs.Bag{} + require.NoError(t, addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{Enabled: &enabled})) + require.NotContains(t, metadata, "runtime") +} + +func TestAddPublishedRuntimeProfileMetadataDisabledErrorsOnMetadataProfiles(t *testing.T) { + dir := t.TempDir() + + enabled := false + metadata := attrs.Bag{ + "runtime": map[string]any{ + "profiles": map[string]any{ + "local": map[string]any{}, + }, + }, + } + + err := addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{Enabled: &enabled}) + require.Error(t, err) + require.Contains(t, err.Error(), "metadata.runtime.profiles is not supported") +} + +func TestAddPublishedRuntimeProfileMetadataErrorsOnMetadataProfiles(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +logger: + level: debug +`) + + metadata := attrs.Bag{ + "runtime": map[string]any{ + "profiles": map[string]any{ + "public": map[string]any{ + "override": map[string]any{ + "app:db:kind": "db.sql.postgres", + }, + }, + }, + }, + } + + err := addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{}) + require.Error(t, err) + require.Contains(t, err.Error(), "metadata.runtime.profiles is not supported") +} + +func TestAddPublishedRuntimeProfileMetadataErrorsOnDottedMetadataProfiles(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +profiles: + local: + override: + "app:db:kind": db.sql.sqlite +`) + + metadata := attrs.Bag{ + "runtime.profiles.prod.override.app:db:kind": "db.sql.postgres", + } + + err := addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{}) + require.Error(t, err) + require.Contains(t, err.Error(), "metadata.runtime.profiles is not supported") +} + +func TestAddPublishedRuntimeProfileMetadataErrorsOnMetadataVarsWithSourceProfiles(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +vars: + port: 8080 +profiles: + local: + override: + "app:http:addr": ":${port}" +`) + + metadata := attrs.Bag{ + "runtime": map[string]any{ + "vars": map[string]any{"port": 9000}, + }, + } + + err := addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{}) + require.Error(t, err) + require.Contains(t, err.Error(), "runtime vars are defined in both") +} + +func TestAddPublishedRuntimeProfileMetadataNoProfilesLeavesMetadata(t *testing.T) { + dir := t.TempDir() + writeRuntimeProfileConfig(t, dir, `.wippy.yaml`, `version: "1.0" +logger: + level: debug +`) + + metadata := attrs.Bag{"name": "app"} + require.NoError(t, addPublishedRuntimeProfileMetadata(metadata, dir, config.PublishProfilesConfig{})) + require.Equal(t, attrs.Bag{"name": "app"}, metadata) +} + +func writeRuntimeProfileConfig(t *testing.T, dir, rel, body string) { + t.Helper() + path := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(body), 0o644)) +} + +func requireMap(t *testing.T, value any) map[string]any { + t.Helper() + typed, ok := value.(map[string]any) + require.Truef(t, ok, "value has type %T", value) + return typed +} diff --git a/cmd/wippy/cmd/run_pack_metadata.go b/cmd/wippy/cmd/run_pack_metadata.go index 2ea3be3ba..42d9df694 100644 --- a/cmd/wippy/cmd/run_pack_metadata.go +++ b/cmd/wippy/cmd/run_pack_metadata.go @@ -42,7 +42,8 @@ func loadPackRuntimeDefaults(packPath string, logger *zap.Logger) (boot.Config, func loadPackRuntimeDefaultsFromFiles(packFiles []string, logger *zap.Logger) (boot.Config, error) { var merged boot.Config - for _, packPath := range packFiles { + mainPackIndex := lastWappPackIndex(packFiles) + for idx, packPath := range packFiles { if !hasWappExtension(packPath) { continue } @@ -51,6 +52,9 @@ func loadPackRuntimeDefaultsFromFiles(packFiles []string, logger *zap.Logger) (b if err != nil { return nil, err } + if idx != mainPackIndex { + cfg = withoutAppRuntimeProfileConfig(cfg) + } if cfg != nil { merged = bootconfig.Merge(merged, cfg) @@ -60,6 +64,45 @@ func loadPackRuntimeDefaultsFromFiles(packFiles []string, logger *zap.Logger) (b return merged, nil } +func lastWappPackIndex(packFiles []string) int { + for idx := len(packFiles) - 1; idx >= 0; idx-- { + if hasWappExtension(packFiles[idx]) { + return idx + } + } + return -1 +} + +func withoutAppRuntimeProfileConfig(cfg boot.Config) boot.Config { + if cfg == nil { + return nil + } + + sections := make(map[string]map[string]any) + for _, key := range cfg.Keys() { + section, subkey, ok := strings.Cut(key, ".") + if !ok || section == "" || subkey == "" || section == "profiles" || section == "vars" { + continue + } + + if sections[section] == nil { + sections[section] = make(map[string]any) + } + value, _ := cfg.Get(key) + sections[section][subkey] = value + } + + if len(sections) == 0 { + return nil + } + + opts := make([]boot.ConfigOption, 0, len(sections)) + for section, values := range sections { + opts = append(opts, boot.WithSection(section, values)) + } + return boot.NewConfig(opts...) +} + // runtimeConfigFromPackMetadata extracts runtime.* metadata keys and builds a boot config. // Example supported keys: runtime.lsp.enabled=true func runtimeConfigFromPackMetadata(metadata wapp.Metadata, logger *zap.Logger) boot.Config { diff --git a/cmd/wippy/cmd/run_pack_metadata_test.go b/cmd/wippy/cmd/run_pack_metadata_test.go index 97f6d3386..c9bf8f437 100644 --- a/cmd/wippy/cmd/run_pack_metadata_test.go +++ b/cmd/wippy/cmd/run_pack_metadata_test.go @@ -89,6 +89,32 @@ func TestLoadPackRuntimeDefaultsFromFiles_MergeOrder(t *testing.T) { require.Equal(t, "info", cfg.GetString("logger.level", "")) } +func TestLoadPackRuntimeDefaultsFromFiles_ProfileConfigComesOnlyFromMainPack(t *testing.T) { + tmpDir := t.TempDir() + + depPack := filepath.Join(tmpDir, "dep.wapp") + require.NoError(t, writeTestPack(depPack, wapp.Metadata{ + "runtime.logger.level": "info", + "runtime.vars.dep_only": "leaked", + "runtime.profiles.dep.override.app:db:kind": "db.sql.sqlite", + })) + + mainPack := filepath.Join(tmpDir, "main.wapp") + require.NoError(t, writeTestPack(mainPack, wapp.Metadata{ + "runtime.vars.main_only": "kept", + "runtime.profiles.main.override.app:db:kind": "db.sql.postgres", + })) + + cfg, err := loadPackRuntimeDefaultsFromFiles([]string{depPack, mainPack}, zap.NewNop()) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, "info", cfg.GetString("logger.level", "")) + require.Equal(t, "", cfg.GetString("vars.dep_only", "")) + require.Equal(t, "kept", cfg.GetString("vars.main_only", "")) + require.Equal(t, "", cfg.GetString("profiles.dep.override.app:db:kind", "")) + require.Equal(t, "db.sql.postgres", cfg.GetString("profiles.main.override.app:db:kind", "")) +} + func writeTestPack(path string, metadata wapp.Metadata) error { file, err := os.Create(path) if err != nil { diff --git a/cmd/wippy/cmd/run_test.go b/cmd/wippy/cmd/run_test.go index 451d3639e..19fc54bfe 100644 --- a/cmd/wippy/cmd/run_test.go +++ b/cmd/wippy/cmd/run_test.go @@ -247,3 +247,38 @@ func TestLoadRuntimeConfig_ProfileFromPackDefaults(t *testing.T) { require.NoError(t, err) require.Equal(t, "db.sql.postgres", cfg.GetString("override.app:db:kind", "")) } + +func TestLoadRuntimeConfig_LocalProfileOverridesPackProfile(t *testing.T) { + tempDir := t.TempDir() + cfgPath := filepath.Join(tempDir, "wippy.yaml") + cfgBody := []byte(`version: "1.0" +profiles: + pg: + override: + "app:db:kind": db.sql.local +`) + require.NoError(t, os.WriteFile(cfgPath, cfgBody, 0o644)) + + prevConfigFile := configFile + prevProfiler := profiler + configFile = cfgPath + profiler = false + t.Cleanup(func() { + configFile = prevConfigFile + profiler = prevProfiler + }) + + runtimeDefaults := boot.NewConfig( + boot.WithSection("profiles", map[string]any{ + "pg.override.app:db:kind": "db.sql.postgres", + }), + ) + + cmd := &cobra.Command{} + cmd.Flags().StringArray("profile", nil, "") + require.NoError(t, cmd.Flags().Set("profile", "pg")) + + cfg, err := loadRuntimeConfigWithDefaults(cmd, zap.NewNop(), runtimeDefaults) + require.NoError(t, err) + require.Equal(t, "db.sql.local", cfg.GetString("override.app:db:kind", "")) +}