Fix: improve Docker startup and compose validation#78
Fix: improve Docker startup and compose validation#78austin047 wants to merge 6 commits intoflatrun:mainfrom
Conversation
Code Review SummaryThis PR improves Docker integration by verifying reachability at startup, creating necessary directories, and implementing robust Compose validation using 🚀 Key Improvements
💡 Minor Suggestions
🚨 Critical Issues
|
| @@ -42,6 +43,15 @@ func main() { | |||
| log.Fatalf("Failed to load config from %s: %v", resolvedConfigPath, err) | |||
| } | |||
There was a problem hiding this comment.
Using exec.Command("docker", "info") relies on the docker CLI being installed in the PATH. Since you've added the moby/moby client dependency, it is more efficient and reliable to check connectivity using the library's Ping() or Info() methods instead of spawning a shell process.
| } | |
| func ensureDockerReachable(socket string) { | |
| ctx := context.Background() | |
| cli, err := client.NewClientWithOpts(client.WithHost(socket), client.WithAPIVersionNegotiation()) | |
| if err != nil { | |
| log.Fatalf("Failed to create Docker client: %v", err) | |
| } | |
| defer cli.Close() | |
| if _, err := cli.Ping(ctx); err != nil { | |
| log.Fatalf("Docker is not reachable: %v", err) | |
| } | |
| } |
internal/api/server.go
Outdated
| return nil | ||
| } | ||
|
|
||
| func validateComposeWithCLI(content string) error { |
There was a problem hiding this comment.
Closing the file before deleting it is safer on some operating systems (like Windows). Also, consider using defer os.Remove(tmp.Name()) immediately after creation to ensure cleanup even if subsequent steps fail.
| func validateComposeWithCLI(content string) error { | |
| tmp, err := os.CreateTemp("", "compose-*.yml") | |
| if err != nil { | |
| log.Printf("Warning: skipping CLI validation, failed to create temp file: %v", err) | |
| return nil | |
| } | |
| defer os.Remove(tmp.Name()) | |
| defer tmp.Close() |
| log.Fatalf("Failed to load config from %s: %v", resolvedConfigPath, err) | ||
| } | ||
|
|
||
| os.Setenv("DOCKER_HOST", cfg.DockerSocket) |
There was a problem hiding this comment.
Setting DOCKER_HOST globally via os.Setenv can cause side effects if other parts of the application or third-party libraries expect a different environment. Since you've already added the Moby client, it is better to pass the socket explicitly to constructors rather than relying on global environment variables.
| os.Setenv("DOCKER_HOST", cfg.DockerSocket) | |
| ensureDockerReachable(cfg.DockerSocket) |
pkg/config/config.go
Outdated
| } | ||
| if cfg.DockerSocket == "" { | ||
| cfg.DockerSocket = "unix:///var/run/docker.sock" | ||
| apiClient, err := client.New(client.FromEnv) |
There was a problem hiding this comment.
Creating a new client just to detect the default host is slightly heavy. Note that client.FromEnv will look at the DOCKER_HOST environment variable first. Ensure this fallback logic matches the expected behavior if the agent is running inside a container.
| apiClient, err := client.New(client.FromEnv) | |
| cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | |
| if err == nil { | |
| cfg.DockerSocket = cli.DaemonHost() | |
| cli.Close() | |
| } else { | |
| cfg.DockerSocket = "unix:///var/run/docker.sock" | |
| } |
| @@ -98,15 +101,15 @@ type InfrastructureConfig struct { | |||
| } | |||
There was a problem hiding this comment.
Relying on docker context inspect via exec.Command adds unnecessary overhead and a dependency on the CLI binary being in the PATH at this specific moment. Since you've added the moby/moby client dependency, it is better to use the library's environment detection logic, which handles context, environment variables, and platform-specific defaults correctly.
| } | |
| func detectDockerHost() string { | |
| cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | |
| if err != nil { | |
| // Fallback to platform defaults if client creation fails | |
| if runtime.GOOS == "windows" { | |
| return "npipe:////./pipe/docker_engine" | |
| } | |
| return "unix:///var/run/docker.sock" | |
| } | |
| host := cli.DaemonHost() | |
| cli.Close() | |
| return host | |
| } |
internal/api/server.go
Outdated
| _ = os.Remove(tmpName) | ||
| }() | ||
|
|
||
| if _, err := tmp.WriteString(content); err != nil { |
There was a problem hiding this comment.
The current implementation writes to the file, syncs, closes it, and then re-opens it via the docker compose CLI. While functional, it's cleaner to handle errors on WriteString and Sync more strictly if you want to ensure validation occurs. More importantly, since the file is already closed on line 2853, the deferred tmp.Close() on line 2841 will return an error (which is ignored), but it's redundant.
| if _, err := tmp.WriteString(content); err != nil { | |
| if _, err := tmp.WriteString(content); err != nil { | |
| return fmt.Errorf("failed to write temp file for validation: %w", err) | |
| } | |
| if err := tmp.Close(); err != nil { | |
| return fmt.Errorf("failed to close temp file: %w", err) | |
| } |
There was a problem hiding this comment.
Nice work, thanks for this a few things to address:
- The PR description mentions adding moby/moby but it's not in the diff. Let's use the official Docker Go SDK (
github.com/docker/docker/client) and compose-go SDK (github.com/compose-spec/compose-go) instead of shelling out to the CLI they handle socket detection, context resolution, and compose validation natively. - None of the new
exec.Commandcalls have timeouts a hanging Docker daemon blocks the agent indefinitely. ./deploymentin the example config should be an absolute path outside the code directory and naming should be consistent with.gitignore.
| _ = apiServer.Stop() | ||
| } | ||
|
|
||
| func ensureDockerReachable(_ string) { |
There was a problem hiding this comment.
The os.Setenv("DOCKER_HOST") above unconditionally overwrites the env var even when empty, and this function accepts a socket param but ignores it (_ string). There is also no timeout a hanging daemon blocks startup forever.
Let's use the Docker Go SDK instead: client.NewClientWithOpts(client.FromEnv) + client.Ping(ctx) handles host resolution, timeouts via context, and doesn't need the docker binary in PATH.
There was a problem hiding this comment.
We can use github.com/compose-spec/compose-go just did not want add to do just one thing
internal/api/server.go
Outdated
| } | ||
| } | ||
|
|
||
| if err := validateComposeWithCLI(content); err != nil { |
There was a problem hiding this comment.
Let's replace this with the compose-go SDK (github.com/compose-spec/compose-go/loader). It validates compose files in-process no temp files, no subprocess per request, proper Go error types, and no dependency on docker compose CLI being installed.
As-is this also has no timeout, so a hung daemon blocks the request handler.
config.example.yml
Outdated
| @@ -1,4 +1,4 @@ | |||
| deployments_path: /home/nfebe/work/deployments | |||
| deployments_path: ./deployment | |||
There was a problem hiding this comment.
./deployment (singular) doesn't match .gitignore which has deployments/ (plural). Also a relative path puts deployment data inside the project directory — something like /var/lib/flatrun/deployments would be a better default.
There was a problem hiding this comment.
I think we should stick to ./deployments, /var/lib/flatrun/deployments will require permission. If the user then wants to keep in that directory, they should create it. But for now lets stick to the ./deployments
| } | ||
| } | ||
|
|
||
| func detectDockerHost() string { |
There was a problem hiding this comment.
Running docker context inspect as a side-effect of config.Load() makes config loading slower, silently broken if docker isn't installed, and harder to test.
The Docker Go SDK handles this natively — client.NewClientWithOpts(client.FromEnv) resolves DOCKER_HOST, Docker context, and platform defaults automatically. We can drop detectDockerHost() and dockerContextHost() entirely.
There was a problem hiding this comment.
I think if we have to rely on the DOCKER_HOST from client.FromEnv, then we will have to remove the socket from the config. It is kind of misleading in a way, because it doesn't get used in the load config.
|
The Docker client has been migrated to moby/moby, even though you can still use the previous one. You can check out this moby/moby#50472 , I evaluated it, and we could safely use moby/moby as of this date and when the migration was done. You can have a look and then we settle. |
Thanks for this! But you did not add |
| _ = apiServer.Stop() | ||
| } | ||
|
|
||
| func ensureDockerReachable(_ string) { |
There was a problem hiding this comment.
The previous review suggested using the Moby client library's Ping() or Info() instead of exec.Command("docker", "info"). This implementation still uses the shell command, which depends on the CLI being installed in the PATH and carries more overhead than an API call.
| func ensureDockerReachable(_ string) { | |
| func ensureDockerReachable(socket string) { | |
| log.Println("Checking if Docker is reachable...") | |
| ctx := context.Background() | |
| cli, err := client.NewClientWithOpts(client.WithHost(socket), client.WithAPIVersionNegotiation()) | |
| if err != nil { | |
| log.Fatalf("Failed to create Docker client: %v", err) | |
| } | |
| defer cli.Close() | |
| if _, err := cli.Ping(ctx); err != nil { | |
| log.Fatalf("Docker is not reachable: %v", err) | |
| } | |
| log.Println("Docker is reachable") | |
| } |
| return "unix:///var/run/docker.sock" | ||
| } | ||
|
|
||
| func dockerContextHost() string { |
There was a problem hiding this comment.
Relying on docker context inspect via exec.Command adds unnecessary overhead and a dependency on the CLI binary. Use the Moby client's environment detection logic instead, which is already designed to handle contexts and platform defaults correctly.
| func dockerContextHost() string { | |
| func detectDockerHost() string { | |
| cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | |
| if err != nil { | |
| if runtime.GOOS == "windows" { | |
| return "npipe:////./pipe/docker_engine" | |
| } | |
| return "unix:///var/run/docker.sock" | |
| } | |
| host := cli.DaemonHost() | |
| cli.Close() | |
| return host | |
| } |
|
We will have to use the CLI to detect the host. The default implementation of the Moby client always falls back to platform-specific paths, and in some cases, the current context is the default OS socket context. For example, on my system the default path is So for now, using the CLI for this particular operation before setting |
config.exampledeployments_path to ./deployment