diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c5f0ba --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Guidelines + +**Documentation Updates**: When making code changes, ALWAYS update this documentation file to reflect: +- New architectural patterns or changes to existing ones +- Bug fixes with explanations of the root cause and solution +- New development patterns or conventions +- Changes to key interfaces or components +- Updates to build processes or commands + +This ensures the documentation stays current and helps future developers understand the codebase evolution. + +## Commands + +### Build and Development +- `make build` - Build the launchr binary to `bin/launchr` +- `make` - Show help (default target) +- `make DEBUG=1` - Build with debug symbols for use with dlv debugger +- `make deps` - Fetch go dependencies +- `make test` - Run all tests +- `make lint` - Run golangci-lint with fixes +- `make install` - Install globally to `$GOPATH/bin` +- `go generate ./...` - Generate code (runs as part of build) + +### Usage +- `bin/launchr --help` - Show help +- `bin/launchr --version` - Show version +- `bin/launchr build` - Build custom launchr with plugins + +## Architecture Overview + +Launchr is a CLI action runner that executes tasks defined in YAML files across multiple runtimes (containers, shell, plugins). The architecture is built around several core patterns: + +### Core Systems + +**Plugin Architecture**: Weight-based plugin system where plugins register via `init()` functions and implement lifecycle interfaces like `OnAppInitPlugin`, `CobraPlugin`, `DiscoveryPlugin`. Plugins are registered globally through `launchr.RegisterPlugin()`. + +**Plugin Hierarchies**: Plugins can have sub-plugins (module subpaths). During the build process, when checking for module replacements, the system must distinguish between a plugin and its sub-plugins. The fix ensures that exact path matches (`p.Path == repl`) are not skipped, only true subpath relationships (`p.Path != repl && strings.HasPrefix(p.Path, repl)`). + +**Service-Oriented Design**: Core services (Config, Manager, PluginManager) are registered and retrieved through `ServiceManager` via `app.Services().Add()` and `app.Services().Get()`. `App.AddService()`/`App.GetService()` are deprecated. Services can implement `ServiceCreate` for lazy initialization. All services implement the `Service` interface. + +**Runtime Strategy Pattern**: Multiple runtime implementations (shell, container, plugin) that implement the `Runtime` interface with `Init()`, `Execute()`, `Close()`, `Clone()` methods. + +### Key Components + +- **Action System** (`pkg/action/`): Core action entity with manager handling lifecycle, discovery, validation, and execution +- **Runtime System**: Shell, Container (Docker/K8s), and Plugin runtime implementations +- **Discovery System**: YAML and embedded filesystem action discovery with extensible discovery plugins +- **Configuration System**: YAML-based config with dot-notation access and reflection-based caching +- **Plugin System** (`plugins/`): Core plugins for naming, CLI integration, discovery, value processing, and verbosity + +### Important Types and Interfaces + +- `App`: Global application state management (interface) +- `Plugin`: Base plugin interface with `PluginInfo()` and lifecycle hooks +- `Service`: Dependency injection with `ServiceInfo()` (interface) +- `ServiceCreate`: Lazy service initialization (interface) +- `ServiceManager`: DI container with `Add()`/`Get()` methods (struct) +- `Runtime`: Action execution environment abstraction (interface) +- `Manager`: Action management and orchestration (type alias for `*actionManagerMap`) +- `Config`: YAML-based configuration (type alias for `*config`) +- `PluginManager`: Plugin registry (type alias for `pluginManagerMap`) +- `PluginInfo`: Plugin metadata - has `Weight` (public) and auto-set private `pkgPath`/`typeName` +- `ServiceInfo`: Service metadata - has auto-set private `pkgPath`/`typeName` + +### Key Files + +- `app.go`: Main application implementation with plugin and service management +- `types.go`: Type aliases to reduce external dependencies +- `pkg/action/manager.go`: Action lifecycle management +- `pkg/action/action.go`: Core action entity +- `internal/launchr/config.go`: Configuration system +- `plugins/default.go`: Plugin registration + +### Development Patterns + +- Type aliases in `types.go` for clean interfaces +- Error handling with custom types and `errors.Is()` support +- Go template integration for dynamic action configuration +- Mutex-protected operations for concurrency safety +- `fs.FS` interface for filesystem abstraction +- JSON Schema validation for inputs and configuration +- **Plugin Replacement Logic**: In `plugins/builder/environment.go`, when downloading plugins during build, the system handles replaced modules via `getOrRequire()`: + 1. **Subpath Detection**: Skip plugins that are subpaths of replaced modules (`p.Path != repl && strings.HasPrefix(p.Path, repl)`) using labeled loop control + 2. **Replaced Module Handling**: For modules that exactly match a replacement entry, `go get` is called without the version suffix (just the module path). This is necessary because when a module is replaced with a local path, `go get module@version` tries to resolve the version from the network, which fails. Stripping the version lets Go resolve the module from the local replacement. + 3. **Non-replaced Modules**: Downloaded normally with `go get module@version` + + This prevents downloading dependencies for sub-plugins when their parent module is replaced, ensures replaced modules are properly required in go.mod, and avoids "replaced but not required" errors. +- **Environment Variable Handling**: Different runtimes handle environment variables differently: + - **Shell Runtime** (`pkg/action/runtime.shell.go:47`): Automatically inherits all host environment variables using `append(os.Environ(), rt.Shell.Env...)`, making all host variables available to the script. + - **Container Runtime** (`pkg/action/runtime.container.go:527`): Only passes explicitly defined environment variables from `runtime.env` in action YAML. Host environment variables must be explicitly referenced using `${VAR}` expansion syntax during action loading (`pkg/action/loader.go:59`). + - **Environment Variable Expansion**: Uses `os.Expand()` to replace `${VAR}` patterns with host environment values during action loading, before container creation. + - **EnvSlice Type** (`pkg/action/yaml.def.go:323`): Supports both YAML map (`KEY: value`) and array (`- KEY=value`) syntax for environment variable definitions. + +### Execution Flow + +1. Plugin registration and service initialization +2. Action discovery through registered discovery plugins +3. Cobra command generation from discovered actions +4. Multi-stage input validation (runtime flags, persistent flags, action parameters) +5. Runtime-specific execution with cleanup +6. Support for async action execution with status tracking + +### Environment Variables + +- `LAUNCHR_ACTIONS_PATH`: Path for action discovery (default: current directory) diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/docs/DEVELOPER_GUIDELINES.md b/docs/DEVELOPER_GUIDELINES.md new file mode 100644 index 0000000..c8d1836 --- /dev/null +++ b/docs/DEVELOPER_GUIDELINES.md @@ -0,0 +1,575 @@ +# Developer Guidelines + +This document provides comprehensive guidelines for developers working on the Launchr project. + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [Code Style and Conventions](#code-style-and-conventions) +3. [Architecture Guidelines](#architecture-guidelines) +4. [Logging Guidelines](#logging-guidelines) +5. [Plugin Development](#plugin-development) +6. [Service Development](#service-development) +7. [Testing Guidelines](#testing-guidelines) +8. [Error Handling](#error-handling) +9. [Performance Considerations](#performance-considerations) +10. [Contributing Guidelines](#contributing-guidelines) + +## Getting Started + +### Prerequisites +- Go 1.25 or later +- Make +- Docker (for container runtime testing) +- Kubernetes (optional, for k8s runtime testing) + +### Development Setup +```bash +# Clone the repository +git clone +cd launchr + +# Install dependencies +make deps + +# Build the project +make build + +# Run tests +make test + +# Run linter +make lint +``` + +### Development Environment +```bash +# Build with debug symbols for debugging +make DEBUG=1 + +# Run with verbose logging +LAUNCHR_LOG_LEVEL=DEBUG bin/launchr --help +``` + +## Code Style and Conventions + +### Go Code Style +Follow standard Go conventions as defined by `gofmt` and `golangci-lint`: + +```go +// Good: Clear, descriptive function names +func NewActionManager(config Config) *ActionManager { + return &ActionManager{ + config: config, + actions: make(map[string]*Action), + } +} + +// Bad: Unclear, abbreviated names +func NewActMgr(cfg Cfg) *ActMgr { + return &ActMgr{cfg: cfg, acts: make(map[string]*Act)} +} +``` + +### File Organization +``` +pkg/ +├── action/ # Action-related functionality +├── driver/ # Runtime drivers (docker, k8s) +├── jsonschema/ # JSON schema utilities +└── archive/ # Archive utilities + +internal/ +└── launchr/ # Internal application code + +plugins/ +├── actionnaming/ # Action naming plugin +├── actionscobra/ # Cobra integration plugin +└── ... + +cmd/ +└── launchr/ # Command-line entry point +``` + +### Import Organization +```go +package example + +import ( + // Standard library + "context" + "fmt" + "io" + + // Third-party dependencies + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + // Local imports + "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" +) +``` + +### Naming Conventions +- **Packages**: Short, single-word, lowercase (e.g., `action`, `driver`) +- **Interfaces**: Noun or adjective ending in -er (e.g., `Manager`, `Runner`) +- **Functions**: Descriptive verbs (e.g., `CreateAction`, `ValidateInput`) +- **Constants**: CamelCase with descriptive names +- **Variables**: CamelCase, descriptive but concise + +## Architecture Guidelines + +### Plugin Development Rules + +1. **Single Responsibility**: Each plugin should have one clear purpose +2. **Minimal Dependencies**: Avoid tight coupling between plugins +3. **Weight Selection**: Choose weights thoughtfully for proper ordering +4. **Interface Implementation**: Only implement interfaces you actually use + +```go +// Good: Focused plugin with single responsibility +type ActionNamingPlugin struct { + transformer NameTransformer +} + +func (p *ActionNamingPlugin) PluginInfo() launchr.PluginInfo { + return launchr.PluginInfo{ + Weight: 100, + } +} + +// Bad: Plugin trying to do everything +type MegaPlugin struct{} +func (p *MegaPlugin) OnAppInit(...) error { /* complex logic */ } +func (p *MegaPlugin) CobraAddCommands(...) error { /* more logic */ } +func (p *MegaPlugin) DiscoverActions(...) error { /* even more logic */ } +``` + +### Service Design Rules + +1. **Interface Segregation**: Keep interfaces focused and small +2. **Dependency Injection**: Use constructor injection for dependencies +3. **Service Lifecycle**: Consider service startup and shutdown +4. **Error Handling**: Return errors, don't panic + +```go +// Good: Small, focused interface +type RuntimeFlags interface { + ValidateInput(input *Input) error + SetFlags(input *Input) error +} + +// Note: Manager is a type alias (type Manager = *actionManagerMap), +// not an interface. Keep service interfaces focused and small. +``` + +## Logging Guidelines + +### Use the Right Logger + +#### Internal Logging (`Log()`) - For Developers +```go +// Good: Debug information for developers +Log().Debug("initialising application", "config_dir", app.cfgDir) +Log().Error("error on plugin init", "plugin", p.Name, "err", err) + +// Bad: User-facing messages in internal log +Log().Info("Build completed successfully") // Users won't see this +``` + +#### Terminal Logging (`Term()`) - For Users +```go +// Good: User-facing status messages +Term().Info().Printfln("Starting to build %s", buildName) +Term().Warning().Printfln("Configuration file not found, using defaults") +Term().Success().Println("Build completed successfully") + +// Bad: Debug information in terminal output +Term().Info().Printf("Debug: variable state = %+v", internalState) +``` + +### Structured Logging Best Practices +```go +// Good: Structured with context +Log().Error("failed to execute action", + "action_id", action.ID(), + "runtime", action.Runtime().Type(), + "error", err) + +// Bad: String concatenation +Log().Error("failed to execute action " + action.ID() + ": " + err.Error()) +``` + +## Plugin Development + +### Plugin Lifecycle + +1. **Registration**: Plugins register themselves in `init()` +2. **Initialization**: Implement required interfaces +3. **Execution**: Plugin methods called by the system + +### Plugin Template +```go +package myplugin + +import "github.com/launchrctl/launchr" + +type MyPlugin struct { + name string +} + +func (p *MyPlugin) PluginInfo() launchr.PluginInfo { + return launchr.PluginInfo{ + Weight: 100, // Choose appropriate weight + } +} + +func (p *MyPlugin) OnAppInit(app launchr.App) error { + // Get required services via ServiceManager + var config launchr.Config + app.Services().Get(&config) + + // Initialize plugin + return p.initialize(config) +} + +func (p *MyPlugin) initialize(config launchr.Config) error { + // Plugin-specific initialization + return nil +} + +func init() { + launchr.RegisterPlugin(&MyPlugin{}) +} +``` + +### Plugin Testing +```go +func TestMyPlugin(t *testing.T) { + plugin := &MyPlugin{} + + // Test plugin info + info := plugin.PluginInfo() + assert.Equal(t, 100, info.Weight) + + // Test initialization + app := createTestApp() + err := plugin.OnAppInit(app) + assert.NoError(t, err) +} +``` + +## Service Development + +### Service Implementation +```go +type MyService struct { + config Config + logger *Logger +} + +func (s *MyService) ServiceInfo() launchr.ServiceInfo { + return launchr.ServiceInfo{} +} + +func (s *MyService) Initialize() error { + // Service initialization logic + return nil +} + +func NewMyService(config Config, logger *Logger) *MyService { + return &MyService{ + config: config, + logger: logger, + } +} +``` + +### Service Testing +```go +func TestMyService(t *testing.T) { + config := createTestConfig() + logger := createTestLogger() + + service := NewMyService(config, logger) + + err := service.Initialize() + assert.NoError(t, err) + + info := service.ServiceInfo() + assert.NotNil(t, info) +} +``` + +## Testing Guidelines + +### Test Structure +```go +func TestFunctionName(t *testing.T) { + // Arrange + input := createTestInput() + expected := expectedResult() + + // Act + result, err := FunctionUnderTest(input) + + // Assert + assert.NoError(t, err) + assert.Equal(t, expected, result) +} +``` + +### Test Helpers +```go +func createTestApp() launchr.App { + app := &testApp{ + services: make(map[launchr.ServiceInfo]launchr.Service), + } + return app +} + +func createTestConfig() launchr.Config { + return &testConfig{ + data: make(map[string]interface{}), + } +} +``` + +### Table-Driven Tests +```go +func TestActionValidation(t *testing.T) { + tests := []struct { + name string + action *Action + wantErr bool + }{ + { + name: "valid action", + action: createValidAction(), + wantErr: false, + }, + { + name: "invalid action - missing ID", + action: createActionWithoutID(), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateAction(tt.action) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} +``` + +## Error Handling + +### Error Creation +```go +// Good: Descriptive error with context +func (m *Manager) GetAction(id string) (*Action, error) { + action, exists := m.actions[id] + if !exists { + return nil, fmt.Errorf("action %q not found", id) + } + return action, nil +} + +// Bad: Generic error +func (m *Manager) GetAction(id string) (*Action, error) { + action, exists := m.actions[id] + if !exists { + return nil, errors.New("not found") + } + return action, nil +} +``` + +### Error Wrapping +```go +func (r *Runtime) Execute(ctx context.Context, action *Action) error { + if err := r.prepare(ctx, action); err != nil { + return fmt.Errorf("failed to prepare runtime: %w", err) + } + + if err := r.run(ctx, action); err != nil { + return fmt.Errorf("failed to execute action: %w", err) + } + + return nil +} +``` + +### Avoid Panics +```go +// Current implementation panics on duplicate registration. +// Consider returning error in future: +func RegisterPlugin(p Plugin) error { + info := p.PluginInfo() + if _, exists := registeredPlugins[info]; exists { + return fmt.Errorf("plugin %q already registered", info) + } + registeredPlugins[info] = p + return nil +} + +// Current: panics on duplicate (acceptable for init()-time registration) +func RegisterPlugin(p Plugin) { + info := p.PluginInfo() + InitPluginInfo(&info, p) + if _, exists := registeredPlugins[info]; exists { + panic(fmt.Errorf("plugin %q already registered", info)) + } + registeredPlugins[info] = p +} +``` + +## Performance Considerations + +### Minimize Reflection +```go +// Good: Direct type assertion +func GetService[T Service](container ServiceContainer) (T, error) { + var zero T + service, exists := container.GetByType(reflect.TypeOf(zero)) + if !exists { + return zero, fmt.Errorf("service %T not found", zero) + } + return service.(T), nil +} + +// Current: Heavy reflection usage +func (app *appImpl) GetService(v any) { + // Complex reflection logic... +} +``` + +### Efficient Logging +```go +// Good: Check log level before expensive operations +if Log().Level() >= LogLevelDebug { + Log().Debug("complex operation result", "data", expensiveSerialize(data)) +} + +// Bad: Always compute expensive data +Log().Debug("complex operation result", "data", expensiveSerialize(data)) +``` + +### Resource Management +```go +// Good: Proper cleanup with defer +func (r *Runtime) Execute(ctx context.Context) error { + resource, err := r.acquireResource() + if err != nil { + return err + } + defer func() { + if cleanupErr := resource.Close(); cleanupErr != nil { + Log().Error("failed to cleanup resource", "error", cleanupErr) + } + }() + + return r.doWork(resource) +} +``` + +## Contributing Guidelines + +### Before Making Changes + +1. **Read Architecture Documentation**: Understand the system design +2. **Check Existing Issues**: Look for related work or discussions +3. **Write Tests**: Ensure your changes are well-tested +4. **Run Linter**: `make lint` must pass +5. **Update Documentation**: Keep docs in sync with code changes + +### Pull Request Process + +1. **Feature Branch**: Create a descriptive branch name +2. **Small Commits**: Make logical, focused commits +3. **Clear Messages**: Write descriptive commit messages +4. **Update Tests**: Add or modify tests as needed +5. **Documentation**: Update relevant documentation + +### Code Review Checklist + +- [ ] Code follows style guidelines +- [ ] All tests pass +- [ ] Linter passes without warnings +- [ ] Changes are well-documented +- [ ] No breaking changes without version bump +- [ ] Error handling is appropriate +- [ ] Logging follows guidelines +- [ ] Performance impact considered + +### Release Process + +1. **Version Bump**: Update version in appropriate files +2. **Changelog**: Update CHANGELOG.md with changes +3. **Tag Release**: Create annotated git tag +4. **GitHub Release**: Create release with binaries + +## Best Practices Summary + +### DO ✅ +- Use structured logging with context +- Implement focused interfaces +- Handle errors gracefully +- Write comprehensive tests +- Document public APIs +- Follow Go conventions +- Use appropriate logging system +- Minimize reflection usage +- Clean up resources properly + +### DON'T ❌ +- Use panics for recoverable errors +- Create large, monolithic interfaces +- Mix logging systems inappropriately +- Ignore test coverage +- Break existing APIs without versioning +- Use magic numbers without constants +- Leak resources or goroutines +- Write untestable code + +## Getting Help + +- **Architecture Questions**: Review `docs/architecture/` +- **Plugin Development**: See `docs/development/plugin.md` +- **Service Development**: See `docs/development/service.md` +- **General Questions**: Create a GitHub issue +- **Discussions**: Use GitHub Discussions for broader topics + +## Useful Commands + +```bash +# Development +make deps # Install dependencies +make build # Build project +make DEBUG=1 # Build with debug symbols +make test # Run tests +make lint # Run linter + +# Testing +go test ./... # Run all tests +go test -v ./pkg/action/... # Run specific package tests +go test -race ./... # Run tests with race detection +go test -cover ./... # Run tests with coverage + +# Debugging +dlv debug ./cmd/launchr # Debug with Delve +LAUNCHR_LOG_LEVEL=DEBUG ./bin/launchr # Verbose logging + +# Building +make install # Install globally +make clean # Clean build artifacts +``` + +Remember: The goal is to maintain high code quality while preserving the excellent architectural foundation that already exists in Launchr. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 95b3290..d60ea95 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,21 @@ -# Launchr documentation +# Launchr Documentation -1. [Built-in functionality](#built-in-functionality) -2. [Actions](actions.md) -3. [Actions Schema](actions.schema.md) -4. [Global configuration](config.md) -5. [Development](development) +## User Documentation +1. [Actions](actions.md) - Action definition and usage +2. [Actions Schema](actions.schema.md) - Schema validation for actions +3. [Global Configuration](config.md) - Application configuration +4. [Built-in functionality](#built-in-functionality) + +## Developer Documentation +5. **[Developer Guidelines](DEVELOPER_GUIDELINES.md)** - Comprehensive development guide +6. **[Architecture](architecture/)** - System architecture documentation + - [Architectural Patterns](architecture/ARCHITECTURAL_PATTERNS.md) + - [Logging Architecture](architecture/LOGGING_ARCHITECTURE.md) + - [Plugin System](architecture/PLUGIN_SYSTEM.md) + - [Service System](architecture/SERVICE_SYSTEM.md) +7. [Development](development) - Specific development topics + - [Plugin Development](development/plugin.md) + - [Service Development](development/service.md) ## Build plugin diff --git a/docs/actions.md b/docs/actions.md index 59a5682..4539e89 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -61,12 +61,12 @@ action: - name: optStr title: Option string description: Some additional info for option - + runtime: type: container image: python:3.7-slim - command: - - python3 + command: + - python3 - {{ .myArg1 }} {{ .myArg2 }} - '{{ .optStr }}' - ${ENV_VAR} @@ -176,4 +176,63 @@ If run in the remote runtime (docker, kubernetes) or with a flag `--remote-runti 1. Current working directory is copied to a new volume `volume_host:/host` 2. Action directory is copied to a new volume `volume_action:/action` -To copy back the result of the execution, use `--remote-copy-back` flag. \ No newline at end of file +To copy back the result of the execution, use `--remote-copy-back` flag. + +### Environment Variables in Container Runtime + +Environment variables are passed to Docker containers through the `runtime.env` configuration in the action YAML file. There are key differences between how container and shell runtimes handle environment variables: + +#### Container Runtime Environment Variable Behavior + +**Explicit Definition Required**: Unlike shell runtime which inherits all host environment variables, container runtime only passes explicitly defined environment variables from the action YAML. + +**Host Environment Variable Access**: To access host environment variables, you must explicitly reference them using `${VAR}` syntax in the action definition: + +```yaml +runtime: + type: container + image: alpine:latest + env: + # Static environment variable + ACTION_ENV: "static_value" + + # Dynamic environment variable from host + USER_NAME: ${USER} + + # Environment variable with fallback (if HOST_VAR is not set, uses "default") + HOST_SETTING: ${HOST_VAR-default} +``` + +**Environment Variable Expansion**: During action loading, the system expands `${VAR}` patterns using the host's environment variables via `os.Expand()`. This happens before the container is created. + +**Supported Formats**: Environment variables can be defined using either YAML map or array syntax: + +```yaml +# Map syntax (key-value pairs) +runtime: + env: + KEY1: value1 + KEY2: ${HOST_VAR} + +# Array syntax (KEY=value strings) +runtime: + env: + - KEY1=value1 + - KEY2=${HOST_VAR} +``` + +#### Shell Runtime vs Container Runtime + +**Shell Runtime**: +- Inherits all host environment variables automatically +- Additional variables defined in `runtime.env` are appended +- Uses: `cmd.Env = append(os.Environ(), rt.Shell.Env...)` + +**Container Runtime**: +- Only passes explicitly defined environment variables +- Host variables must be referenced with `${VAR}` syntax +- No automatic inheritance of host environment + +#### Example + +For a practical example, see the [envvars action](../example/actions/envvars/action.yaml) which demonstrates both static and dynamic environment variable usage in containers. diff --git a/docs/actions.schema.md b/docs/actions.schema.md index ef5c42c..6d6d745 100644 --- a/docs/actions.schema.md +++ b/docs/actions.schema.md @@ -27,7 +27,7 @@ Action has the following top-level configuration: * `version` - action schema version. - * `working_directory` - Working directory where the action will be executed, by default current working directory. See [Predefined variables](#predefined-variables) for possible substitutions. + * `working_directory` - Working directory where the action will be executed, by default current working directory. See [Predefined variables](#predefined-variables) for possible substitutions. * `action` (required) - declares action title, description and parameters (arguments and options). * `runtime` (required) - declares where the action will be executed, e.g. container, shell, custom environment. @@ -47,12 +47,14 @@ runtime: ## JSON Schema, Arguments and options -Arguments and options are defined in `action.yaml`, parsed according to the schema and replaced on run. -Parameter declaration follows [JSON Schema](https://json-schema.org/). The declaration is the same for both. +Arguments and options are defined in `action.yaml`, parsed according to the schema and replaced on run. +Parameter declaration follows [JSON Schema](https://json-schema.org/). The declaration is the same for both. -Both arguments and options can be required and optional, be of various types and have a default value. +Both arguments and options can be required and optional, be of various types and have a default value. The only difference is how the parameters are provided in the terminal. Arguments are positional, options are named. +Options support a `shorthand` field - a single character alias for the option name (e.g., `shorthand: v` allows `-v` in addition to `--verbose`). + See [examples](#examples) of how required and default are used and more complex parameter validation. **Supported variable types:** @@ -101,7 +103,7 @@ action: - name: MyArg2 title: Argument 2 - Integer description: | - This is a required argument of type int with a default value. + This is a required argument of type int with a default value. It can be omitted, default value is used. type: integer required: true @@ -112,9 +114,9 @@ action: type: string enum: [enum1, enum2] description: | - This is an optional argument without a default value of type enum. + This is an optional argument without a default value of type enum. Only enum values are allowed. - It can be omitted, nil value is used. + It can be omitted, nil value is used. Since arguments are positional in the terminal, MyArg2 must be provided. # Options declaration @@ -123,13 +125,13 @@ action: title: Option default string default: "" description: | - This is an option of implicit type "string". + This is an option of implicit type "string". It can be omitted, empty string is used. - name: optStrNil title: Option string description: | - This is an option of implicit type "string". + This is an option of implicit type "string". It can be omitted, no default value, nil value is used. - name: optBool @@ -171,7 +173,7 @@ action: description: | This is an optional option of type enum. By default `enum1` is used. Only enum values may allowed. This is validated by JSON Schema. - + - name: optip title: Option IP string type: string @@ -203,6 +205,7 @@ Arguments and Options are available by their machine names - `{{ .myArg1 }}`, `{ 5. `action_dir` or `$ACTION_DIR` - directory of the action file. 6. `current_bin` or `$CBIN` - path to the Currently executed Binary. Works only in "shell" runtime. On Windows, the path is converted to unix style. +7. `$ACTION_ID` - unique action identifier. ### Environment variables @@ -264,8 +267,8 @@ runtime: ### `default` -**Description:** Returns a default value when the first parameter is `nil` or empty. -Emptiness is determined by its zero value - empty string `""`, integer `0`, structs with all zero-value fields, etc. +**Description:** Returns a default value when the first parameter is `nil` or empty. +Emptiness is determined by its zero value - empty string `""`, integer `0`, structs with all zero-value fields, etc. Or type implements `interface { IsEmpty() bool }`. **Usage:** @@ -274,6 +277,15 @@ Or type implements `interface { IsEmpty() bool }`. {{ default .nil_value "bar" }} ``` +### `mask` + +**Description:** Masks a sensitive value in the output. The value will be replaced with `****` in terminal output. + +**Usage:** +```gotemplate +{{ .password | mask }} +``` + ### `config.Get` **Description:** Returns a [config](config.md) value by a path. @@ -286,7 +298,6 @@ Or type implements `interface { IsEmpty() bool }`. {{ config "foo.missing-elem" | default "bar" }} # uses default if key doesn't exist ``` - ## Runtimes Action can be executed in different runtime environments. This section covers their declaration. @@ -295,6 +306,15 @@ Action can be executed in different runtime environments. This section covers th Container runtime executes the action in a container. Basic definition must have `type`, `image` and `command` to run an action. +Available container runtime fields: + * `type` (required) - must be `container` + * `image` (required) - container image to use + * `command` (required) - command to execute + * `env` - environment variables + * `build` - image build definition + * `extra_hosts` - additional host entries + * `user` - user to run the container as + Here is an example: ```yaml @@ -302,6 +322,7 @@ Here is an example: runtime: type: container image: alpine:latest + user: "1000:1000" env: ENV1: val1 build: @@ -391,7 +412,7 @@ Or action: title: Test description: Test - + runtime: type: container image: alpine:latest @@ -474,7 +495,6 @@ runtime: USER_NAME: plasma ``` - Can be used as: ``` FROM alpine:latest @@ -512,8 +532,8 @@ A more detailed definition of each property can be found below. #### Script -The script is executed in the default user shell provided by `$SHELL` environment variable. If it's empty, `/bin/bash` is used by default. -Compared to `container` runtime with a command defined as an array, here we can define a multiline script: +The script is executed in the default user shell provided by `$SHELL` environment variable. If it's empty, `/bin/bash` is used by default. +Compared to `container` runtime with a command defined as an array, here we can define a multiline script: ```yaml # ... @@ -528,8 +548,8 @@ runtime: #### Environment variables -To pass environment variables to the execution environment, add `env` section. They work exactly the same as in container. -**NB!** If you need to use an environment variable in the script, you must escape it with a double `$$` like `$$MY_ENV`. +To pass environment variables to the execution environment, add `env` section. They work exactly the same as in container. +**NB!** If you need to use an environment variable in the script, you must escape it with a double `$$` like `$$MY_ENV`. If not escaped, the variable will be replaced during templating and not during the execution. That may lead to an unwanted result. ```yaml diff --git a/docs/architecture/ARCHITECTURAL_PATTERNS.md b/docs/architecture/ARCHITECTURAL_PATTERNS.md new file mode 100644 index 0000000..224c0a2 --- /dev/null +++ b/docs/architecture/ARCHITECTURAL_PATTERNS.md @@ -0,0 +1,338 @@ +# Architectural Patterns Analysis + +## Overview +This document analyzes the architectural patterns used in the Launchr Go project and provides suggestions for improvements. + +## Patterns Currently Used + +### 1. **Factory Pattern** ✅ +**Location**: `pkg/driver/factory.go`, `pkg/action/runtime.container.go` +**Implementation**: Clean factory methods for creating drivers and runtimes +```go +func New(t Type) (ContainerRunner, error) { + switch t { + case Docker: return NewDockerRuntime() + case Kubernetes: return NewKubernetesRuntime() + default: panic(fmt.Sprintf("container runtime %q is not implemented", t)) + } +} +``` + +### 2. **Plugin Architecture** ✅ +**Location**: `internal/launchr/types.go`, `plugins/` +**Implementation**: Weight-based plugin system with lifecycle hooks +- Excellent extensibility through interface-based design +- Multiple plugin types: `OnAppInitPlugin`, `CobraPlugin`, `DiscoveryPlugin` + +### 3. **Strategy Pattern** ✅ +**Location**: `pkg/action/discover.go`, `pkg/action/process.go` +**Implementation**: Pluggable algorithms for discovery and value processing +- `DiscoveryStrategy` interface for different file discovery methods +- `ValueProcessor` interface for different value processing strategies + +### 4. **Decorator Pattern** ✅ +**Location**: `pkg/action/manager.go` +**Implementation**: Action decoration system using functional decorators +```go +func WithDefaultRuntime(cfg Config) DecorateWithFn +func WithContainerRuntimeConfig(cfg Config, prefix string) DecorateWithFn +``` + +### 5. **Template Method Pattern** ✅ +**Location**: `pkg/action/runtime.go` +**Implementation**: Consistent lifecycle management across all runtimes +- `Init()`, `Execute()`, `Close()`, `Clone()` methods + +### 6. **Observer Pattern** ✅ +**Location**: `app.go` +**Implementation**: Event-driven plugin hooks for application lifecycle events + +### 7. **Repository Pattern** ✅ +**Location**: `pkg/action/manager.go` +**Implementation**: Action management with CRUD operations +- `All()`, `Get()`, `Add()`, `Delete()` methods + +### 8. **Dependency Injection** ✅ +**Location**: `app.go` +**Implementation**: Reflection-based service container +- Type-safe service registration and retrieval + +### 9. **Builder Pattern** ✅ +**Location**: `pkg/action/runtime.container.go` +**Implementation**: Container definition building with conditional logic + +### 10. **Composition Pattern** ✅ +**Location**: Throughout codebase +**Implementation**: Mixin-style composition (`WithLogger`, `WithTerm`, `WithFlagsGroup`) + +## Anti-Patterns and Issues + +### 1. **Panic-Driven Error Handling** ❌ +**Problem**: Multiple locations use `panic()` for recoverable errors +**Impact**: Reduces application stability and error handling flexibility + +### 2. **Heavy Reflection Usage** ⚠️ +**Problem**: Service container and configuration rely heavily on reflection +**Impact**: Runtime performance overhead and reduced compile-time safety + +### 3. **Global State** ⚠️ +**Problem**: Global plugin registry and other global variables +**Impact**: Reduces testability and increases coupling + +### 4. **Large Type Surface** ⚠️ +**Problem**: `Manager` (type alias for `*actionManagerMap`) has 19+ exported methods +**Impact**: Large API surface, though not an interface issue since `Manager` is a concrete type alias + +### 5. **Temporal Coupling** ❌ +**Problem**: Hidden dependencies on operation order +**Impact**: Fragile code that breaks when usage order changes + +## Suggested Improvements + +### 1. **Replace Panics with Proper Error Handling** +**Priority**: High +**Benefits**: +- Improved application stability +- Better error recovery +- More predictable behavior + +**Implementation**: +```go +// Instead of: +func RegisterPlugin(p Plugin) { + if _, ok := registeredPlugins[info]; ok { + panic(fmt.Errorf("plugin %q already registered", info)) + } +} + +// Use: +func RegisterPlugin(p Plugin) error { + if _, ok := registeredPlugins[info]; ok { + return fmt.Errorf("plugin %q already registered", info) + } + return nil +} +``` + +### 2. **Implement Dependency Graph for Plugins** +**Priority**: High +**Benefits**: +- Proper dependency resolution +- Better plugin ordering +- Reduced configuration complexity + +**Implementation**: +```go +type PluginDependency struct { + Name string + Dependencies []string + Optional []string +} + +func ResolveDependencies(plugins []PluginDependency) ([]string, error) { + // Topological sort implementation +} +``` + +### 3. **Interface Segregation for Manager** +**Priority**: Medium +**Benefits**: +- Better separation of concerns +- Easier testing +- Reduced coupling + +**Note**: `Manager` is currently a type alias (`type Manager = *actionManagerMap`), not an interface. +Introducing focused interfaces would enable better testing and decoupling: +```go +type ActionReader interface { + All() map[string]*Action + Get(id string) (*Action, bool) +} + +type ActionWriter interface { + Add(*Action) error + Delete(id string) +} +``` + +### 4. **Reduce Reflection Usage in Services** +**Priority**: Medium +**Benefits**: +- Better performance +- Compile-time safety +- Clearer dependencies + +**Implementation**: +```go +type ServiceContainer struct { + config Config + manager Manager + pluginMgr PluginManager +} + +func NewServiceContainer(config Config, manager Manager, pluginMgr PluginManager) *ServiceContainer { + return &ServiceContainer{config, manager, pluginMgr} +} + +func (sc *ServiceContainer) Config() Config { return sc.config } +func (sc *ServiceContainer) Manager() Manager { return sc.manager } +``` + +### 5. **Add Configuration Schema Validation** +**Priority**: Medium +**Benefits**: +- Better error messages +- Early validation +- Improved user experience + +**Implementation**: +```go +type ConfigValidator interface { + Validate(config map[string]interface{}) error +} + +func NewSchemaValidator(schemaPath string) ConfigValidator { + // JSON Schema validation implementation +} +``` + +### 6. **Implement Error Context Enhancement** +**Priority**: Low +**Benefits**: +- Better debugging +- Improved error messages +- Easier troubleshooting + +**Implementation**: +```go +type ContextError struct { + Op string + Context map[string]interface{} + Err error +} + +func (e *ContextError) Error() string { + return fmt.Sprintf("%s: %v (context: %+v)", e.Op, e.Err, e.Context) +} +``` + +### 7. **Add Circuit Breaker Pattern for External Services** +**Priority**: Low +**Benefits**: +- Improved resilience +- Better failure handling +- Reduced cascade failures + +**Implementation**: +```go +type CircuitBreaker interface { + Execute(operation func() error) error + State() CircuitState +} + +func NewCircuitBreaker(config CircuitBreakerConfig) CircuitBreaker { + // Circuit breaker implementation +} +``` + +### 8. **Implement Command Pattern for Actions** +**Priority**: Low +**Benefits**: +- Better action queuing +- Undo/redo capabilities +- Audit logging + +**Implementation**: +```go +type Command interface { + Execute(ctx context.Context) error + Undo(ctx context.Context) error + Description() string +} + +type ActionCommand struct { + action *Action + runtime Runtime +} +``` + +### 9. **Add Caching Layer with Cache-Aside Pattern** +**Priority**: Low +**Benefits**: +- Improved performance +- Reduced resource usage +- Better scalability + +**Implementation**: +```go +type Cache interface { + Get(key string) (interface{}, bool) + Set(key string, value interface{}, ttl time.Duration) + Delete(key string) +} + +type CachedActionManager struct { + manager Manager + cache Cache +} +``` + +### 10. **Implement Retry Pattern with Exponential Backoff** +**Priority**: Low +**Benefits**: +- Better resilience +- Reduced transient failures +- Improved reliability + +**Implementation**: +```go +type RetryConfig struct { + MaxAttempts int + BaseDelay time.Duration + MaxDelay time.Duration + Multiplier float64 +} + +func RetryWithBackoff(config RetryConfig, operation func() error) error { + // Exponential backoff implementation +} +``` + +## Implementation Priority + +### High Priority (Immediate Impact) +1. Replace panics with proper error handling +2. Implement plugin dependency graph +3. Add configuration schema validation + +### Medium Priority (Quality Improvements) +1. Interface segregation for Manager +2. Reduce reflection usage in services +3. Enhance error context + +### Low Priority (Advanced Features) +1. Circuit breaker pattern +2. Command pattern for actions +3. Caching layer implementation +4. Retry pattern with backoff + +## Testing Recommendations + +### 1. **Add Architectural Tests** +- Test plugin dependency resolution +- Validate interface segregation +- Test error handling paths + +### 2. **Performance Tests** +- Benchmark reflection usage +- Test concurrent action execution +- Measure memory usage patterns + +### 3. **Integration Tests** +- Test plugin lifecycle +- Validate service container behavior +- Test configuration loading + +## Conclusion + +The Launchr project demonstrates excellent architectural design with sophisticated use of design patterns. The suggested improvements focus on increasing stability, reducing complexity, and enhancing maintainability while preserving the excellent extensibility provided by the current plugin architecture. \ No newline at end of file diff --git a/docs/architecture/LOGGING_ARCHITECTURE.md b/docs/architecture/LOGGING_ARCHITECTURE.md new file mode 100644 index 0000000..bee51c3 --- /dev/null +++ b/docs/architecture/LOGGING_ARCHITECTURE.md @@ -0,0 +1,160 @@ +# Dual Logging Architecture Analysis + +## Overview + +Launchr implements a sophisticated **dual logging architecture** that separates internal debugging/diagnostics from user-facing terminal output. This is an advanced architectural pattern that provides excellent separation of concerns between developer logging and user communication. + +## The Two Logging Systems + +### 1. Internal Logging System: `Log()` + +**Location**: `internal/launchr/log.go` +**Purpose**: Developer-focused structured logging for debugging and diagnostics +**Technology**: Go's structured logger (`slog`) with pterm integration + +#### Key Features: +- **Structured Logging**: Uses `slog` for key-value structured logs +- **Multiple Handlers**: Console, TextHandler, JSON Handler support +- **Configurable Levels**: Disabled, Debug, Info, Warn, Error +- **Thread-Safe**: Uses atomic pointer for default logger +- **Default Behavior**: Outputs to `io.Discard` (silent by default) +- **Runtime Configuration**: Log level and output can be changed at runtime + +#### Implementation Details: +```go +// Global atomic logger for thread safety +var defaultLogger atomic.Pointer[Logger] + +// Logger wraps slog with additional options +type Logger struct { + *Slog // Go's structured logger + LogOptions +} + +// Usage pattern +Log().Debug("shutdown cleanup") +Log().Error("error on OnAppInit", "plugin", p.K.String(), "err", err) +``` + +#### Usage: +- Used extensively across the codebase in: + - Application lifecycle (`app.go`) + - Action management (`pkg/action/`) + - Driver implementations (`pkg/driver/`) + - Plugin systems (`plugins/`) + +### 2. Terminal/User Logging System: `Term()` + +**Location**: `internal/launchr/term.go` +**Purpose**: User-facing formatted terminal output with styling and colors +**Technology**: pterm (Pretty Terminal) library + +#### Key Features: +- **Styled Output**: Colored, formatted terminal output with prefixes +- **Multiple Printers**: Basic, Info, Warning, Success, Error +- **User-Focused**: Designed for end-user communication +- **Global Control**: Can be enabled/disabled globally +- **Reflection-Based**: Uses reflection for pterm WithWriter method calls + +#### Implementation Details: +```go +// Terminal with styled printers +type Terminal struct { + w io.Writer // Target writer + p []TextPrinter // Styled printers array + enabled bool // Global enable/disable +} + +// Usage patterns +Term().Info().Printfln("Starting to build %s", b.PkgName) +Term().Warning().Printfln("Error on application shutdown cleanup:\n %s", err) +Term().Error().Println(err) +``` + +#### Usage: +- Used across the codebase in: + - Application error handling (`app.go`) + - Build system (`plugins/builder/`) + - Action discovery warnings (`pkg/action/`) + - User-facing operations + +## Best Practices for Usage + +### When to Use Internal Logging (`Log()`) +- Debug information for developers +- Error diagnostics with context +- Performance metrics and traces +- Internal state changes +- Plugin lifecycle events + +```go +// Good examples +Log().Debug("initialising application") +Log().Error("error on OnAppInit", "plugin", p.K.String(), "err", err) +Log().Debug("executing shell", "cmd", cmd) +``` + +### When to Use Terminal Logging (`Term()`) +- User-facing status messages +- Error messages users need to see +- Build progress information +- Success/completion notifications +- Warnings about user actions + +```go +// Good examples +Term().Info().Printfln("Starting to build %s", name) +Term().Warning().Printfln("Error on application shutdown cleanup:\n %s", err) +Term().Success().Println("Build completed successfully") +``` + +### Anti-Patterns to Avoid +```go +// DON'T: Use internal logging for user messages +Log().Info("Build started") // Users won't see this + +// DON'T: Use terminal logging for debug info +Term().Info().Printf("Debug: variable x = %v", x) // Clutters user output + +// DON'T: Mix logging systems inconsistently +Log().Error("Failed to start") +Term().Error().Println("Failed to start") // Pick one based on audience +``` + +## Configuration + +### Internal Logging Configuration: +```go +// Runtime level changes +Log().SetLevel(LogLevelDebug) +Log().SetOutput(os.Stderr) + +// Different handler types +NewConsoleLogger(w) // Pretty console output +NewTextHandlerLogger(w) // Plain text output +NewJSONHandlerLogger(w) // JSON structured output +``` + +### Terminal Configuration: +```go +// Global enable/disable +Term().EnableOutput() +Term().DisableOutput() + +// Output redirection +Term().SetOutput(writer) + +// Individual printer access +Term().Info().Println("message") +Term().Warning().Printf("format %s", arg) +``` + +## Thread Safety + +- **Internal Logging**: ✅ Thread-safe using `atomic.Pointer[Logger]` +- **Terminal Logging**: ⚠️ Global instance without explicit synchronization + +## Performance Considerations + +- **Internal Logging**: Optimized with `io.Discard` by default +- **Terminal Logging**: Uses reflection in `SetOutput()` which has overhead \ No newline at end of file diff --git a/docs/architecture/PLUGIN_SYSTEM.md b/docs/architecture/PLUGIN_SYSTEM.md new file mode 100644 index 0000000..06c86bc --- /dev/null +++ b/docs/architecture/PLUGIN_SYSTEM.md @@ -0,0 +1,269 @@ +# Plugin System Architecture + +## Overview + +Launchr implements a sophisticated plugin architecture that allows for extensible functionality through a weight-based plugin system with lifecycle hooks and interface-based design. + +## Core Plugin Architecture + +### Plugin Registration + +Plugins register themselves globally during package initialization: + +```go +// Global plugin registry +var registeredPlugins = make(PluginsMap) + +func RegisterPlugin(p Plugin) { + info := p.PluginInfo() + InitPluginInfo(&info, p) + if _, ok := registeredPlugins[info]; ok { + panic(fmt.Errorf("plugin %q already registered", info)) + } + registeredPlugins[info] = p +} +``` + +### Plugin Interface Hierarchy + +#### Base Plugin Interface +```go +type Plugin interface { + PluginInfo() PluginInfo +} + +type PluginInfo struct { + Weight int // Used for ordering plugins + pkgPath string // Set automatically via InitPluginInfo() + typeName string // Set automatically via InitPluginInfo() +} +``` + +#### Specialized Plugin Interfaces + +1. **OnAppInitPlugin** - Application initialization hooks +```go +type OnAppInitPlugin interface { + Plugin + OnAppInit(app App) error +} +``` + +2. **CobraPlugin** - Command-line interface integration +```go +type CobraPlugin interface { + Plugin + CobraAddCommands(rootCmd *Command) error +} +``` + +3. **PersistentPreRunPlugin** - Pre-execution hooks +```go +type PersistentPreRunPlugin interface { + Plugin + PersistentPreRun(cmd *Command, args []string) error +} +``` + +4. **DiscoveryPlugin** - Action discovery hooks (defined in `pkg/action/discover.go`) +```go +type DiscoveryPlugin interface { + launchr.Plugin + DiscoverActions(ctx context.Context) ([]*Action, error) +} +``` + +5. **GeneratePlugin** - Code generation hooks +```go +type GeneratePlugin interface { + Plugin + Generate(config GenerateConfig) error +} +``` + +## Core Plugins + +### 1. Action Naming Plugin (`plugins/actionnaming/`) +- **Purpose**: Configurable action ID transformation +- **Functionality**: Provides naming strategies for discovered actions + +### 2. Actions Cobra Plugin (`plugins/actionscobra/`) +- **Purpose**: Cobra CLI integration for actions +- **Functionality**: Converts discovered actions into Cobra commands + +### 3. YAML Discovery Plugin (`plugins/yamldiscovery/`) +- **Purpose**: YAML file discovery in filesystem +- **Functionality**: Discovers `action.yaml` files and loads action definitions + +### 4. Built-in Processors Plugin (`plugins/builtinprocessors/`) +- **Purpose**: Value processing for action parameters +- **Functionality**: Provides processors for different parameter types + +### 5. Builder Plugin (`plugins/builder/`) +- **Purpose**: Code generation and template functionality +- **Functionality**: Builds custom launchr binaries with embedded plugins + +### 6. Verbosity Plugin (`plugins/verbosity/`) +- **Purpose**: Logging level management +- **Functionality**: Handles verbosity flags and log level configuration + +## Plugin Lifecycle + +### 1. Registration Phase +```go +func init() { + launchr.RegisterPlugin(&myPlugin{}) +} +``` + +### 2. Discovery Phase +```go +func (app *appImpl) init() error { + // Get plugins by type + plugins := launchr.GetPluginByType[OnAppInitPlugin](app.pluginMngr) + + // Execute in weight order + for _, p := range plugins { + if err = p.V.OnAppInit(app); err != nil { + return err + } + } +} +``` + +### 3. Execution Phase +```go +func (app *appImpl) exec() error { + // Add commands from plugins + plugins := launchr.GetPluginByType[CobraPlugin](app.pluginMngr) + for _, p := range plugins { + if err := p.V.CobraAddCommands(app.cmd); err != nil { + return err + } + } +} +``` + +## Plugin Weight System + +Plugins are ordered by weight for execution: +- **Lower weight** = **Higher priority** (executed first) +- **Default weight**: Usually 0 or positive integers +- **System plugins**: Often use negative weights for high priority + +## Plugin Implementation Example + +```go +type MyPlugin struct { + name string +} + +func (p *MyPlugin) PluginInfo() launchr.PluginInfo { + return launchr.PluginInfo{ + Weight: 100, + } +} + +func (p *MyPlugin) OnAppInit(app launchr.App) error { + // Plugin initialization logic + return nil +} + +func init() { + launchr.RegisterPlugin(&MyPlugin{}) +} +``` + +## Plugin Service Integration + +Plugins can access application services through dependency injection via `ServiceManager`: + +```go +func (p *MyPlugin) OnAppInit(app launchr.App) error { + var config launchr.Config + app.Services().Get(&config) + + var manager action.Manager + app.Services().Get(&manager) + + // Use services... + return nil +} +``` + +## Best Practices + +### Plugin Design +1. **Single Responsibility**: Each plugin should have one clear purpose +2. **Minimal Dependencies**: Avoid tight coupling between plugins +3. **Error Handling**: Always return meaningful errors +4. **Weight Selection**: Choose weights thoughtfully for proper ordering + +### Plugin Registration +```go +// Good: Clear registration in init() +func init() { + launchr.RegisterPlugin(&wellNamedPlugin{}) +} + +// Bad: Registration outside init() +func RegisterMyPlugin() { + launchr.RegisterPlugin(&myPlugin{}) +} +``` + +### Plugin Interface Implementation +```go +// Good: Implement only needed interfaces +type MyDiscoveryPlugin struct{} + +func (p *MyDiscoveryPlugin) PluginInfo() launchr.PluginInfo { ... } +func (p *MyDiscoveryPlugin) DiscoverActions(...) ([]*Action, error) { ... } + +// Bad: Implementing unnecessary interfaces +type MyPlugin struct{} +func (p *MyPlugin) OnAppInit(...) error { return nil } // Empty implementation +``` + +## Advanced Features + +### Plugin Composition +Plugins can implement multiple interfaces: + +```go +type MultiInterfacePlugin struct{} + +func (p *MultiInterfacePlugin) PluginInfo() launchr.PluginInfo { ... } +func (p *MultiInterfacePlugin) OnAppInit(app launchr.App) error { ... } +func (p *MultiInterfacePlugin) CobraAddCommands(cmd *Command) error { ... } +``` + +### Plugin Dependencies +While not explicitly supported, plugins can coordinate through: +- **Weight ordering**: Lower weight plugins run first +- **Service sharing**: Common services accessed through app +- **Configuration**: Shared configuration through Config service + +## Limitations and Considerations + +### Current Limitations +1. **Weight-based ordering**: Primitive dependency resolution +2. **Global registration**: All plugins registered globally +3. **No unloading**: Plugins cannot be dynamically unloaded +4. **Panic on conflicts**: Duplicate plugin registrations cause panics + +### Improvement Opportunities +1. **Dependency graphs**: Explicit plugin dependencies +2. **Plugin discovery**: Dynamic plugin loading from files +3. **Plugin isolation**: Namespace isolation between plugins +4. **Hot reloading**: Dynamic plugin reloading support + +## Plugin Development Workflow + +1. **Define Purpose**: Clear single responsibility +2. **Choose Interfaces**: Implement only necessary plugin interfaces +3. **Select Weight**: Choose appropriate execution order +4. **Implement Logic**: Core plugin functionality +5. **Register**: Add registration in `init()` +6. **Test**: Ensure plugin works in isolation and with others +7. **Document**: Provide clear documentation and examples \ No newline at end of file diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..03410e3 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,103 @@ +# Architecture Documentation + +This directory contains comprehensive architectural documentation for the Launchr project. + +## Documents Overview + +### [Architectural Patterns](ARCHITECTURAL_PATTERNS.md) +Comprehensive analysis of all architectural patterns used in Launchr, including: +- **Design Patterns**: Factory, Strategy, Decorator, Observer, Repository, etc. +- **Anti-Patterns**: Identified issues and code smells +- **Improvement Suggestions**: Detailed recommendations with priorities +- **Implementation Examples**: Code samples showing pattern usage + +### [Logging Architecture](LOGGING_ARCHITECTURE.md) +Deep dive into Launchr's sophisticated dual logging system: +- **Internal Logging (`Log()`)**: Developer-focused structured logging +- **Terminal Logging (`Term()`)**: User-facing styled output +- **Best Practices**: When to use each system +- **Configuration**: Runtime setup and customization + +### [Plugin System](PLUGIN_SYSTEM.md) +Complete guide to Launchr's extensible plugin architecture: +- **Plugin Interfaces**: Base and specialized plugin types +- **Registration System**: Weight-based plugin ordering +- **Core Plugins**: Built-in plugin functionality +- **Development Guide**: How to create custom plugins + +### [Service System](SERVICE_SYSTEM.md) +Documentation of the dependency injection and service management: +- **Service Container**: Type-safe service registration and retrieval +- **Core Services**: Config, Manager, PluginManager +- **Service Lifecycle**: Creation, registration, and usage +- **Best Practices**: Service design and implementation + +## Architecture Overview + +Launchr is built around several key architectural principles: + +### 1. **Plugin-Based Extensibility** +- Weight-based plugin system with lifecycle hooks +- Interface-driven design for maximum flexibility +- Clear separation between core and plugin functionality + +### 2. **Service-Oriented Architecture** +- Dependency injection through service container +- Type-safe service resolution using reflection +- Clean separation of concerns between services + +### 3. **Dual Logging System** +- Internal structured logging for developers (`Log()`) +- Styled terminal output for users (`Term()`) +- Runtime configuration and level management + +### 4. **Strategy Pattern for Runtimes** +- Multiple execution environments (shell, container, plugin) +- Pluggable runtime implementations +- Consistent lifecycle management across runtimes + +### 5. **Repository Pattern for Actions** +- Centralized action management and discovery +- CRUD operations with validation +- Multiple discovery strategies (filesystem, embedded) + +## Key Strengths + +✅ **Excellent Extensibility**: Plugin architecture allows easy feature addition +✅ **Clean Separation**: Clear boundaries between internal and user-facing code +✅ **Type Safety**: Reflection-based but type-safe service resolution +✅ **User Experience**: Sophisticated terminal output with styling +✅ **Developer Experience**: Structured logging with contextual information + +## Areas for Improvement + +⚠️ **Error Handling**: Replace panics with proper error returns +⚠️ **Thread Safety**: Add synchronization to terminal logging +⚠️ **Reflection Usage**: Consider compile-time alternatives +⚠️ **Interface Segregation**: Split large interfaces into focused ones +⚠️ **Plugin Dependencies**: Implement proper dependency resolution + +## Reading Guide + +1. **New Developers**: Start with [Plugin System](PLUGIN_SYSTEM.md) and [Service System](SERVICE_SYSTEM.md) +2. **Architecture Review**: Focus on [Architectural Patterns](ARCHITECTURAL_PATTERNS.md) +3. **Logging Implementation**: See [Logging Architecture](LOGGING_ARCHITECTURE.md) +4. **Contributing**: Review all documents for complete understanding + +## Relationship to Other Documentation + +This architecture documentation complements: +- **[../development/](../development/)**: Development guides and practices +- **[../actions.md](../actions.md)**: Action definition and usage +- **[../config.md](../config.md)**: Configuration management +- **[CLAUDE.md](../../CLAUDE.md)**: AI assistant guidance + +## Maintenance + +These documents should be updated when: +- New architectural patterns are introduced +- Existing patterns are significantly modified +- Major refactoring affects system architecture +- New services or plugins are added to the core system + +For questions or suggestions about the architecture, please create an issue or discussion in the project repository. \ No newline at end of file diff --git a/docs/architecture/SERVICE_SYSTEM.md b/docs/architecture/SERVICE_SYSTEM.md new file mode 100644 index 0000000..9520423 --- /dev/null +++ b/docs/architecture/SERVICE_SYSTEM.md @@ -0,0 +1,383 @@ +# Service System Architecture + +## Overview + +Launchr implements a service-oriented architecture using dependency injection pattern. The system provides a type-safe way to register and retrieve services throughout the application. + +## Core Service Architecture + +### Service Interface +```go +type Service interface { + ServiceInfo() ServiceInfo +} + +type ServiceInfo struct { + pkgPath string // Set automatically via InitServiceInfo() + typeName string // Set automatically via InitServiceInfo() +} +``` + +### ServiceCreate Interface +Services can implement `ServiceCreate` for lazy initialization: +```go +type ServiceCreate interface { + Service + ServiceCreate(svc *ServiceManager) Service +} +``` + +### Service Container +The `ServiceManager` is a basic Dependency Injection container: + +```go +type ServiceManager struct { + services map[ServiceInfo]Service +} + +func (sm *ServiceManager) Add(s Service) { + info := sm.serviceInfo(s) + if _, ok := sm.services[info]; ok { + panic(fmt.Errorf("service %s already exists, review your code", info)) + } + sm.services[info] = s +} + +func (sm *ServiceManager) Get(v any) { + // 1. Try direct lookup by ServiceInfo + // 2. Find by type match using reflection + // 3. Try lazy creation via ServiceCreate interface + // Panics if service not found +} +``` + +The application delegates to `ServiceManager`: +```go +type appImpl struct { + services *ServiceManager +} + +// Deprecated: use app.Services().Add(s) +func (app *appImpl) AddService(s Service) { app.services.Add(s) } +// Deprecated: use app.Services().Get(&v) +func (app *appImpl) GetService(v any) { app.services.Get(v) } +// Preferred way to access services: +func (app *appImpl) Services() *ServiceManager { return app.services } +``` + +## Core Services + +### 1. Configuration Service +**Type**: `Config` (type alias for `*config`) +**Implementation**: `launchr.ConfigFromFS` +**Purpose**: YAML-based configuration management with lazy creation via `ServiceCreate` + +```go +// Config is a type alias, not an interface +type Config = *config + +// Key methods: +func (cfg *config) Exists(key string) bool +func (cfg *config) Get(key string, v any) error +func (cfg *config) DirPath() string +func (cfg *config) Path(parts ...string) string +``` + +**Usage**: +```go +var config launchr.Config +app.Services().Get(&config) + +var timeout time.Duration +config.Get("action.timeout", &timeout) +``` + +### 2. Action Manager Service +**Type**: `Manager` (type alias for `*actionManagerMap`) +**Implementation**: `action.NewManager` with lazy creation via `ServiceCreate` +**Purpose**: Action lifecycle management + +```go +// Manager is a type alias, not an interface +type Manager = *actionManagerMap + +// Key methods: +func (m *actionManagerMap) All() map[string]*Action +func (m *actionManagerMap) Get(id string) (*Action, bool) +func (m *actionManagerMap) Add(a *Action) error +func (m *actionManagerMap) Delete(id string) +func (m *actionManagerMap) AddDiscovery(fn DiscoverActionsFn) +func (m *actionManagerMap) Decorate(a *Action, withFns ...DecorateWithFn) +``` + +**Usage**: +```go +var manager action.Manager +app.Services().Get(&manager) + +actions := manager.All() +``` + +### 3. Plugin Manager Service +**Type**: `PluginManager` (type alias for `pluginManagerMap`) +**Implementation**: `launchr.NewPluginManagerWithRegistered` +**Purpose**: Plugin management and discovery + +```go +// PluginManager is a type alias, not an interface +type PluginManager = pluginManagerMap + +// Methods: +func (m pluginManagerMap) ServiceInfo() ServiceInfo +func (m pluginManagerMap) All() PluginsMap + +// GetPluginByType is a standalone generic function: +func GetPluginByType[T Plugin](mngr PluginManager) []MapItem[PluginInfo, T] +``` + +**Usage**: +```go +var pluginMgr launchr.PluginManager +app.Services().Get(&pluginMgr) + +plugins := launchr.GetPluginByType[OnAppInitPlugin](pluginMgr) +``` + +## Service Registration + +Services are registered during application initialization. Core services (`Config`, `Manager`) +use lazy creation via `ServiceCreate` interface - they are instantiated on first `Get()` call: + +```go +func (app *appImpl) init() error { + // Create ServiceManager + app.services = launchr.NewServiceManager() + app.pluginMngr = launchr.NewPluginManagerWithRegistered() + + // Register core services eagerly + app.services.Add(app.mask) // SensitiveMask + app.services.Add(app.pluginMngr) // PluginManager + + // Config and Manager are created lazily via ServiceCreate + // when first requested with app.Services().Get(&config) + return nil +} +``` + +## Service Usage Patterns + +### In Plugins +```go +func (p *MyPlugin) OnAppInit(app launchr.App) error { + // Get required services via ServiceManager + var config launchr.Config + app.Services().Get(&config) + + var manager action.Manager + app.Services().Get(&manager) + + // Use services + return p.initializeWithServices(config, manager) +} +``` + +### In Application Code +```go +func (app *appImpl) someMethod() error { + var manager action.Manager + app.Services().Get(&manager) + + return manager.SomeOperation() +} +``` + +## Service Lifecycle + +### 1. Eager Registration Phase +Core services are registered during app initialization: +```go +app.services = launchr.NewServiceManager() +app.services.Add(app.mask) +app.services.Add(app.pluginMngr) +``` + +### 2. Lazy Creation Phase +Services implementing `ServiceCreate` are instantiated on first `Get()`: +```go +// Config implements ServiceCreate - created on first access +func (cfg *config) ServiceCreate(_ *ServiceManager) Service { + return ConfigFromFS(os.DirFS("." + name)) +} + +// Manager implements ServiceCreate - created on first access +func (m *actionManagerMap) ServiceCreate(svc *ServiceManager) Service { + var config Config + svc.Get(&config) + return NewManager(WithDefaultRuntime(config), ...) +} +``` + +### 3. Usage Phase +Services are retrieved when needed: +```go +var config launchr.Config +app.Services().Get(&config) +``` + +## Service Implementation Example + +```go +type MyService struct { + name string + config Config +} + +func (s *MyService) ServiceInfo() launchr.ServiceInfo { + return launchr.ServiceInfo{} +} + +func (s *MyService) DoSomething() error { + // Service logic + return nil +} + +func NewMyService(config launchr.Config) *MyService { + return &MyService{ + name: "my-service", + config: config, + } +} +``` + +## Service Dependencies + +Services can depend on other services through constructor injection: + +```go +func NewActionManager(config Config, pluginMgr PluginManager) Manager { + return &actionManager{ + config: config, + pluginMgr: pluginMgr, + } +} +``` + +## Best Practices + +### Service Design +1. **Single Responsibility**: Each service should have one clear purpose +2. **Interface Segregation**: Define focused interfaces +3. **Dependency Injection**: Use constructor injection for dependencies +4. **Immutable State**: Prefer immutable service configuration + +### Service Registration +```go +// Use app.Services() to access ServiceManager +app.Services().Add(myService) + +// Services with lazy creation implement ServiceCreate: +type MyService struct{} +func (s *MyService) ServiceInfo() launchr.ServiceInfo { return launchr.ServiceInfo{} } +func (s *MyService) ServiceCreate(svc *launchr.ServiceManager) launchr.Service { + // Create service with dependencies + return NewMyService() +} +``` + +### Service Retrieval +```go +// Services are retrieved by type via reflection. +// Panics if service not found - ensure service is registered. +var config launchr.Config +app.Services().Get(&config) +config.Get("key", &value) +``` + +## Strengths + +1. **Type Safety**: Reflection-based but type-safe service retrieval +2. **Dependency Injection**: Clean separation of concerns +3. **Service Discovery**: Easy access to registered services +4. **Interface-Based**: Services defined by contracts, not implementations + +## Limitations + +1. **Reflection Overhead**: Runtime reflection for service retrieval +2. **Panic on Missing**: Missing services cause panics +3. **No Lifecycle Management**: Services don't have explicit lifecycle hooks (except `ServiceCreate` for lazy init) +4. **Single Instance**: Each service type can only have one instance + +## Improvement Opportunities + +### 1. Service Lifecycle Management +```go +type ServiceLifecycle interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error + Health() error +} +``` + +### 2. Service Scoping +```go +type ServiceScope string + +const ( + ScopeSingleton ServiceScope = "singleton" + ScopeTransient ServiceScope = "transient" + ScopeScoped ServiceScope = "scoped" +) +``` + +### 3. Service Factories +Note: `ServiceCreate` interface already provides basic factory capability: +```go +type ServiceCreate interface { + Service + ServiceCreate(svc *ServiceManager) Service +} +``` + +### 4. Graceful Error Handling +```go +func (app *appImpl) GetService(v any) error { + // Return error instead of panic + if service, exists := app.findService(v); exists { + setValue(v, service) + return nil + } + return fmt.Errorf("service %T not found", v) +} +``` + +## Advanced Patterns + +### Service Composition +```go +type CompositeService struct { + config Config + manager Manager + logger Logger +} + +func (s *CompositeService) ServiceInfo() launchr.ServiceInfo { + return launchr.ServiceInfo{} +} +``` + +### Service Delegation +```go +type DelegatingService struct { + delegate Service +} + +func (s *DelegatingService) DoWork() error { + // Add cross-cutting concerns + log.Debug("starting work") + defer log.Debug("finished work") + + return s.delegate.DoWork() +} +``` + +The service system provides a clean, type-safe way to manage dependencies and share functionality across the application while maintaining loose coupling between components. \ No newline at end of file diff --git a/plugins/builder/environment.go b/plugins/builder/environment.go index 61d5412..0c59d5e 100644 --- a/plugins/builder/environment.go +++ b/plugins/builder/environment.go @@ -108,7 +108,7 @@ func (env *buildEnvironment) CreateModFile(ctx context.Context, opts *BuildOptio } // Download core. - err = env.execGoGet(ctx, opts.CorePkg.String()) + err = env.getOrRequire(ctx, opts.CorePkg, opts.ModReplace) if err != nil { return err } @@ -122,7 +122,7 @@ nextPlugin: continue nextPlugin } } - err = env.execGoGet(ctx, p.String()) + err = env.getOrRequire(ctx, p, opts.ModReplace) if err != nil { return err } @@ -145,6 +145,16 @@ func (env *buildEnvironment) NewCommand(ctx context.Context, command string, arg return cmd } +// getOrRequire downloads the module with "go get". For replaced modules, +// the version is stripped because it can't be resolved from the network +// when the module is replaced with a local path. +func (env *buildEnvironment) getOrRequire(ctx context.Context, p UsePluginInfo, modReplace map[string]string) error { + if _, replaced := modReplace[p.Path]; replaced { + return env.execGoGet(ctx, p.Path) + } + return env.execGoGet(ctx, p.String()) +} + func (env *buildEnvironment) execGoMod(ctx context.Context, args ...string) error { cmd := env.NewCommand(ctx, env.Go(), append([]string{"mod"}, args...)...) // Don't output go output unless some verbosity is requested. diff --git a/test/testdata/build/replace.txtar b/test/testdata/build/replace.txtar new file mode 100644 index 0000000..887cd44 --- /dev/null +++ b/test/testdata/build/replace.txtar @@ -0,0 +1,22 @@ +# Launchr Replace Module Test +# +# Validates that replaced modules are properly required in go.mod. +# Regression test for: "replaced but not required" error when using --replace flag. +# When a module is replaced, `go mod edit -require` must be used to add it +# to go.mod, otherwise Go compilation fails. + +env HOME=$REAL_HOME +env APP_PLUGIN_LOCAL=example.com/genaction@v1.1.1 +env APP_PLUGIN_LOCAL_PATH=$REPO_PATH/test/plugins/genaction + +# Test 1: Build with only replaced plugin (no external plugins). +# This is the minimal reproduction of the "replaced but not required" bug. +! exists launchr +exec launchr build -r $CORE_PKG=$REPO_PATH -p $APP_PLUGIN_LOCAL -r $APP_PLUGIN_LOCAL=$APP_PLUGIN_LOCAL_PATH +! stderr . +exists launchr + +# Test 2: Verify the built binary works. +exec ./launchr genaction:example +stdout 'hello world' +! stderr .