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.