Files
2026-09-10 20:19:11 +02:00

128 lines
3.6 KiB
Go

package store
import (
"encoding/json"
"fmt"
"strings"
)
// ApplyChange applies one `docker import --change` instruction — a
// Dockerfile line — to config. Supported: ENTRYPOINT, CMD (JSON array
// or shell form), ENV, WORKDIR, USER, VOLUME (JSON array or space
// separated), LABEL. Everything else is refused: EXPOSE has no network
// to speak of, the rest does not describe how to run the image.
func ApplyChange(config *ImageConfig, change string) error {
instruction, rest, _ := strings.Cut(strings.TrimSpace(change), " ")
rest = strings.TrimSpace(rest)
switch strings.ToUpper(instruction) {
case "ENTRYPOINT":
v, err := parseExecForm(rest)
if err != nil {
return fmt.Errorf("%s: %w", change, err)
}
config.Entrypoint = v
case "CMD":
v, err := parseExecForm(rest)
if err != nil {
return fmt.Errorf("%s: %w", change, err)
}
config.Cmd = v
case "ENV":
key, value, err := parseKeyValue(rest)
if err != nil {
return fmt.Errorf("%s: %w", change, err)
}
config.Env = setEnv(config.Env, key, value)
case "WORKDIR":
if rest == "" || !strings.HasPrefix(rest, "/") {
return fmt.Errorf("%s: WORKDIR needs an absolute path", change)
}
config.WorkingDir = rest
case "USER":
if rest == "" || strings.ContainsAny(rest, " \t") {
return fmt.Errorf("%s: USER needs one user[:group], by name or id", change)
}
config.User = rest
case "VOLUME":
paths, err := parseExecForm(rest)
if err != nil {
return fmt.Errorf("%s: %w", change, err)
}
if !strings.HasPrefix(rest, "[") {
paths = strings.Fields(rest)
}
for _, p := range paths {
if !strings.HasPrefix(p, "/") {
return fmt.Errorf("%s: VOLUME needs absolute paths", change)
}
if config.Volumes == nil {
config.Volumes = map[string]struct{}{}
}
config.Volumes[p] = struct{}{}
}
case "LABEL":
key, value, err := parseKeyValue(rest)
if err != nil {
return fmt.Errorf("%s: %w", change, err)
}
if config.Labels == nil {
config.Labels = map[string]string{}
}
config.Labels[key] = value
case "":
return fmt.Errorf("empty --change")
default:
return fmt.Errorf("%s: unsupported instruction %s (supported: ENTRYPOINT, CMD, ENV, WORKDIR, USER, VOLUME, LABEL)", change, strings.ToUpper(instruction))
}
return nil
}
// parseExecForm reads a JSON array (exec form); anything else is the
// shell form, which docker wraps in /bin/sh -c.
func parseExecForm(s string) ([]string, error) {
if strings.HasPrefix(s, "[") {
var v []string
if err := json.Unmarshal([]byte(s), &v); err != nil {
return nil, fmt.Errorf("exec form must be a JSON array of strings: %w", err)
}
return v, nil
}
if s == "" {
return nil, fmt.Errorf("missing value")
}
return []string{"/bin/sh", "-c", s}, nil
}
// parseKeyValue accepts KEY=VALUE (optionally double-quoted) and the
// legacy KEY VALUE form.
func parseKeyValue(s string) (string, string, error) {
key, value, found := strings.Cut(s, "=")
if !found {
key, value, found = strings.Cut(s, " ")
if !found {
return "", "", fmt.Errorf("expected KEY=VALUE")
}
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || strings.ContainsAny(key, " \t") {
return "", "", fmt.Errorf("invalid key %q", key)
}
if len(value) >= 2 && strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
value = value[1 : len(value)-1]
}
return key, value, nil
}
// setEnv replaces an existing KEY= entry or appends, keeping order.
func setEnv(env []string, key, value string) []string {
entry := key + "=" + value
for i, e := range env {
if strings.HasPrefix(e, key+"=") {
env[i] = entry
return env
}
}
return append(env, entry)
}