diff --git a/README.md b/README.md index 1a8347e..ca9bb22 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,12 @@ For a full list with all the supported options, see [the docs](./docs/installer- - Installs packages using pipx. +- **`docker`** + + - Pulls and runs a Docker container by name + - Supports optional `flags` + - Supports container name override (`bin_name`) + --- ## 📂 Example Workflow diff --git a/appconfig/installer_data.go b/appconfig/installer_data.go index e7225f6..56ed239 100644 --- a/appconfig/installer_data.go +++ b/appconfig/installer_data.go @@ -33,6 +33,7 @@ type InstallerType string const ( InstallerTypeGroup InstallerType = "group" InstallerTypeShell InstallerType = "shell" + InstallerTypeDocker InstallerType = "docker" InstallerTypeBrew InstallerType = "brew" InstallerTypeApt InstallerType = "apt" InstallerTypeApk InstallerType = "apk" diff --git a/docs/installer-configuration.md b/docs/installer-configuration.md index 8f9a835..84d0573 100644 --- a/docs/installer-configuration.md +++ b/docs/installer-configuration.md @@ -197,6 +197,7 @@ These fields are shared by all installer types. Some fields may vary in behavior - **`brew`** - **Description**: Installs packages using Homebrew. + - **Options**: - `opts.tap`: Name of the tap to install the package from. @@ -212,8 +213,55 @@ These fields are shared by all installer types. Some fields may vary in behavior - Use `type: apt` for `apt install`, and `type: apk` for `apk add`. - **`pipx`** + - **Description**: Installs packages using pipx. +- **`docker`** + + - **Description**: Pulls and runs Docker containers using `docker run`. Also supports update + checks by comparing image digests. + + - The image is pulled from the registry (e.g., Docker Hub or GHCR) and started with the provided + options. + - If the container already exists, it will be started instead of run again. + - Updates are detected by comparing the image digest before and after a pull. + - The container is always run with `--restart always -d`, unless overridden in a custom shell. + + - **Required**: + + - `name`: The full Docker image name, including tag (e.g., + `ghcr.io/open-webui/open-webui:main`). + - `bin_name`: The container name to assign to the running instance (used in install and update + checks). + + - **Options**: + + - `opts.flags`: A string of flags to pass to `docker run` (e.g., ports, volumes, extra args). + These are appended after the default flags and before the image name. + + - Example: + + ```yaml + opts: + flags: > + -p 3300:8080 -v data-volume:/app/data --add-host=host.docker.internal:host-gateway + ``` + + - `opts.platform`: Override the platform used when checking the image manifest for updates. + Accepts a per-OS map with values in `os/arch` format (e.g., `linux/amd64`). + + This is useful if you're running on a platform like `darwin/arm64`, but want to compare + digests for a different image target (e.g., `linux/amd64`). + + - Example: + + ```yaml + opts: + platform: + macos: linux/amd64 + linux: linux/amd64 + ``` + ## Installer Examples All of these examples should be usable, but don't count on them being maintained. Why not look at @@ -328,3 +376,14 @@ install: platforms: only: ['linux'] ``` + +### docker + +```yaml +- name: ghcr.io/open-webui/open-webui:main + bin_name: open-webui + type: docker + opts: + flags: > + -p 3300:8080 --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data +``` diff --git a/installer/docker_installer.go b/installer/docker_installer.go new file mode 100644 index 0000000..cf7e914 --- /dev/null +++ b/installer/docker_installer.go @@ -0,0 +1,278 @@ +package installer + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/chenasraf/sofmani/appconfig" + "github.com/chenasraf/sofmani/logger" + "github.com/chenasraf/sofmani/platform" +) + +type DockerInstaller struct { + InstallerBase + Config *appconfig.AppConfig + Info *appconfig.InstallerData +} + +type DockerOpts struct { + Flags *string + Platform *platform.PlatformMap[string] +} + +func NewDockerInstaller(cfg *appconfig.AppConfig, installer *appconfig.InstallerData) *DockerInstaller { + return &DockerInstaller{ + InstallerBase: InstallerBase{Data: installer}, + Config: cfg, + Info: installer, + } +} + +func (i *DockerInstaller) Validate() []ValidationError { + errors := i.BaseValidate() + return errors +} + +func (i *DockerInstaller) Install() error { + return i.runOrStartContainer(false) +} + +func (i *DockerInstaller) Update() error { + image := *i.Info.Name + containerName := i.GetContainerName() + + logger.Debug("Pulling updated image: %s", image) + if err := i.RunCmdAsFile(fmt.Sprintf("docker pull %s", image)); err != nil { + return fmt.Errorf("failed to pull image: %w", err) + } + + // Check if container exists before trying to remove + exists := exec.Command("docker", "inspect", containerName).Run() == nil + if exists { + logger.Debug("Removing existing container: %s", containerName) + _ = exec.Command("docker", "rm", "-f", containerName).Run() + } + + logger.Debug("Running updated container: %s", containerName) + return i.runOrStartContainer(true) +} + +func (i *DockerInstaller) CheckNeedsUpdate() (bool, error) { + if i.HasCustomUpdateCheck() { + return i.RunCustomUpdateCheck() + } + + image := *i.Info.Name + + localDigest, err := i.getRepoDigestFromBeforePull(image) + if err != nil { + // If the image isn't present locally, we assume an update is needed + logger.Debug("No local image found, assuming update needed") + return true, nil + } + + remoteDigest, err := i.getRemoteRepoDigest(image) + if err != nil { + return false, fmt.Errorf("failed to get remote image digest: %w", err) + } + + logger.Debug("Local digest: %s", localDigest) + logger.Debug("Remote digest: %s", remoteDigest) + + return localDigest != remoteDigest, nil +} + +func (i *DockerInstaller) CheckIsInstalled() (bool, error) { + if i.HasCustomInstallCheck() { + return i.RunCustomInstallCheck() + } + + containerName := i.GetContainerName() + cmd := exec.Command("docker", "inspect", containerName) + err := cmd.Run() + return err == nil, nil +} + +func (i *DockerInstaller) GetData() *appconfig.InstallerData { + return i.Info +} + +func (i *DockerInstaller) GetOpts() *DockerOpts { + opts := &DockerOpts{} + if i.Info.Opts != nil { + if flags, ok := (*i.Info.Opts)["flags"].(string); ok { + opts.Flags = &flags + } + if platformMap, ok := (*i.Info.Opts)["platform"].(map[string]*string); ok { + opts.Platform = &platform.PlatformMap[string]{ + MacOS: platformMap["macos"], + Linux: platformMap["linux"], + Windows: platformMap["windows"], + } + } + } + return opts +} + +func (i *DockerInstaller) GetContainerName() string { + if i.Info.BinName != nil && len(*i.Info.BinName) > 0 { + return *i.Info.BinName + } + return *i.Info.Name +} + +// Helpers + +func (i *DockerInstaller) runOrStartContainer(forceRun bool) error { + containerName := i.GetContainerName() + image := *i.Info.Name + opts := i.GetOpts() + + flags := "-d --restart always" + if opts.Flags != nil { + flat := strings.Join(strings.Fields(*opts.Flags), " ") + flags += " " + flat + } + + if !forceRun { + exists := exec.Command("docker", "inspect", containerName).Run() == nil + if exists { + return i.RunCmdAsFile(fmt.Sprintf(`docker start "%s"`, containerName)) + } + } + + return i.RunCmdAsFile(fmt.Sprintf(`docker run %s --name "%s" "%s"`, flags, containerName, image)) +} + +type DockerManifestList struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Manifests []struct { + Digest string `json:"digest"` + Platform struct { + Architecture string `json:"architecture"` + OS string `json:"os"` + } `json:"platform"` + } `json:"manifests"` +} + +func extractDigestFromManifest(jsonData []byte, osTarget, archTarget string) (string, error) { + var manifest DockerManifestList + logger.Debug("Parsing manifest JSON data for OS: %s, Arch: %s", osTarget, archTarget) + if err := json.Unmarshal(jsonData, &manifest); err != nil { + logger.Debug("Failed to parse manifest JSON: %v", err) + return "", fmt.Errorf("failed to parse manifest JSON: %w", err) + } + + for _, m := range manifest.Manifests { + if m.Platform.OS == osTarget && m.Platform.Architecture == archTarget { + return strings.TrimPrefix(m.Digest, "sha256:"), nil + } + } + logger.Debug("No matching digest found for OS: %s, Arch: %s", osTarget, archTarget) + logger.Debug("Available manifests: %v", manifest.Manifests) + return "", fmt.Errorf("no digest found for %s/%s", osTarget, archTarget) +} + +func (i *DockerInstaller) getRemoteRepoDigest(image string) (string, error) { + logger.Debug("Fetching remote image digest for: %s", image) + cmd := exec.Command("docker", "manifest", "inspect", image) + output, err := cmd.Output() + if err != nil { + logger.Debug("Docker manifest inspect failed: %v", err) + return "", fmt.Errorf("docker manifest inspect failed: %w", err) + } + + var osTarget, archTarget *string + + opts := i.GetOpts() + if opts.Platform != nil { + if resolved := opts.Platform.Resolve(); resolved != nil { + parts := strings.SplitN(*resolved, "/", 2) + if len(parts) == 2 { + osTarget, archTarget = &parts[0], &parts[1] + } + } + } + + if osTarget == nil { + osTarget = platform.DockerOSMap.Resolve() + } + if archTarget == nil { + archTarget = platform.DockerArchMap.Resolve() + } + if osTarget == nil || archTarget == nil { + logger.Debug("Could not resolve platform for manifest digest: OS=%v, Arch=%v", osTarget, archTarget) + return "", fmt.Errorf("could not resolve platform for manifest digest") + } + logger.Debug("Resolved OS: %s, Architecture: %s", *osTarget, *archTarget) + + digest, err := extractDigestFromManifest(output, *osTarget, *archTarget) + if err == nil { + return digest, nil + } + + // fallback: architecture-only + logger.Debug("Attempting fallback: match architecture only") + digest, fallbackErr := extractDigestFromManifestAnyOS(output, *archTarget) + if fallbackErr == nil { + logger.Debug("Using fallback digest with architecture-only match: sha256:%s", digest) + return digest, nil + } + + return "", fmt.Errorf("no digest found for %s/%s or fallback: %w", *osTarget, *archTarget, err) +} + +func extractDigestFromManifestAnyOS(jsonData []byte, archTarget string) (string, error) { + var manifest DockerManifestList + logger.Debug("Parsing manifest JSON data for architecture: %s", archTarget) + if err := json.Unmarshal(jsonData, &manifest); err != nil { + logger.Debug("Failed to parse manifest JSON: %v", err) + return "", fmt.Errorf("failed to parse manifest JSON: %w", err) + } + for _, m := range manifest.Manifests { + if m.Platform.Architecture == archTarget { + logger.Debug("Found fallback digest for architecture: %s, Digest: %s", archTarget, m.Digest) + return strings.TrimPrefix(m.Digest, "sha256:"), nil + } + } + logger.Debug("No fallback digest found for architecture: %s", archTarget) + return "", fmt.Errorf("no fallback digest found for arch: %s", archTarget) +} + +func GetPlatformArchWithFallback(preferred string, fallbacks ...string) string { + image := "ghcr.io/open-webui/open-webui:main" + cmd := exec.Command("docker", "manifest", "inspect", image) + out, err := cmd.Output() + if err != nil { + return preferred + } + for _, arch := range append([]string{preferred}, fallbacks...) { + if strings.Contains(string(out), fmt.Sprintf(`"architecture": "%s"`, arch)) { + return arch + } + } + return preferred +} + +func (i *DockerInstaller) getRepoDigestFromBeforePull(image string) (string, error) { + logger.Debug("Checking local image digest before pull: %s", image) + out, err := exec.Command("docker", "image", "inspect", "--format", "{{index .RepoDigests 0}}", image).Output() + if err != nil { + logger.Debug("Failed to get local image digest: %v", err) + return "", err + } + parts := strings.Split(strings.TrimSpace(string(out)), "@") + logger.Debug("Local image digest output: %s", out) + if len(parts) != 2 { + logger.Debug("Unexpected digest format before pull: %s", out) + return "", fmt.Errorf("unexpected digest format before pull: %s", out) + } + + digest := parts[1] + digest, _ = strings.CutPrefix(digest, "sha256:") + logger.Debug("Extracted local digest: %s", digest) + return digest, nil +} diff --git a/installer/installer.go b/installer/installer.go index 805e35d..7df9560 100644 --- a/installer/installer.go +++ b/installer/installer.go @@ -31,6 +31,8 @@ func GetInstaller(config *appconfig.AppConfig, data *appconfig.InstallerData) (I return NewBrewInstaller(config, data), nil case appconfig.InstallerTypeShell: return NewShellInstaller(config, data), nil + case appconfig.InstallerTypeDocker: + return NewDockerInstaller(config, data), nil case appconfig.InstallerTypeRsync: return NewRsyncInstaller(config, data), nil case appconfig.InstallerTypeNpm, appconfig.InstallerTypePnpm, appconfig.InstallerTypeYarn: diff --git a/installer/installer_test.go b/installer/installer_test.go index c740b73..f063c4b 100644 --- a/installer/installer_test.go +++ b/installer/installer_test.go @@ -10,6 +10,7 @@ import ( "github.com/chenasraf/sofmani/logger" "github.com/chenasraf/sofmani/platform" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type MockInstaller struct { @@ -649,6 +650,80 @@ func TestShellValidation(t *testing.T) { assert.Equal(t, "command", errors[0].FieldName) } +func newTestDockerInstaller(data *appconfig.InstallerData) *DockerInstaller { + return &DockerInstaller{ + InstallerBase: InstallerBase{ + Data: data, + }, + Config: nil, + Info: data, + } +} + +func TestDockerValidation(t *testing.T) { + logger.InitLogger(false) + + // 🟢 Valid: just name and type + validData := &appconfig.InstallerData{ + Name: strPtr("ghcr.io/open-webui/open-webui:main"), + Type: appconfig.InstallerTypeDocker, + BinName: strPtr("open-webui"), + } + assert.Empty(t, newTestDockerInstaller(validData).Validate()) + + // 🟢 Valid: with flags + withFlags := &appconfig.InstallerData{ + Name: strPtr("ghcr.io/open-webui/open-webui:main"), + Type: appconfig.InstallerTypeDocker, + BinName: strPtr("open-webui"), + Opts: &map[string]any{ + "flags": "-p 3300:8080 -v open-webui:/data", + }, + } + assert.Empty(t, newTestDockerInstaller(withFlags).Validate()) + + // 🔴 Invalid: missing name (should be caught by BaseValidate) + invalid := &appconfig.InstallerData{ + Type: appconfig.InstallerTypeDocker, + } + errors := newTestDockerInstaller(invalid).Validate() + assert.Len(t, errors, 1) + assert.Equal(t, "name", errors[0].FieldName) +} + +func TestExtractDigestFromManifest(t *testing.T) { + data := []byte(`{ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:abc", + "platform": { + "architecture": "arm64", + "os": "darwin" + } + }, + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:def", + "platform": { + "architecture": "amd64", + "os": "linux" + } + } + ] + }`) + + digest, err := extractDigestFromManifest(data, "darwin", "arm64") + require.NoError(t, err) + require.Equal(t, "abc", digest) + + digest, err = extractDigestFromManifest(data, "linux", "amd64") + require.NoError(t, err) + require.Equal(t, "def", digest) +} + func strPtr(s string) *string { return &s } diff --git a/platform/platform.go b/platform/platform.go index 7c7fed3..af7fd03 100644 --- a/platform/platform.go +++ b/platform/platform.go @@ -100,6 +100,22 @@ func (p *Platforms) GetShouldRunOnOS(curOS Platform) bool { return true } +func strPtr(s string) *string { + return &s +} + +var DockerArchMap = PlatformMap[string]{ + MacOS: strPtr("amd64"), + Linux: strPtr("amd64"), + Windows: strPtr("amd64"), +} + +var DockerOSMap = PlatformMap[string]{ + MacOS: strPtr("darwin"), + Linux: strPtr("linux"), + Windows: strPtr("windows"), +} + func NewPlatformMap[T any](values map[string]T) *PlatformMap[T] { p := &PlatformMap[T]{} for k, v := range values { diff --git a/utils/command.go b/utils/command.go index a069826..ce37174 100644 --- a/utils/command.go +++ b/utils/command.go @@ -116,6 +116,7 @@ func RunCmdAsFile(env []string, contents string, envShell *platform.PlatformMap[ shell := GetOSShell(envShell) args := GetOSShellArgs(tmpfile) + logger.Debug("Running command as file: %s", contents) return RunCmdPassThrough(env, shell, args...) }