mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
feat: docker installer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
278
installer/docker_installer.go
Normal file
278
installer/docker_installer.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user