feat(dev): add Docker development CLI (#6495)
This commit is contained in:
1 parent
d58a47b776
commit
018f84f80d
27 files changed
+4048
-7
No files matched your search
@@ -0,0 +1,427 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const developmentEncryptionKeyEnv = "HOMARR_DEV_SECRET_ENCRYPTION_KEY"
|
||||
|
||||
type Container struct {
|
||||
ID string
|
||||
Name string
|
||||
Image string
|
||||
Status string
|
||||
Ports string
|
||||
State string
|
||||
}
|
||||
|
||||
func (c Container) HostPort() string {
|
||||
for _, mapping := range strings.Split(c.Ports, ", ") {
|
||||
if strings.HasSuffix(mapping, "->7575/tcp") {
|
||||
parts := strings.Split(mapping, "->")
|
||||
left := parts[0]
|
||||
if i := strings.LastIndex(left, ":"); i >= 0 {
|
||||
return left[i+1:]
|
||||
}
|
||||
return left
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c Container) Running() bool {
|
||||
return c.State == "running"
|
||||
}
|
||||
|
||||
func List() ([]Container, error) {
|
||||
out, err := exec.Command("docker", "ps", "-a",
|
||||
"--filter", "name=homarr",
|
||||
"--format", "{{json .}}").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var containers []Container
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var raw struct {
|
||||
ID string
|
||||
Names string
|
||||
Image string
|
||||
Status string
|
||||
Ports string
|
||||
State string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
containers = append(containers, Container{
|
||||
ID: raw.ID,
|
||||
Name: raw.Names,
|
||||
Image: raw.Image,
|
||||
Status: raw.Status,
|
||||
Ports: raw.Ports,
|
||||
State: raw.State,
|
||||
})
|
||||
}
|
||||
filtered := containers[:0]
|
||||
for _, c := range containers {
|
||||
if c.Name == "homarr" || strings.HasPrefix(c.Name, "homarr_") {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func FindFreePort(start int) int {
|
||||
if start > 65535 {
|
||||
return 0
|
||||
}
|
||||
used := usedDockerPorts()
|
||||
start = max(start, 1)
|
||||
for port := start; port <= 65535; port++ {
|
||||
if used[port] {
|
||||
continue
|
||||
}
|
||||
listener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", port))
|
||||
if err == nil {
|
||||
_ = listener.Close()
|
||||
return port
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func usedDockerPorts() map[int]bool {
|
||||
out, err := exec.Command("docker", "ps", "--format", "{{.Ports}}").Output()
|
||||
if err != nil {
|
||||
return make(map[int]bool)
|
||||
}
|
||||
return parseUsedDockerPorts(out)
|
||||
}
|
||||
|
||||
func parseUsedDockerPorts(out []byte) map[int]bool {
|
||||
used := make(map[int]bool)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
for _, mapping := range strings.Split(line, ",") {
|
||||
left, _, found := strings.Cut(mapping, "->")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
colon := strings.LastIndex(left, ":")
|
||||
if colon < 0 {
|
||||
continue
|
||||
}
|
||||
ports := strings.SplitN(strings.TrimSpace(left[colon+1:]), "-", 2)
|
||||
first, err := strconv.Atoi(ports[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
last := first
|
||||
if len(ports) == 2 {
|
||||
if parsed, err := strconv.Atoi(ports[1]); err == nil {
|
||||
last = parsed
|
||||
}
|
||||
}
|
||||
for port := first; port <= last; port++ {
|
||||
used[port] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func IsRunning(name string) bool {
|
||||
containers, _ := List()
|
||||
for _, c := range containers {
|
||||
if c.Name == name && c.Running() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type StartOptions struct {
|
||||
Name string
|
||||
Image string
|
||||
Volume string
|
||||
HostPort int
|
||||
Env []string
|
||||
Platform string
|
||||
PullAlways bool
|
||||
Daemon bool
|
||||
}
|
||||
|
||||
func Start(opts StartOptions) error {
|
||||
encryptionKey, err := developmentEncryptionKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.PullAlways {
|
||||
cmd := PullCommand(opts.Image, opts.Platform)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("pull %s: %w", opts.Image, err)
|
||||
}
|
||||
}
|
||||
|
||||
_ = exec.Command("docker", "rm", "-f", opts.Name).Run()
|
||||
|
||||
args := []string{"run", "--rm", "--name", opts.Name,
|
||||
"-e", "SECRET_ENCRYPTION_KEY=" + encryptionKey,
|
||||
}
|
||||
if opts.Platform != "" {
|
||||
args = append(args, "--platform", opts.Platform)
|
||||
}
|
||||
if opts.Daemon {
|
||||
args = append(args, "-d")
|
||||
} else {
|
||||
args = append(args, "-it")
|
||||
}
|
||||
for _, env := range opts.Env {
|
||||
args = append(args, "-e", env)
|
||||
}
|
||||
args = append(args,
|
||||
"-v", opts.Volume+":/appdata",
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock:ro",
|
||||
"-p", fmt.Sprintf("%d:7575", opts.HostPort),
|
||||
opts.Image,
|
||||
)
|
||||
|
||||
cmd := exec.Command("docker", args...)
|
||||
if opts.Daemon {
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, cleanOutput(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
stderr := &tailBuffer{}
|
||||
cmd.Stderr = io.MultiWriter(os.Stderr, stderr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, cleanOutput(stderr.data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func developmentEncryptionKey() (string, error) {
|
||||
if key := os.Getenv(developmentEncryptionKeyEnv); key != "" {
|
||||
return validateDevelopmentEncryptionKey(key)
|
||||
}
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate user config directory: %w", err)
|
||||
}
|
||||
return persistedDevelopmentEncryptionKey(configDir)
|
||||
}
|
||||
|
||||
func persistedDevelopmentEncryptionKey(configDir string) (string, error) {
|
||||
dir := filepath.Join(configDir, "homarr-dev")
|
||||
path := filepath.Join(dir, "secret-encryption-key")
|
||||
if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil {
|
||||
return key, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create development config directory: %w", err)
|
||||
}
|
||||
lockPath := path + ".lock"
|
||||
for attempts := 0; ; attempts++ {
|
||||
if err := os.Mkdir(lockPath, 0o700); err == nil {
|
||||
break
|
||||
} else if !os.IsExist(err) {
|
||||
return "", fmt.Errorf("lock development encryption key: %w", err)
|
||||
}
|
||||
if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil {
|
||||
return key, err
|
||||
}
|
||||
if attempts == 99 {
|
||||
return "", fmt.Errorf("timed out waiting for development encryption key lock %q", lockPath)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
defer func() { _ = os.Remove(lockPath) }()
|
||||
|
||||
if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil {
|
||||
return key, err
|
||||
}
|
||||
keyBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return "", fmt.Errorf("generate development encryption key: %w", err)
|
||||
}
|
||||
key := hex.EncodeToString(keyBytes)
|
||||
file, err := os.CreateTemp(dir, "secret-encryption-key-*.tmp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create development encryption key: %w", err)
|
||||
}
|
||||
tempPath := file.Name()
|
||||
defer func() { _ = os.Remove(tempPath) }()
|
||||
if _, err := file.WriteString(key + "\n"); err != nil {
|
||||
_ = file.Close()
|
||||
return "", fmt.Errorf("write development encryption key: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return "", fmt.Errorf("close development encryption key: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tempPath, 0o600); err != nil {
|
||||
return "", fmt.Errorf("secure development encryption key: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
return "", fmt.Errorf("persist development encryption key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func readDevelopmentEncryptionKey(path string) (string, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("read development encryption key: %w", err)
|
||||
}
|
||||
key, err := validateDevelopmentEncryptionKey(strings.TrimSpace(string(data)))
|
||||
return key, true, err
|
||||
}
|
||||
|
||||
func validateDevelopmentEncryptionKey(key string) (string, error) {
|
||||
decoded, err := hex.DecodeString(key)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
return "", fmt.Errorf("development encryption key must contain exactly 64 hexadecimal characters")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
type tailBuffer struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b *tailBuffer) Write(p []byte) (int, error) {
|
||||
const limit = 32 * 1024
|
||||
b.data = append(b.data, p...)
|
||||
if len(b.data) > limit {
|
||||
b.data = b.data[len(b.data)-limit:]
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func cleanOutput(out []byte) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
cleaned := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "WARNING: The requested image's platform") || isContainerID(line) {
|
||||
continue
|
||||
}
|
||||
if line != "" {
|
||||
cleaned = append(cleaned, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(cleaned, "\n")
|
||||
}
|
||||
|
||||
func isContainerID(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func PullCommand(image, platform string) *exec.Cmd {
|
||||
return PullCommandContext(context.Background(), image, platform)
|
||||
}
|
||||
|
||||
func PullCommandContext(ctx context.Context, image, platform string) *exec.Cmd {
|
||||
args := []string{"pull"}
|
||||
if platform != "" {
|
||||
args = append(args, "--platform", platform)
|
||||
}
|
||||
args = append(args, image)
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
if platform != "" {
|
||||
cmd.Env = append(os.Environ(), "DOCKER_DEFAULT_PLATFORM="+platform)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func IsPortConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "port is already allocated") ||
|
||||
strings.Contains(message, "address already in use") ||
|
||||
strings.Contains(message, "failed programming external connectivity")
|
||||
}
|
||||
|
||||
func Stop(name string) error {
|
||||
cmd := exec.Command("docker", "stop", name)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Restart(name string) error {
|
||||
cmd := exec.Command("docker", "restart", name)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Remove(name string) error {
|
||||
cmd := exec.Command("docker", "rm", "-f", name)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CheckImage(image string) (bool, error) {
|
||||
cmd := exec.Command("docker", "manifest", "inspect", image)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
message := strings.ToLower(string(out))
|
||||
if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("check image %s: %w: %s", image, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
func FollowLogs(name string) error {
|
||||
cmd := exec.Command("docker", "logs", "-f", "--tail", "100", name)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContainerHostPort(t *testing.T) {
|
||||
c := Container{Ports: "0.0.0.0:7576->7575/tcp, [::]:7576->7575/tcp"}
|
||||
if got := c.HostPort(); got != "7576" {
|
||||
t.Fatalf("HostPort() = %q, want 7576", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPortConflict(t *testing.T) {
|
||||
for _, message := range []string{
|
||||
"Bind for 0.0.0.0:7575 failed: port is already allocated",
|
||||
"driver failed programming external connectivity on endpoint",
|
||||
} {
|
||||
if !IsPortConflict(errors.New(message)) {
|
||||
t.Fatalf("expected port conflict for %q", message)
|
||||
}
|
||||
}
|
||||
if IsPortConflict(errors.New("manifest unknown")) {
|
||||
t.Fatal("manifest error classified as port conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsedDockerPortsPreservesLineBoundaries(t *testing.T) {
|
||||
ports := parseUsedDockerPorts([]byte("6379/tcp\n0.0.0.0:7575->7575/tcp, [::]:7575->7575/tcp\n0.0.0.0:8000-8002->8000-8002/tcp\n"))
|
||||
for _, port := range []int{7575, 8000, 8001, 8002} {
|
||||
if !ports[port] {
|
||||
t.Fatalf("port %d was not detected", port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFreePortRejectsOutOfRangeStart(t *testing.T) {
|
||||
if port := FindFreePort(65536); port != 0 {
|
||||
t.Fatalf("port = %d, want 0", port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentEncryptionKeyUsesEnvironmentOverride(t *testing.T) {
|
||||
want := strings.Repeat("a", 64)
|
||||
t.Setenv(developmentEncryptionKeyEnv, want)
|
||||
got, err := developmentEncryptionKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("key = %q, want environment value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedDevelopmentEncryptionKeyIsReused(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
first, err := persistedDevelopmentEncryptionKey(configDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := persistedDevelopmentEncryptionKey(configDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("persisted key changed between reads")
|
||||
}
|
||||
decoded, err := hex.DecodeString(first)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
t.Fatalf("generated key is not 32 bytes: %q", first)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(configDir, "homarr-dev", "secret-encryption-key"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("key permissions = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedDevelopmentEncryptionKeyRejectsInvalidValue(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
dir := filepath.Join(configDir, "homarr-dev")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "secret-encryption-key"), []byte("invalid\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := persistedDevelopmentEncryptionKey(configDir); err == nil {
|
||||
t.Fatal("expected invalid persisted key to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedDevelopmentEncryptionKeyConcurrentCreation(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
type result struct {
|
||||
key string
|
||||
err error
|
||||
}
|
||||
results := make(chan result, 20)
|
||||
for range 20 {
|
||||
go func() {
|
||||
key, err := persistedDevelopmentEncryptionKey(configDir)
|
||||
results <- result{key: key, err: err}
|
||||
}()
|
||||
}
|
||||
var first string
|
||||
for range 20 {
|
||||
result := <-results
|
||||
if result.err != nil {
|
||||
t.Fatal(result.err)
|
||||
}
|
||||
if first == "" {
|
||||
first = result.key
|
||||
} else if result.key != first {
|
||||
t.Fatal("concurrent callers received different keys")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceLabel = "org.homarr.dev.source"
|
||||
RevisionLabel = "org.homarr.dev.revision"
|
||||
PRLabel = "org.homarr.dev.pr"
|
||||
)
|
||||
|
||||
var tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
|
||||
|
||||
type Image struct {
|
||||
ID string
|
||||
Tag string
|
||||
Source string
|
||||
Revision string
|
||||
PRNumber int
|
||||
}
|
||||
|
||||
func (image Image) Reference() string {
|
||||
return "homarr:" + image.Tag
|
||||
}
|
||||
|
||||
type imageListRow struct {
|
||||
ID string
|
||||
Repository string
|
||||
Tag string
|
||||
}
|
||||
|
||||
func parseImageList(output []byte) ([]Image, error) {
|
||||
images := make([]Image, 0)
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var row imageListRow
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
return nil, fmt.Errorf("parse local Docker image: %w", err)
|
||||
}
|
||||
if row.Repository == "homarr" && row.Tag != "<none>" {
|
||||
images = append(images, Image{ID: row.ID, Tag: row.Tag})
|
||||
}
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func ListLocalImages(ctx context.Context) ([]Image, error) {
|
||||
out, err := exec.CommandContext(ctx, "docker", "image", "ls", "--filter", "reference=homarr:*", "--format", "{{json .}}").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list local Homarr images: %w", err)
|
||||
}
|
||||
images, err := parseImageList(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labelsByImage, err := inspectImageLabels(ctx, images)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, labels := range labelsByImage {
|
||||
images[index].Source = labels[SourceLabel]
|
||||
images[index].Revision = labels[RevisionLabel]
|
||||
images[index].PRNumber, _ = strconv.Atoi(labels[PRLabel])
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func inspectImageLabels(ctx context.Context, images []Image) ([]map[string]string, error) {
|
||||
if len(images) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
args := []string{"image", "inspect", "--format", "{{json .Config.Labels}}"}
|
||||
for _, image := range images {
|
||||
args = append(args, image.Reference())
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect local Homarr images: %w", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) != len(images) {
|
||||
return nil, fmt.Errorf("inspect returned %d images, expected %d", len(lines), len(images))
|
||||
}
|
||||
labelsByImage := make([]map[string]string, len(images))
|
||||
for index, line := range lines {
|
||||
labelsByImage[index] = make(map[string]string)
|
||||
if line == "null" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
|
||||
return nil, fmt.Errorf("parse labels for %s: %w", images[index].Reference(), err)
|
||||
}
|
||||
}
|
||||
return labelsByImage, nil
|
||||
}
|
||||
|
||||
type BuildOptions struct {
|
||||
Context string
|
||||
Tag string
|
||||
Source string
|
||||
Revision string
|
||||
PRNumber int
|
||||
}
|
||||
|
||||
func BuildCommand(ctx context.Context, options BuildOptions) (*exec.Cmd, error) {
|
||||
if !tagPattern.MatchString(options.Tag) {
|
||||
return nil, fmt.Errorf("invalid image name %q", options.Tag)
|
||||
}
|
||||
contextPath, err := filepath.Abs(options.Context)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve build context: %w", err)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(contextPath, "Dockerfile")); err != nil || info.IsDir() {
|
||||
return nil, fmt.Errorf("%s does not contain a Dockerfile", contextPath)
|
||||
}
|
||||
args := []string{"build", "--label", SourceLabel + "=" + options.Source, "--label", RevisionLabel + "=" + options.Revision}
|
||||
if options.PRNumber > 0 {
|
||||
args = append(args, "--label", fmt.Sprintf("%s=%d", PRLabel, options.PRNumber))
|
||||
}
|
||||
args = append(args, "-t", "homarr:"+options.Tag, ".")
|
||||
command := exec.CommandContext(ctx, "docker", args...)
|
||||
command.Dir = contextPath
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func BuildContext(ctx context.Context, options BuildOptions, stdout, stderr io.Writer) error {
|
||||
command, err := BuildCommand(ctx, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
command.Stdout = stdout
|
||||
command.Stderr = stderr
|
||||
if err := command.Run(); err != nil {
|
||||
return fmt.Errorf("build homarr:%s: %w", options.Tag, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseImageListFiltersHomarrTags(t *testing.T) {
|
||||
output := []byte("{\"ID\":\"sha256:1\",\"Repository\":\"homarr\",\"Tag\":\"dev\"}\n{\"ID\":\"sha256:2\",\"Repository\":\"other\",\"Tag\":\"dev\"}\n")
|
||||
images, err := parseImageList(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(images) != 1 || images[0].Reference() != "homarr:dev" {
|
||||
t.Fatalf("images = %#v, want homarr:dev", images)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectImageLabelsWithNoImages(t *testing.T) {
|
||||
labels, err := inspectImageLabels(t.Context(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if labels != nil {
|
||||
t.Fatalf("labels = %#v, want nil", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandIncludesProvenance(t *testing.T) {
|
||||
command, err := BuildCommand(t.Context(), BuildOptions{
|
||||
Context: "../../../..",
|
||||
Tag: "feature",
|
||||
Source: "/tmp/homarr",
|
||||
Revision: "abc123",
|
||||
PRNumber: 42,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"docker", "build", "--label", SourceLabel + "=/tmp/homarr", "--label", RevisionLabel + "=abc123", "--label", PRLabel + "=42", "-t", "homarr:feature", "."}
|
||||
if len(command.Args) != len(want) {
|
||||
t.Fatalf("args = %v, want %v", command.Args, want)
|
||||
}
|
||||
for index := range want {
|
||||
if command.Args[index] != want[index] {
|
||||
t.Fatalf("args = %v, want %v", command.Args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLocalImagesIntegration(t *testing.T) {
|
||||
wantTag := os.Getenv("HOMARR_TEST_LOCAL_IMAGE")
|
||||
if wantTag == "" {
|
||||
t.Skip("set HOMARR_TEST_LOCAL_IMAGE to a local homarr image tag")
|
||||
}
|
||||
images, err := ListLocalImages(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, image := range images {
|
||||
if image.Tag == wantTag {
|
||||
if image.Source == "" || image.Revision == "" {
|
||||
t.Fatalf("image %#v is missing provenance", image)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("homarr:%s was not discovered", wantTag)
|
||||
}
|
||||
Reference in new issue
Block a user