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) }