diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf43c4..6757e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.11](https://github.com/PerimeterX/envite/compare/v0.0.10...v0.0.11) + +### Added + +- Added docker runtime awareness with support for the following: + - Docker Desktop + - Colima (with 3-second network latency) + - Podman + - Rancher Desktop + - Lima + - OrbStack + - Minikube + - ContainerD + - Finch +- `ExtractRuntimeInfo` function to detect runtime type from Docker client info +- Runtime-specific internal hostname mapping (e.g., `host.docker.internal`, `host.lima.internal`) +- Network latency configuration for runtimes that require startup delays +- Improved error handling with error wrapping to provide better error messages + ## [0.0.10](https://github.com/PerimeterX/envite/compare/v0.0.9...v0.0.10) ### Fixed diff --git a/README.md b/README.md index 951931e..504f6df 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ A framework to manage development and testing environments. - [Flags and Options](#flags-and-options) - [Adding Custom Components](#adding-custom-components) * [Key Elements of ENVITE](#key-elements-of-envite) +* [Runtime Awareness](#runtime-awareness) * [Local Development](#local-development) * [Contact and Contribute](#contact-and-contribute) * [ENVITE Logo](#envite-logo) @@ -334,6 +335,12 @@ functional environment. * `Component` Graph: Organizes components into layers and defines their relationships. * `Server`: Allow serving a UI to manage the environment. +## Runtime Awareness + +ENVITE automatically detects and adapts to different Docker-compatible runtimes (Docker Desktop, Colima, Podman, Rancher Desktop, Lima, OrbStack, Minikube, ContainerD, and Finch). This runtime awareness allows ENVITE to handle runtime-specific behaviors automatically. + +> Colima has some latency when attaching networking stack of new containers. This may lead to issue when adding log message based waiters. As a workaround, ENVITE adds a 3-second wait time after creating containers, to allow colima to finalize networking. This may not work perfectly as it depends on the time it takes colima to complete. + ## Local Development To locally work on ENVITE UI, cd into the `ui` dir and run react dev server using `npm start`. diff --git a/api.go b/api.go index 25f110e..f367d48 100644 --- a/api.go +++ b/api.go @@ -76,13 +76,13 @@ type GetStatusResponseComponent struct { func buildComponentInfo(c Component) (map[string]any, error) { data, err := json.Marshal(c.Config()) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to marshal component config: %w", err) } var result map[string]any err = json.Unmarshal(data, &result) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to unmarshal component config: %w", err) } if result == nil { diff --git a/cmd/envite/environment.go b/cmd/envite/environment.go index 4ec69f2..5f1e26d 100644 --- a/cmd/envite/environment.go +++ b/cmd/envite/environment.go @@ -162,7 +162,7 @@ func extractComponentType(err error, data []byte) (string, error) { } err = json.Unmarshal(data, &t) if err != nil { - return "", err + return "", fmt.Errorf("could not unmarshal component type: %w", err) } return t.Type, nil diff --git a/docker/component.go b/docker/component.go index 716ca3a..6ac07af 100644 --- a/docker/component.go +++ b/docker/component.go @@ -9,6 +9,12 @@ import ( "context" "encoding/json" "fmt" + "math" + "strings" + "sync" + "sync/atomic" + "time" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" @@ -18,11 +24,6 @@ import ( "github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/pkg/stdcopy" "github.com/perimeterx/envite" - "math" - "strings" - "sync" - "sync/atomic" - "time" ) // ComponentType is the type identifier for the Docker component. @@ -33,6 +34,7 @@ type Component struct { lock sync.Mutex envID string cli *client.Client + runtimeInfo *RuntimeInfo config Config runConfig *runConfig network *Network @@ -48,6 +50,7 @@ type Component struct { // docker components must be instantiated via a Network. func newComponent( cli *client.Client, + runtimeInfo *RuntimeInfo, envID string, network *Network, config Config, @@ -55,7 +58,7 @@ func newComponent( imageCloneTag := fmt.Sprintf("%s_%s", config.Image, envID) runConf, err := config.initialize(network, imageCloneTag) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to initialize component config: %w", err) } containerName := fmt.Sprintf("%s_%s", envID, config.Name) @@ -63,6 +66,7 @@ func newComponent( c := &Component{ cli: cli, + runtimeInfo: runtimeInfo, config: config, envID: envID, runConfig: runConf, @@ -176,6 +180,9 @@ func (c *Component) Start(ctx context.Context) error { } c.monitorStartingStatus(id, true) + if c.runtimeInfo.NetworkLatency > 0 { + time.Sleep(c.runtimeInfo.NetworkLatency) + } return nil } @@ -195,11 +202,11 @@ func (c *Component) startContainer(ctx context.Context) (string, error) { if err == nil { id = res.ID } else if !errdefs.IsConflict(err) { - return "", err + return "", fmt.Errorf("failed to create container: %w", err) } else { cont, err := c.findContainer(ctx) if err != nil { - return "", err + return "", fmt.Errorf("failed to find container: %w", err) } id = cont.ID @@ -207,7 +214,7 @@ func (c *Component) startContainer(ctx context.Context) (string, error) { err = c.cli.ContainerStart(context.Background(), id, container.StartOptions{}) if err != nil { - return "", err + return "", fmt.Errorf("failed to start container: %w", err) } go c.writeLogs(id) @@ -279,7 +286,7 @@ func (c *Component) Status(context.Context) (envite.ComponentStatus, error) { // check if container stopped cont, err := c.findContainer(context.Background()) if err != nil { - return "", err + return "", fmt.Errorf("failed to find container: %w", err) } if cont == nil || cont.State != "running" { @@ -316,7 +323,7 @@ func (c *Component) Config() any { func (c *Component) Exec(ctx context.Context, cmd []string) (int, error) { cont, err := c.findContainer(ctx) if err != nil { - return 0, err + return 0, fmt.Errorf("failed to find container: %w", err) } c.Writer().WriteString(c.Writer().Color.Cyan(fmt.Sprintf("executing: %s", strings.Join(cmd, " ")))) @@ -327,12 +334,12 @@ func (c *Component) Exec(ctx context.Context, cmd []string) (int, error) { AttachStderr: true, }) if err != nil { - return 0, err + return 0, fmt.Errorf("failed to create exec: %w", err) } hijack, err := c.cli.ContainerExecAttach(ctx, response.ID, types.ExecStartCheck{}) if err != nil { - return 0, err + return 0, fmt.Errorf("failed to attach exec: %w", err) } scanner := bufio.NewScanner(hijack.Reader) @@ -344,7 +351,7 @@ func (c *Component) Exec(ctx context.Context, cmd []string) (int, error) { execResp, err := c.cli.ContainerExecInspect(ctx, response.ID) if err != nil { - return 0, err + return 0, fmt.Errorf("failed to inspect exec: %w", err) } c.Writer().WriteString(c.Writer().Color.Cyan(fmt.Sprintf("exit code: %d", execResp.ExitCode))) @@ -357,7 +364,7 @@ func (c *Component) findContainer(ctx context.Context) (*types.Container, error) Filters: filters.NewArgs(filters.Arg("name", c.containerName)), }) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to list containers: %w", err) } for _, co := range containers { diff --git a/docker/config.go b/docker/config.go index 198bad7..7a8e6b7 100644 --- a/docker/config.go +++ b/docker/config.go @@ -652,7 +652,7 @@ func (c Config) imagePullOptions() (image.PullOptions, error) { var err error auth, err = c.ImagePullOptions.RegistryAuthFunc() if err != nil { - return image.PullOptions{}, err + return image.PullOptions{}, fmt.Errorf("failed to get registry auth: %w", err) } } else { auth = c.ImagePullOptions.RegistryAuth diff --git a/docker/network.go b/docker/network.go index 49a038b..632acd1 100644 --- a/docker/network.go +++ b/docker/network.go @@ -8,16 +8,17 @@ import ( "context" _ "embed" "fmt" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/client" - "github.com/docker/go-connections/nat" "os" "regexp" "runtime" "strings" "sync" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" ) // Network represents a Docker network. @@ -28,6 +29,7 @@ import ( // component, err := network.NewComponent(dockerComponentConfig) type Network struct { client *client.Client + runtimeInfo *RuntimeInfo envID string ID string lock sync.Mutex @@ -44,13 +46,18 @@ type Network struct { // - On linux, it will create a network with mode "host" and attach new components to it. // - On other types of OS, it will create a network in mode "bridge" and expose ports for all components. func NewNetwork(cli *client.Client, networkID, envID string) (*Network, error) { + runtimeInfo, err := ExtractRuntimeInfo(context.Background(), cli) + if err != nil { + return nil, fmt.Errorf("failed to extract runtime info: %w", err) + } + if networkID != "" { - return newClosedNetwork(cli, envID, networkID) + return newClosedNetwork(cli, envID, networkID, runtimeInfo) } if runtime.GOOS == "linux" { - return newOpenLinuxNetwork(cli, envID) + return newOpenLinuxNetwork(cli, envID, runtimeInfo) } - return newOpenNetwork(cli, envID) + return newOpenNetwork(cli, envID, runtimeInfo) } // NewComponent creates a new Docker component within the network. @@ -58,22 +65,23 @@ func (n *Network) NewComponent(config Config) (*Component, error) { if n.OnNewComponent != nil { n.OnNewComponent(&config) } - return newComponent(n.client, n.envID, n, config) + return newComponent(n.client, n.runtimeInfo, n.envID, n, config) } -func newClosedNetwork(cli *client.Client, envID, networkIdentifier string) (*Network, error) { +func newClosedNetwork(cli *client.Client, envID, networkIdentifier string, runtimeInfo *RuntimeInfo) (*Network, error) { networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{}) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to list networks: %w", err) } nw, err := findNetwork(networks, networkIdentifier) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to find network: %w", err) } return &Network{ client: cli, + runtimeInfo: runtimeInfo, envID: envID, shouldDelete: false, ID: nw.ID, @@ -87,14 +95,15 @@ func newClosedNetwork(cli *client.Client, envID, networkIdentifier string) (*Net }, nil } -func newOpenLinuxNetwork(cli *client.Client, envID string) (*Network, error) { +func newOpenLinuxNetwork(cli *client.Client, envID string, runtimeInfo *RuntimeInfo) (*Network, error) { id, err := createNetworkIfNotExist(cli, envID, "host") if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create network: %w", err) } return &Network{ client: cli, + runtimeInfo: runtimeInfo, envID: envID, shouldDelete: true, ID: id, @@ -108,19 +117,20 @@ func newOpenLinuxNetwork(cli *client.Client, envID string) (*Network, error) { }, nil } -func newOpenNetwork(cli *client.Client, envID string) (*Network, error) { - err := validateHostsFile() +func newOpenNetwork(cli *client.Client, envID string, runtimeInfo *RuntimeInfo) (*Network, error) { + err := validateHostsFile(runtimeInfo) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to validate hosts file: %w", err) } id, err := createNetworkIfNotExist(cli, envID, "bridge") if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create network: %w", err) } return &Network{ client: cli, + runtimeInfo: runtimeInfo, envID: envID, shouldDelete: true, ID: id, @@ -129,7 +139,7 @@ func newOpenNetwork(cli *client.Client, envID string) (*Network, error) { runConfig.networkingConfig = &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{id: {NetworkID: id}}, } - runConfig.hostname = "host.docker.internal" + runConfig.hostname = runtimeInfo.InternalHostname runConfig.containerConfig.ExposedPorts = nat.PortSet{} runConfig.hostConfig.PortBindings = nat.PortMap{} for _, port := range config.Ports { @@ -173,7 +183,7 @@ func createNetworkIfNotExist(cli *client.Client, name, driver string) (string, e }) if err != nil { if !strings.Contains(err.Error(), "already exists") { - return "", err + return "", fmt.Errorf("failed to create network: %w", err) } return name, nil @@ -198,8 +208,8 @@ var setupNeeded string var setupFinished string // validateHostsFile installs necessary steps to /etc/hosts if needed -func validateHostsFile() error { - valid, err := isHostsFileValid() +func validateHostsFile(runtimeInfo *RuntimeInfo) error { + valid, err := isHostsFileValid(runtimeInfo) if err != nil { return err } @@ -208,26 +218,29 @@ func validateHostsFile() error { return nil } - err = updateHostsFile() + err = updateHostsFile(runtimeInfo) if err == nil { - fmt.Println(setupFinished) + fmt.Println(fmt.Sprintf(setupFinished, runtimeInfo.Runtime)) os.Exit(0) } if strings.Contains(err.Error(), "permission denied") { - fmt.Println(setupNeeded) + fmt.Println(fmt.Sprintf(setupNeeded, runtimeInfo.Runtime, runtimeInfo.InternalHostname)) os.Exit(1) } return err } -var hostsEntryRE = regexp.MustCompile(`^\s*127\.0\.0\.1\s+host\.docker\.internal\s*$`) - -func isHostsFileValid() (bool, error) { +func isHostsFileValid(runtimeInfo *RuntimeInfo) (bool, error) { data, err := os.ReadFile("/etc/hosts") if err != nil { - return false, err + return false, fmt.Errorf("failed to read hosts file: %w", err) + } + + hostsEntryRE, err := regexp.Compile(fmt.Sprintf(`^\s*127\.0\.0\.1\s+%s\s*$`, strings.ReplaceAll(runtimeInfo.InternalHostname, ".", "\\."))) + if err != nil { + return false, fmt.Errorf("failed to compile hosts entry regex: %w", err) } lines := strings.Split(string(data), "\n") @@ -240,13 +253,13 @@ func isHostsFileValid() (bool, error) { return false, nil } -func updateHostsFile() error { +func updateHostsFile(runtimeInfo *RuntimeInfo) error { f, err := os.OpenFile("/etc/hosts", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } - _, err = f.WriteString("\n# To allow envite to create open docker networks\n127.0.0.1 host.docker.internal\n# End of section\n") + _, err = f.WriteString(fmt.Sprintf("\n# To allow ENVITE to create open docker networks\n127.0.0.1 %s\n# End of section\n", runtimeInfo.InternalHostname)) if err != nil { _ = f.Close() return err diff --git a/docker/runtime.go b/docker/runtime.go new file mode 100644 index 0000000..2039070 --- /dev/null +++ b/docker/runtime.go @@ -0,0 +1,95 @@ +package docker + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/docker/docker/client" +) + +// RuntimeInfo contains information about a runtime type +type RuntimeInfo struct { + // Runtime is the name of the runtime + Runtime string + // InternalHostname is the hostname to use for the docker daemon + InternalHostname string + // NetworkLatency some runtimes have a slight latency before network is ready when starting a container + NetworkLatency time.Duration +} + +// ExtractRuntimeInfo detects the Docker daemon implementation for the given client +func ExtractRuntimeInfo(ctx context.Context, cli *client.Client) (*RuntimeInfo, error) { + info, err := cli.Info(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get docker info: %w", err) + } + + name := strings.ToLower(info.Name) + serverVersion := strings.ToLower(info.ServerVersion) + + // Check for specific runtime indicators + if strings.Contains(name, "colima") || strings.Contains(serverVersion, "colima") { + return &RuntimeInfo{ + Runtime: "Colima", + InternalHostname: "host.lima.internal", + NetworkLatency: time.Second * 3, + }, nil + } + + if strings.Contains(name, "podman") || strings.Contains(serverVersion, "podman") { + return &RuntimeInfo{ + Runtime: "Podman", + InternalHostname: "host.containers.internal", + }, nil + } + + if strings.Contains(name, "rancher") || strings.Contains(serverVersion, "rancher") { + return &RuntimeInfo{ + Runtime: "Rancher Desktop", + InternalHostname: "host.docker.internal", + }, nil + } + + if strings.Contains(name, "lima") || strings.Contains(serverVersion, "lima") { + return &RuntimeInfo{ + Runtime: "Lima", + InternalHostname: "host.lima.internal", + }, nil + } + + if strings.Contains(name, "orbstack") || strings.Contains(serverVersion, "orbstack") { + return &RuntimeInfo{ + Runtime: "OrbStack", + InternalHostname: "host.docker.internal", + }, nil + } + + if strings.Contains(name, "minikube") || strings.Contains(serverVersion, "minikube") { + return &RuntimeInfo{ + Runtime: "Minikube", + InternalHostname: "host.minikube.internal", + }, nil + } + + if strings.Contains(name, "containerd") || strings.Contains(serverVersion, "containerd") { + return &RuntimeInfo{ + Runtime: "ContainerD", + InternalHostname: "host.docker.internal", + }, nil + } + + if strings.Contains(name, "finch") || strings.Contains(serverVersion, "finch") { + return &RuntimeInfo{ + Runtime: "Finch", + InternalHostname: "host.docker.internal", + }, nil + } + + // Default to Docker Desktop for unknown runtimes + return &RuntimeInfo{ + Runtime: "Docker Desktop", + InternalHostname: "host.docker.internal", + }, nil +} diff --git a/docker/setup-finished.txt b/docker/setup-finished.txt index 7b56756..f916a0c 100644 --- a/docker/setup-finished.txt +++ b/docker/setup-finished.txt @@ -1,3 +1,3 @@ Done ☑️ -Network features will now work properly for any envite environment. +Network features will now work properly for any ENVITE environment with %s. You can now rerun the process WITHOUT sudo and continue normally. \ No newline at end of file diff --git a/docker/setup-needed.txt b/docker/setup-needed.txt index 1c274d5..12da6b6 100644 --- a/docker/setup-needed.txt +++ b/docker/setup-needed.txt @@ -1,4 +1,4 @@ Hey there 👋 -It seems to be the first time you're running envite with docker. -We need to install a quick change. To do it, rerun the process using sudo. -If you prefer, you can manually add `127.0.0.1 host.docker.internal` to `/etc/hosts` and then rerun without sudo. \ No newline at end of file +It seems to be the first time you're running ENVITE with %s. +We need to install a quick change. To do it, rerun the process once using sudo. +If you prefer, you can manually add `127.0.0.1 %s` to `/etc/hosts` and then rerun without sudo. \ No newline at end of file diff --git a/docker/waiter.go b/docker/waiter.go index b30a233..def3ae6 100644 --- a/docker/waiter.go +++ b/docker/waiter.go @@ -64,7 +64,7 @@ func validateWaiter(w Waiter) (waiterFunc, error) { case WaiterTypeRegex: re, err := regexp.Compile(w.Regex) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to compile regex: %w", err) } return func(ctx context.Context, cli *client.Client, containerID string, _ bool) error { @@ -86,7 +86,7 @@ func validateWaiter(w Waiter) (waiterFunc, error) { case WaiterTypeDuration: d, err := time.ParseDuration(w.Duration) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse duration: %w", err) } return func(_ context.Context, _ *client.Client, _ string, isNewContainer bool) error { diff --git a/environment.go b/environment.go index c82993c..19b0d85 100644 --- a/environment.go +++ b/environment.go @@ -60,7 +60,7 @@ func NewEnvironment(id string, componentGraph *ComponentGraph, options ...Option err := component.AttachEnvironment(context.Background(), b, om.writer(componentID)) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to attach environment to component %s: %w", componentID, err) } b.componentsByID[componentID] = component @@ -216,7 +216,7 @@ func (b *Environment) Status(ctx context.Context) (GetStatusResponse, error) { info, err := buildComponentInfo(component) if err != nil { - return GetStatusResponse{}, err + return GetStatusResponse{}, fmt.Errorf("failed to build component info for %s: %w", id, err) } components = append(components, GetStatusResponseComponent{ diff --git a/seed/redis/component.go b/seed/redis/component.go index b464221..9b17e00 100644 --- a/seed/redis/component.go +++ b/seed/redis/component.go @@ -141,7 +141,7 @@ func (r *SeedComponent) clientProvider() (*redis.Client, error) { options, err := redis.ParseURL(r.config.Address) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse redis address: %w", err) } return redis.NewClient(options), nil diff --git a/ui/src/ProductTour.tsx b/ui/src/ProductTour.tsx index 10038ae..bb19ea6 100644 --- a/ui/src/ProductTour.tsx +++ b/ui/src/ProductTour.tsx @@ -28,7 +28,7 @@ function ProductTour(props: ProductTourProps) { friendly than manipulating components one by one.

- When you apply state, envite takes care of the dependencies + When you apply state, ENVITE takes care of the dependencies between components by loading them in the required order and monitoring their status. It will also stop and re-run components to your request.