diff --git a/README.md b/README.md index f78c8fd..9d81ae5 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,31 @@ dfc --in-place ./Dockerfile mv ./Dockerfile.bak ./Dockerfile # revert ``` -Note: the `Dockerfile` and `Dockerfile.chainguard` in the root of this repo are not actually for building `dfc`, they -are symlinks to files in the [`testdata/`](./testdata/) folder so users can run the commands in this README. +Convert to apko configuration format: + +```sh +dfc --apko apko_overlay.yaml ./Dockerfile +``` + +This will: +1. Convert the Dockerfile to use Chainguard Images +2. Use the Chainguard-converted Dockerfile to generate an apko overlay configuration file that can be used to build the image with apko + +Convert directly from original Dockerfile to apko overlay in a single command: + +```sh +dfc --direct-apko apko_overlay.yaml ./Dockerfile +``` + +This performs both steps in one operation without displaying the Chainguard-converted Dockerfile: +1. Convert the Dockerfile to use Chainguard Images (internally) +2. Use the converted Dockerfile to generate an apko overlay configuration file + +Enable debug logging for troubleshooting (disabled by default): + +```sh +dfc --debug ./Dockerfile +``` ## Examples @@ -114,6 +137,36 @@ USER root RUN apk add --no-cache nano ``` +### Convert to apko configuration + +```sh +cat < 1 || (stageName != "stage0" && stageName != "") || baseNameNoExt != inputDockerfileName { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay%s", baseNameNoExt, stageName, fileExt)) + if fileExt == "" { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay.yaml", baseNameNoExt, stageName)) + } + } else { + finalApkoPath = *directApko + } + + // Write apko YAML to file + if err := os.WriteFile(finalApkoPath, []byte(currentApkoYAML), 0644); err != nil { + log.Fatalf("Failed to write apko YAML for stage '%s' to %s in direct-apko: %v", stageName, finalApkoPath, err) + } + log.Printf("Generated apko overlay for stage '%s' to %s", stageName, finalApkoPath) + } + + // Exit without printing the Chainguard Dockerfile + return + } + + // Handle apko overlay generation + if *apkoOutput != "" { + // Convert the Chainguard Dockerfile to apko overlay format + // This now returns a map of stage names to their ApkoConfig + stageConfigs, err := apko.ConvertDockerfileToApko(converted) + if err != nil { + log.Fatalf("Failed to convert to apko overlay: %v", err) + } + + if len(stageConfigs) == 0 { + log.Println("No stages found or an error occurred during apko conversion, no overlay files generated.") + return + } + + baseOutputDir := filepath.Dir(*apkoOutput) + baseOutputFileName := filepath.Base(*apkoOutput) + fileExt := filepath.Ext(baseOutputFileName) + baseNameNoExt := strings.TrimSuffix(baseOutputFileName, fileExt) + + // Determine the base name from the input Dockerfile path if needed + inputDockerfileName := "Dockerfile" // Default if input is from stdin or path is weird + if inputPath != "" { + inputBase := filepath.Base(inputPath) + inputExt := filepath.Ext(inputBase) + inputNameNoExt := strings.TrimSuffix(inputBase, inputExt) + if inputNameNoExt != "" { + inputDockerfileName = inputNameNoExt + } + } + + for stageName, stageConfig := range stageConfigs { + // Generate YAML for the current stage + yamlOutput, err := apko.GenerateApkoYAML(stageConfig) + if err != nil { + log.Fatalf("Failed to generate apko YAML for stage '%s': %v", stageName, err) + } + + // Construct the output filename for the stage + var finalApkoPath string + if baseNameNoExt == "" || baseNameNoExt == "_overlay" || baseNameNoExt == "apko" { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay%s", inputDockerfileName, stageName, fileExt)) + if fileExt == "" { // if original apkoOutput had no extension + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay.yaml", inputDockerfileName, stageName)) + } + } else if len(stageConfigs) > 1 || (stageName != "stage0" && stageName != "") || baseNameNoExt != inputDockerfileName { + // If multiple stages, or a named stage (not default "stage0"), or if the original output name wasn't already specific to a Dockerfile. + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay%s", baseNameNoExt, stageName, fileExt)) + if fileExt == "" { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay.yaml", baseNameNoExt, stageName)) + } + } else { // Single stage, and a specific output name was given that likely matches the dockerfile name already. + finalApkoPath = *apkoOutput + } + + // Write to output file + if err := os.WriteFile(finalApkoPath, []byte(yamlOutput), 0644); err != nil { + log.Fatalf("Failed to write apko overlay file for stage '%s' to %s: %v", stageName, finalApkoPath, err) + } + fmt.Printf("Generated Apko overlay for stage '%s' to %s\n", stageName, finalApkoPath) + } + return + } + + // Handle in-place conversion + if *inPlace { + // Create backup + backupPath := inputPath + ".bak" + if err := os.WriteFile(backupPath, input, 0644); err != nil { + log.Fatalf("Failed to create backup file: %v", err) + } + + // Write converted content + if err := os.WriteFile(inputPath, []byte(converted.String()), 0644); err != nil { + log.Fatalf("Failed to write converted file: %v", err) + } + return + } + + // Print converted content + if *jsonOutput { + json, err := json.MarshalIndent(converted, "", " ") + if err != nil { + log.Fatalf("Failed to marshal JSON: %v", err) + } + fmt.Println(string(json)) + } else { + fmt.Print(converted) } } @@ -53,6 +300,9 @@ func cli() *cobra.Command { var mappingsFile string var updateFlag bool var noBuiltInFlag bool + var apkoOutput string + var directApko string + var debug bool // Default log level is info var level = slag.Level(slog.LevelInfo) @@ -76,6 +326,9 @@ func cli() *cobra.Command { log := clog.New(slog.Default().Handler()) ctx := clog.WithLogger(cmd.Context(), log) + // Set debug mode in apko package + apko.Debug = debug + // If update flag is set but no args, just update and exit if updateFlag && len(args) == 0 { // Set up update options @@ -200,20 +453,167 @@ func cli() *cobra.Command { return nil } - // Print to stdout + // Print the converted Dockerfile fmt.Print(result) + // If apko output is requested, convert to apko format + if apkoOutput != "" { + // Convert to apko format - this now returns a map of stage names to their ApkoConfig + stageConfigs, err := apko.ConvertDockerfileToApko(convertedDockerfile) + if err != nil { + return fmt.Errorf("converting to apko format: %w", err) + } + + if len(stageConfigs) == 0 { + log.Info("No stages found or an error occurred during apko conversion, no overlay files generated.") + return nil + } + + baseOutputDir := filepath.Dir(apkoOutput) + baseOutputFileName := filepath.Base(apkoOutput) + fileExt := filepath.Ext(baseOutputFileName) + baseNameNoExt := strings.TrimSuffix(baseOutputFileName, fileExt) + + // Determine the base name from the input Dockerfile path if needed + inputDockerfileName := "Dockerfile" // Default if input is from stdin or path is weird + if isFile && path != "" { + inputBase := filepath.Base(path) + inputExt := filepath.Ext(inputBase) + inputNameNoExt := strings.TrimSuffix(inputBase, inputExt) + if inputNameNoExt != "" { + inputDockerfileName = inputNameNoExt + } + } + + for stageName, stageConfig := range stageConfigs { + // Generate apko YAML for the current stage + currentApkoYAML, err := apko.GenerateApkoYAML(stageConfig) + if err != nil { + return fmt.Errorf("generating apko YAML for stage '%s': %w", stageName, err) + } + + var finalApkoPath string + // If the original apkoOutput was generic (like "apko.yaml" or "_overlay.yaml") or just a directory, + // base the new filename on the input Dockerfile's name. + if baseNameNoExt == "" || baseNameNoExt == "_overlay" || baseNameNoExt == "apko" { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay%s", inputDockerfileName, stageName, fileExt)) + if fileExt == "" { // if original apkoOutput had no extension + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay.yaml", inputDockerfileName, stageName)) + } + } else if len(stageConfigs) > 1 || (stageName != "stage0" && stageName != "") || baseNameNoExt != inputDockerfileName { + // If multiple stages, or a named stage (not default "stage0"), or if the original output name wasn't already specific to a Dockerfile. + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay%s", baseNameNoExt, stageName, fileExt)) + if fileExt == "" { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay.yaml", baseNameNoExt, stageName)) + } + } else { // Single stage, and a specific output name was given that likely matches the dockerfile name already. + finalApkoPath = apkoOutput + } + + // Write apko YAML to file + if err := os.WriteFile(finalApkoPath, []byte(currentApkoYAML), 0644); err != nil { + return fmt.Errorf("writing apko YAML for stage '%s' to %s: %w", stageName, finalApkoPath, err) + } + log.Info("Generated Apko overlay", "stage", stageName, "path", finalApkoPath) + } + } + + // If direct-apko is requested, convert the ORIGINAL Dockerfile to Chainguard format + // and then to apko in one step (without displaying the converted Dockerfile) + if directApko != "" { + // IMPORTANT: We need to suppress the normal output when using direct-apko + // We want to convert directly from raw -> chainguard -> apko without printing the Chainguard result + + // Parse the original Dockerfile + originalDockerfile, err := dfc.ParseDockerfile(ctx, raw) + if err != nil { + return fmt.Errorf("unable to parse dockerfile for direct-apko: %w", err) + } + + // Convert to Chainguard format + convertedDockerfileForApko, err := originalDockerfile.Convert(ctx, opts) + if err != nil { + return fmt.Errorf("converting dockerfile for direct-apko: %w", err) + } + + // No need to print the converted Dockerfile when using direct-apko + log.Info("Converting directly to apko overlay") + + // Convert the Chainguard Dockerfile to apko overlay + stageConfigs, err := apko.ConvertDockerfileToApko(convertedDockerfileForApko) + if err != nil { + return fmt.Errorf("converting to apko format for direct-apko: %w", err) + } + + if len(stageConfigs) == 0 { + log.Info("No stages found or an error occurred during direct-apko conversion, no overlay files generated.") + return nil + } + + baseOutputDir := filepath.Dir(directApko) + baseOutputFileName := filepath.Base(directApko) + fileExt := filepath.Ext(baseOutputFileName) + baseNameNoExt := strings.TrimSuffix(baseOutputFileName, fileExt) + + // Determine the base name from the input Dockerfile path if needed + inputDockerfileName := "Dockerfile" // Default if input is from stdin or path is weird + if isFile && path != "" { + inputBase := filepath.Base(path) + inputExt := filepath.Ext(inputBase) + inputNameNoExt := strings.TrimSuffix(inputBase, inputExt) + if inputNameNoExt != "" { + inputDockerfileName = inputNameNoExt + } + } + + for stageName, stageConfig := range stageConfigs { + // Generate apko YAML for the current stage + currentApkoYAML, err := apko.GenerateApkoYAML(stageConfig) + if err != nil { + return fmt.Errorf("generating apko YAML for stage '%s' in direct-apko: %w", stageName, err) + } + + var finalApkoPath string + // Handle path construction for direct-apko output + if baseNameNoExt == "" || baseNameNoExt == "_overlay" || baseNameNoExt == "apko" { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay%s", inputDockerfileName, stageName, fileExt)) + if fileExt == "" { // if original apkoOutput had no extension + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay.yaml", inputDockerfileName, stageName)) + } + } else if len(stageConfigs) > 1 || (stageName != "stage0" && stageName != "") || baseNameNoExt != inputDockerfileName { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay%s", baseNameNoExt, stageName, fileExt)) + if fileExt == "" { + finalApkoPath = filepath.Join(baseOutputDir, fmt.Sprintf("%s_%s_overlay.yaml", baseNameNoExt, stageName)) + } + } else { + finalApkoPath = directApko + } + + // Write apko YAML to file + if err := os.WriteFile(finalApkoPath, []byte(currentApkoYAML), 0644); err != nil { + return fmt.Errorf("writing apko YAML for stage '%s' to %s in direct-apko: %w", stageName, finalApkoPath, err) + } + log.Info("Generated Apko overlay directly from original Dockerfile", "stage", stageName, "path", finalApkoPath) + } + + // Return here to prevent printing the Chainguard Dockerfile + return nil + } + return nil }, } - cmd.Flags().StringVar(&org, "org", dfc.DefaultOrg, "the organization for cgr.dev// (defaults to ORG)") - cmd.Flags().StringVar(®istry, "registry", "", "an alternate registry and root namepace (e.g. r.example.com/cg-mirror)") - cmd.Flags().BoolVarP(&inPlace, "in-place", "i", false, "modified the Dockerfile in place (vs. stdout), saving original in a .bak file") - cmd.Flags().BoolVarP(&j, "json", "j", false, "print dockerfile as json (before conversion)") - cmd.Flags().StringVarP(&mappingsFile, "mappings", "m", "", "path to a custom package mappings YAML file (instead of the default)") - cmd.Flags().BoolVar(&updateFlag, "update", false, "check for and apply available updates") - cmd.Flags().BoolVar(&noBuiltInFlag, "no-builtin", false, "skip built-in package/image mappings, still apply default conversion logic") + cmd.Flags().BoolVarP(&j, "json", "j", false, "output as json") + cmd.Flags().BoolVarP(&inPlace, "in-place", "i", false, "modify file in place") + cmd.Flags().StringVar(&org, "org", dfc.DefaultOrg, "organization name for cgr.dev namespace") + cmd.Flags().StringVar(®istry, "registry", dfc.DefaultRegistryDomain, "registry domain and root namespace") + cmd.Flags().StringVar(&mappingsFile, "mappings", "", "path to custom mappings file") + cmd.Flags().BoolVar(&updateFlag, "update", false, "update cached mappings") + cmd.Flags().BoolVar(&noBuiltInFlag, "no-builtin", false, "don't use built-in mappings") + cmd.Flags().StringVar(&apkoOutput, "apko", "", "output path for apko configuration file") + cmd.Flags().StringVar(&directApko, "direct-apko", "", "convert Dockerfile directly to apko overlay and save to the specified path") + cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging") cmd.Flags().Var(&level, "log-level", "log level (e.g. debug, info, warn, error)") return cmd diff --git a/pkg/apko/apko.go b/pkg/apko/apko.go new file mode 100644 index 0000000..fb164b9 --- /dev/null +++ b/pkg/apko/apko.go @@ -0,0 +1,838 @@ +/* +Copyright 2025 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package apko + +import ( + "fmt" + "log" + "strings" + + "github.com/chainguard-dev/dfc/pkg/dfc" +) + +// Debug flag controls whether debug logging is enabled +var Debug bool = false + +// ApkoConfig represents the apko YAML configuration +type ApkoConfig struct { + Contents struct { + Repositories []string `yaml:"repositories"` + Packages []string `yaml:"packages"` + } `yaml:"contents"` + Entrypoint struct { + Command string `yaml:"command,omitempty"` + Type string `yaml:"type,omitempty"` + } `yaml:"entrypoint,omitempty"` + WorkDir string `yaml:"work-dir,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` + Accounts struct { + Users []User `yaml:"users,omitempty"` + Groups []Group `yaml:"groups,omitempty"` + RunAs string `yaml:"run-as,omitempty"` + } `yaml:"accounts,omitempty"` + Paths []Path `yaml:"paths,omitempty"` +} + +type User struct { + Username string `yaml:"username"` + UID int `yaml:"uid"` + Shell string `yaml:"shell,omitempty"` +} + +type Group struct { + Groupname string `yaml:"groupname"` + GID int `yaml:"gid"` +} + +type Path struct { + Path string `yaml:"path"` + Type string `yaml:"type"` + UID int `yaml:"uid,omitempty"` + GID int `yaml:"gid,omitempty"` + Permissions string `yaml:"permissions,omitempty"` + Source string `yaml:"source,omitempty"` +} + +type BuildStage struct { + Name string + BaseImage string + Packages []string + EnvVars map[string]string + WorkDir string + Users []User + Groups []Group + Services []string + Paths []Path + FromStage string +} + +func parseEntrypointOrCmd(raw string) string { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + // JSON array form: ENTRYPOINT ["/bin/sh", "-c", "echo hello"] + raw = strings.Trim(raw, "[]") + parts := strings.Split(raw, ",") + for i := range parts { + parts[i] = strings.Trim(parts[i], " \"") + } + return strings.Join(parts, " ") + } + return raw +} + +func parseEnvLine(parts []string) map[string]string { + envs := make(map[string]string) + for _, kv := range parts[1:] { + if strings.Contains(kv, "=") { + pair := strings.SplitN(kv, "=", 2) + envs[pair[0]] = pair[1] + } + } + return envs +} + +func parseCopyChown(parts []string) (src, dest, chown, chmod string) { + src, dest, chown, chmod = "", "", "", "" + for i := 1; i < len(parts); i++ { + if parts[i] == "--chown" && i+1 < len(parts) { + chown = parts[i+1] + i++ + } else if parts[i] == "--chmod" && i+1 < len(parts) { + chmod = parts[i+1] + i++ + } else if src == "" { + src = parts[i] + } else if dest == "" { + dest = parts[i] + } + } + return +} + +func parsePackageInstall(fullCommand string) []string { + if Debug { + log.Printf("DEBUG: parsePackageInstall received full command: %s", fullCommand) + } + allPackages := []string{} + + // Split the full command into sub-commands based on shell operators + // We need to be careful with escaped operators, but for now, a simple split. + // Replace || and ; with && to simplify splitting, then split by &&. + // This is a simplification; a proper shell parser would be more robust. + processedCommand := strings.ReplaceAll(fullCommand, "||", "&&") + processedCommand = strings.ReplaceAll(processedCommand, ";", "&&") + subCommands := strings.Split(processedCommand, "&&") + + for _, cmd := range subCommands { + trimmedCmd := strings.TrimSpace(cmd) + if trimmedCmd == "" { + continue + } + if Debug { + log.Printf("DEBUG: Processing sub-command: [%s]", trimmedCmd) + } + + parts := strings.Fields(trimmedCmd) + if len(parts) < 2 { // Must have at least + if Debug { + log.Printf("DEBUG: Sub-command too short or not a recognized format: [%s]", trimmedCmd) + } + continue + } + + cmdPrefix := "" + packageManager := parts[0] + action := "" + packageStartIndex := -1 + + if len(parts) > 1 { + action = parts[1] + } + + // Check for known package manager commands + if (packageManager == "apk" && action == "add") || (packageManager == "clean-install") { + cmdPrefix = packageManager + if packageManager == "clean-install" { + packageStartIndex = 1 // packages start after "clean-install" + } else { // apk add + packageStartIndex = 2 // packages start after "apk add" + } + } else if (packageManager == "apt-get" || packageManager == "apt") && action == "install" { + cmdPrefix = packageManager + packageStartIndex = 2 // packages start after "apt-get install" or "apt install" + } else if packageManager == "yum" || packageManager == "dnf" || packageManager == "microdnf" { + // Handle cases like "yum -y install" or "yum install -y" + if action == "install" { + cmdPrefix = packageManager + packageStartIndex = 2 + } else if len(parts) > 2 && strings.HasPrefix(action, "-") && parts[2] == "install" { // e.g. yum -y install + cmdPrefix = packageManager + action = parts[2] // Correctly set action to "install" + packageStartIndex = 3 // Packages start after "yum -y install" + } + } + + if packageStartIndex != -1 && len(parts) > packageStartIndex { + // Take the part of the command that should contain packages + packageRelevantParts := parts[packageStartIndex:] + var currentCommandPackages []string + + // Iterate through parts to strip flags and stop at further shell operators (though less likely here due to pre-split) + for _, part := range packageRelevantParts { + // Stop if we hit a shell operator (should ideally not happen if pre-split was perfect) + // or a path-like string or redirection, or variable expansion. + if part == "&&" || part == "||" || part == "|" || part == ";" || + strings.Contains(part, "/") || // Basic check for paths + strings.HasPrefix(part, "$") || // Variable expansion + strings.Contains(part, "=") || // Variable assignment or flag with value + part == ">" || part == "<" { // Redirection + if Debug { + log.Printf("DEBUG: Stopping package parsing at part: [%s] for sub-command: [%s]", part, trimmedCmd) + } + break + } + + // Skip flags (simple check for prefix) + if strings.HasPrefix(part, "-") { + // This won't handle flags that take a separate value (e.g., -o file) perfectly. + // It assumes flags are self-contained or combined (e.g., -y, --yes, --option=value). + if Debug { + log.Printf("DEBUG: Skipping flag: [%s] in sub-command: [%s]", part, trimmedCmd) + } + continue + } + // Assume what's left is a package + if part != "" { + currentCommandPackages = append(currentCommandPackages, part) + } + } + + if len(currentCommandPackages) > 0 { + if Debug { + log.Printf("DEBUG: Extracted packages: %v from %s install in sub-command: [%s]", currentCommandPackages, cmdPrefix, trimmedCmd) + } + allPackages = append(allPackages, currentCommandPackages...) + } else { + if Debug { + log.Printf("DEBUG: No packages extracted after filtering for sub-command: [%s]", trimmedCmd) + } + } + } else { + if Debug { + log.Printf("DEBUG: Sub-command not a recognized package install operation: [%s]", trimmedCmd) + } + } + } + + if len(allPackages) > 0 { + // Deduplicate packages before returning + seen := make(map[string]bool) + result := []string{} + for _, pkg := range allPackages { + if !seen[pkg] { + seen[pkg] = true + result = append(result, pkg) + } + } + if Debug { + log.Printf("DEBUG: Final packages from full command [%s]: %v", fullCommand, result) + } + return result + } + + if Debug { + log.Printf("DEBUG: No packages extracted from full command: [%s]", fullCommand) + } + return nil +} + +func parseUserAdd(cmd string) (User, error) { + // Basic parsing of adduser command + // In reality, this would need to handle all adduser options + parts := strings.Fields(cmd) + if len(parts) < 3 { + return User{}, fmt.Errorf("invalid adduser command") + } + return User{ + Username: parts[2], + UID: 1000, // Default UID + }, nil +} + +func parseServiceEnable(cmd string) string { + if strings.Contains(cmd, "systemctl enable") { + parts := strings.Fields(cmd) + for i, part := range parts { + if part == "enable" && i+1 < len(parts) { + return parts[i+1] + } + } + } + return "" +} + +// ConvertDockerfileToApko converts a Chainguard Dockerfile to apko configuration +// For multi-stage builds, it returns a map of stage names to their ApkoConfig. +func ConvertDockerfileToApko(dockerfile *dfc.Dockerfile) (map[string]*ApkoConfig, error) { + stageConfigs := make(map[string]*ApkoConfig) + var currentConfig *ApkoConfig + var currentStageName string + stageIndex := 0 + + // Initialize maps for the current stage's context + seenPackages := make(map[string]bool) + // seenPaths := make(map[string]bool) // Path de-duplication should be per-stage + // seenUsers := make(map[string]bool) // User de-duplication should be per-stage + // seenServices := make(map[string]bool) // Service de-duplication should be per-stage + + for _, line := range dockerfile.Lines { + if line.From != nil { + // Start new stage + if line.From.Alias != "" { + currentStageName = line.From.Alias + } else { + currentStageName = fmt.Sprintf("stage%d", stageIndex) + stageIndex++ + } + + currentConfig = &ApkoConfig{ + Contents: struct { + Repositories []string `yaml:"repositories"` + Packages []string `yaml:"packages"` + }{ + Packages: make([]string, 0), + }, + Accounts: struct { + Users []User `yaml:"users,omitempty"` + Groups []Group `yaml:"groups,omitempty"` + RunAs string `yaml:"run-as,omitempty"` + }{ + Users: make([]User, 0), + Groups: make([]Group, 0), + }, + Entrypoint: struct { + Command string `yaml:"command,omitempty"` + Type string `yaml:"type,omitempty"` + }{}, + Environment: make(map[string]string), + Paths: make([]Path, 0), + } + stageConfigs[currentStageName] = currentConfig + // Reset seen items for the new stage + seenPackages = make(map[string]bool) + // seenPaths = make(map[string]bool) // Initialize if needed per stage + // seenUsers = make(map[string]bool) // Initialize if needed per stage + // seenServices = make(map[string]bool) // Initialize if needed per stage + + // Handle base image packages for the current stage's config + base := strings.TrimPrefix(line.From.Base, "cgr.dev/") // Assuming cgr.dev/ORG/img format + // Further base image processing might be needed if we want to inherit from previous stage's packages in apko + // For now, we just note the base. Apko doesn't directly support FROM like Docker. + // We can add common packages based on image name if desired. + if strings.Contains(base, "alpine") && !strings.Contains(base, "distroless") && !strings.Contains(base, "static") { + // Add alpine repositories + currentConfig.Contents.Repositories = append(currentConfig.Contents.Repositories, "https://dl-cdn.alpinelinux.org/alpine/edge/main") + + if _, ok := seenPackages["alpine-base"]; !ok { + currentConfig.Contents.Packages = append(currentConfig.Contents.Packages, "alpine-base") + seenPackages["alpine-base"] = true + } + } + // Example: add nodejs if base image suggests it + if strings.Contains(base, "nodejs") { + if _, ok := seenPackages["nodejs"]; !ok { + currentConfig.Contents.Packages = append(currentConfig.Contents.Packages, "nodejs") + seenPackages["nodejs"] = true + } + } + if strings.Contains(base, "python") { + if _, ok := seenPackages["python3"]; !ok { + currentConfig.Contents.Packages = append(currentConfig.Contents.Packages, "python3") // or specific version + seenPackages["python3"] = true + } + } + + } + + if currentConfig == nil { + // This case should ideally not happen if Dockerfile is valid and starts with FROM + // Or, we can decide to have a default "base" stage if no FROM is found first. + // For now, skip lines until a FROM is processed. + continue + } + + // Process instructions based on line.Raw, as specific fields might not exist for all commands + // Also handle cases where specific instruction fields exist without Raw content + + // Handle RUN instructions directly if RunDetails exists + if line.Run != nil { + // First check if there are packages directly in RunDetails.Packages + if len(line.Run.Packages) > 0 { + for _, pkg := range line.Run.Packages { + if _, ok := seenPackages[pkg]; !ok { + currentConfig.Contents.Packages = append(currentConfig.Contents.Packages, pkg) + seenPackages[pkg] = true + } + } + } + + // Then check shell commands for package installation + if line.Run != nil && line.Run.Shell != nil && line.Run.Shell.Before != nil { + // Build the command string - use the converted command if available + var cmdBuilder strings.Builder + if line.Converted != "" { + // Use the converted command string, skipping the "RUN " prefix + if strings.HasPrefix(line.Converted, "RUN ") { + cmdBuilder.WriteString(strings.TrimPrefix(line.Converted, "RUN ")) + } else { + cmdBuilder.WriteString(line.Converted) + } + } else { + // Fall back to original command if no conversion available + for _, part := range line.Run.Shell.Before.Parts { + cmdBuilder.WriteString(part.Command) + if len(part.Args) > 0 { + cmdBuilder.WriteString(" ") + cmdBuilder.WriteString(strings.Join(part.Args, " ")) + } + if part.Delimiter != "" { + cmdBuilder.WriteString(" ") + cmdBuilder.WriteString(part.Delimiter) + cmdBuilder.WriteString(" ") + } + } + } + cmd := cmdBuilder.String() + + if Debug { + log.Printf("DEBUG: Using command for package parsing: [%s]", cmd) + } + + pkgs := parsePackageInstall(cmd) + for _, pkg := range pkgs { + if _, ok := seenPackages[pkg]; !ok { + currentConfig.Contents.Packages = append(currentConfig.Contents.Packages, pkg) + seenPackages[pkg] = true + } + } + + if strings.Contains(cmd, "adduser") { + // Simplified: assumes 'adduser ' or similar + cmdParts := strings.Fields(cmd) // Re-split the reconstructed command + var username string + var shell string + + for i, p := range cmdParts { + if strings.ToLower(p) == "adduser" && i+1 < len(cmdParts) { + // Check for options like -s or --shell + if cmdParts[i+1] == "-s" || cmdParts[i+1] == "--shell" { + if i+2 < len(cmdParts) { // shell path + shell = cmdParts[i+2] + if i+3 < len(cmdParts) { // username after shell + username = cmdParts[i+3] + } + } + } else if cmdParts[i+1] == "-D" { // Common busybox adduser flag + if i+2 < len(cmdParts) { + username = cmdParts[i+2] + } + } else { + username = cmdParts[i+1] // simple 'adduser username' + } + break // Found adduser and processed + } + } + if username == "" && len(cmdParts) > 1 && strings.ToLower(cmdParts[0]) == "adduser" { // fallback + username = cmdParts[len(cmdParts)-1] + } + + if username != "" { + userExists := false + for _, u := range currentConfig.Accounts.Users { + if u.Username == username { + userExists = true + break + } + } + if !userExists { + newUser := User{Username: username, UID: 1000 + len(currentConfig.Accounts.Users)} + if shell != "" { + newUser.Shell = shell + } + currentConfig.Accounts.Users = append(currentConfig.Accounts.Users, newUser) + } + } + } + } + } + + parts := strings.Fields(line.Raw) + if len(parts) == 0 { + continue + } + instruction := strings.ToUpper(parts[0]) + + switch instruction { + case "RUN": + // First check if there are packages directly in RunDetails.Packages + if line.Run != nil && len(line.Run.Packages) > 0 { + for _, pkg := range line.Run.Packages { + if _, ok := seenPackages[pkg]; !ok { + currentConfig.Contents.Packages = append(currentConfig.Contents.Packages, pkg) + seenPackages[pkg] = true + } + } + } + + // Then check shell commands for package installation + if line.Run != nil && line.Run.Shell != nil && line.Run.Shell.Before != nil { + // Build the command string - use the converted command if available + var cmdBuilder strings.Builder + if line.Converted != "" { + // Use the converted command string, skipping the "RUN " prefix + if strings.HasPrefix(line.Converted, "RUN ") { + cmdBuilder.WriteString(strings.TrimPrefix(line.Converted, "RUN ")) + } else { + cmdBuilder.WriteString(line.Converted) + } + } else { + // Fall back to original command if no conversion available + for _, part := range line.Run.Shell.Before.Parts { + cmdBuilder.WriteString(part.Command) + if len(part.Args) > 0 { + cmdBuilder.WriteString(" ") + cmdBuilder.WriteString(strings.Join(part.Args, " ")) + } + if part.Delimiter != "" { + cmdBuilder.WriteString(" ") + cmdBuilder.WriteString(part.Delimiter) + cmdBuilder.WriteString(" ") + } + } + } + cmd := cmdBuilder.String() + + if Debug { + log.Printf("DEBUG: Using command for package parsing: [%s]", cmd) + } + + pkgs := parsePackageInstall(cmd) + for _, pkg := range pkgs { + if _, ok := seenPackages[pkg]; !ok { + currentConfig.Contents.Packages = append(currentConfig.Contents.Packages, pkg) + seenPackages[pkg] = true + } + } + + if strings.Contains(cmd, "adduser") { + // Simplified: assumes 'adduser ' or similar + cmdParts := strings.Fields(cmd) // Re-split the reconstructed command + var username string + var shell string + + for i, p := range cmdParts { + if strings.ToLower(p) == "adduser" && i+1 < len(cmdParts) { + // Check for options like -s or --shell + if cmdParts[i+1] == "-s" || cmdParts[i+1] == "--shell" { + if i+2 < len(cmdParts) { // shell path + shell = cmdParts[i+2] + if i+3 < len(cmdParts) { // username after shell + username = cmdParts[i+3] + } + } + } else if cmdParts[i+1] == "-D" { // Common busybox adduser flag + if i+2 < len(cmdParts) { + username = cmdParts[i+2] + } + } else { + username = cmdParts[i+1] // simple 'adduser username' + } + break // Found adduser and processed + } + } + if username == "" && len(cmdParts) > 1 && strings.ToLower(cmdParts[0]) == "adduser" { // fallback + username = cmdParts[len(cmdParts)-1] + } + + if username != "" { + userExists := false + for _, u := range currentConfig.Accounts.Users { + if u.Username == username { + userExists = true + break + } + } + if !userExists { + newUser := User{Username: username, UID: 1000 + len(currentConfig.Accounts.Users)} + if shell != "" { + newUser.Shell = shell + } + currentConfig.Accounts.Users = append(currentConfig.Accounts.Users, newUser) + } + } + } + } + + case "ENV": + if len(parts) > 1 { + // ENV can be key=value or key value + if strings.Contains(parts[1], "=") { + // Handles ENV key=value key2=value2 ... + for _, kvPair := range parts[1:] { + pair := strings.SplitN(kvPair, "=", 2) + if len(pair) == 2 { + currentConfig.Environment[strings.TrimSpace(pair[0])] = strings.TrimSpace(pair[1]) + } + } + } else if len(parts) == 3 { + // Handles ENV key value + currentConfig.Environment[strings.TrimSpace(parts[1])] = strings.TrimSpace(parts[2]) + } + } + + case "WORKDIR": + if len(parts) > 1 { + currentConfig.WorkDir = parts[1] + } + + case "USER": + if len(parts) > 1 { + currentConfig.Accounts.RunAs = parts[1] + } + + case "ENTRYPOINT": + if len(parts) > 1 { + cmd := strings.Join(parts[1:], " ") + currentConfig.Entrypoint.Command = parseEntrypointOrCmd(cmd) + } + + case "CMD": + if len(parts) > 1 { + cmd := strings.Join(parts[1:], " ") + if currentConfig.Entrypoint.Command == "" { + currentConfig.Entrypoint.Command = parseEntrypointOrCmd(cmd) + } else { + if !strings.HasPrefix(currentConfig.Entrypoint.Command, "[") { // Simplistic check for shell form + currentConfig.Entrypoint.Command += " " + parseEntrypointOrCmd(cmd) + } + } + } + case "COPY", "ADD": // Treat ADD as COPY for path purposes + if len(parts) >= 3 { // Need at least COPY src dest + var srcs []string + var dest, chownVal, chmodVal, fromVal string + + // Parse flags like --chown, --chmod, --from + idx := 1 + for idx < len(parts) { // iterate through potential flags and sources + currentPart := parts[idx] + if strings.HasPrefix(currentPart, "--chown=") { + chownVal = strings.SplitN(currentPart, "=", 2)[1] + idx++ + continue + } else if currentPart == "--chown" && idx+1 < len(parts) { + chownVal = parts[idx+1] + idx += 2 + continue + } + + if strings.HasPrefix(currentPart, "--chmod=") { + chmodVal = strings.SplitN(currentPart, "=", 2)[1] + idx++ + continue + } else if currentPart == "--chmod" && idx+1 < len(parts) { + chmodVal = parts[idx+1] + idx += 2 + continue + } + + if strings.HasPrefix(currentPart, "--from=") { + fromVal = strings.SplitN(currentPart, "=", 2)[1] + idx++ + continue + } else if currentPart == "--from" && idx+1 < len(parts) { + fromVal = parts[idx+1] + idx += 2 + continue + } + // If it's not a flag, it's a source or destination + break // Break to collect sources and destination + } + + // Collect sources and destination from remaining parts + // parts[idx:] are the source(s) and destination + remainingArgs := parts[idx:] + if len(remainingArgs) >= 1 { + dest = remainingArgs[len(remainingArgs)-1] + if len(remainingArgs) > 1 { + srcs = remainingArgs[:len(remainingArgs)-1] + } else { + // Handle cases like `COPY .` or `COPY artifact` where src might be implied or same as dest + // If --from is used, the single remaining arg is likely the source from that stage + // If not --from, and it's a single arg, it's often `COPY .` (dest is current WORKDIR, src is .) + // For simplicity, if only one arg remains, treat it as both src and dest if not --from, + // or just src if --from is set (dest would have to be explicitly WORKDIR or similar) + // This part is tricky; Docker's COPY is very flexible. + // Let's assume if only one arg after flags, it's the source, and dest is WORKDIR. + // However, our 'dest' var needs to be explicit for path entry. + // A safer bet: if one arg, it's source, and dest must have been WORKDIR. + // Apko needs explicit dest. If WORKDIR is /app, COPY foo becomes COPY foo /app/foo + // For now, if len(remainingArgs) == 1, it must be the source. + // The user will have to ensure destination is clear. + // The previous logic: if len(parts) - idx == 1, dest = parts[idx], src = parts[idx] (if no fromVal) + // This is a complex case. Let's stick to: last is dest, rest are srcs. If only one, it's dest, and src is same. + if len(remainingArgs) == 1 { + srcs = append(srcs, remainingArgs[0]) // treat single remaining as a source + // If dest is directory (ends with /), Docker copies *into* it. + // If dest is a file, it copies *as* it. + // Our model assumes `dest` is the final path. + } + } + } + + if dest != "" { // Only add if we have a destination + if len(srcs) == 0 { // If no explicit sources, but dest is present (e.g. WORKDIR /foo; COPY bar) - this is not how Docker COPY works. + // This implies an issue if srcs is empty but dest is not. + // However, if it was `COPY .` (and `.` was the single remaining arg), `srcs` would have `.` + // If srcs is empty, it means parsing logic for srcs/dest needs review for single arg copies. + // For `COPY artifact_from_stage /app/`, remainingArgs is [`artifact_from_stage`, `/app/`]. srcs=[`artifact_from_stage`], dest=`/app/` + // For `COPY . .`, remainingArgs is [`.`, `.`]. srcs=[`.`], dest=`.` + // For `COPY . /app`, remainingArgs is [`.`, `/app`]. srcs=[`.`], dest=`/app` + // For `COPY singlefile`, if dest is WORKDIR, it becomes `COPY singlefile WORKDIR_PATH/singlefile`. + // Our parser requires explicit dest. + // If after flags, only one item `X` remains: + // - if X is clearly a dir (ends with /) or WORKDIR is set, it could be `dest=X, src="."` (implied CWD). + // - if X is a file, it could be `src=X, dest=WORKDIR/X`. + // The current loop for flags and then `remainingArgs` should correctly identify + // the last element of `remainingArgs` as `dest` and the rest as `srcs`. + // So, `if len(srcs) == 0 && dest != ""` implies remainingArgs had only one element (dest). + // In this case, that one element is also the source. + if len(remainingArgs) == 1 { // This means dest was the only non-flag argument + srcs = append(srcs, dest) + } + } + + for _, src := range srcs { // Create a path entry for each source + pathEntry := Path{ + Path: dest, // Destination path + Type: "hardlink", // Default type + Source: src, // Source path + } + if chownVal != "" { + // TODO: Parse chown (user:group) and set UID/GID on pathEntry + // This requires mapping user/group names to UIDs/GIDs defined in the stage. + // For now, we acknowledge it but don't translate. + // Could add a comment or an annotation if apko supported it. + // fmt.Printf("INFO: COPY --chown='%s' encountered for %s -> %s. Manual UID/GID mapping may be needed.\\n", chownVal, src, dest) + } + if chmodVal != "" { + pathEntry.Permissions = chmodVal // Apply chmod directly + } + if fromVal != "" { + // If --from is used, the 'src' is relative to that stage. + pathEntry.Source = fmt.Sprintf("--from=%s %s", fromVal, src) + } + currentConfig.Paths = append(currentConfig.Paths, pathEntry) + } + } + } + } + } + if len(stageConfigs) == 0 && dockerfile != nil && len(dockerfile.Lines) > 0 { + // If no FROM was found but there are lines, create a default stage + // This might happen for a Dockerfile snippet or an invalid one. + // For now, we require a FROM to start processing. + return nil, fmt.Errorf("no FROM instruction found in Dockerfile, cannot determine stages") + } + + return stageConfigs, nil +} + +// GenerateApkoYAML generates the apko YAML configuration +func GenerateApkoYAML(config *ApkoConfig) (string, error) { + var sb strings.Builder + + sb.WriteString("contents:\n") + if len(config.Contents.Repositories) > 0 { + sb.WriteString(" repositories:\n") + for _, repo := range config.Contents.Repositories { + sb.WriteString(fmt.Sprintf(" - %s\n", repo)) + } + } + sb.WriteString(" packages:\n") + for _, pkg := range config.Contents.Packages { + sb.WriteString(fmt.Sprintf(" - %s\n", pkg)) + } + + if config.WorkDir != "" { + sb.WriteString(fmt.Sprintf("work-dir: %s\n", config.WorkDir)) + } + + if len(config.Environment) > 0 { + sb.WriteString("environment:\n") + for k, v := range config.Environment { + sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v)) + } + } + + if config.Entrypoint.Command != "" { + sb.WriteString("entrypoint:\n") + sb.WriteString(fmt.Sprintf(" command: %s\n", config.Entrypoint.Command)) + if config.Entrypoint.Type != "" { + sb.WriteString(fmt.Sprintf(" type: %s\n", config.Entrypoint.Type)) + } + } + + if config.Accounts.RunAs != "" || len(config.Accounts.Users) > 0 || len(config.Accounts.Groups) > 0 { + sb.WriteString("accounts:\n") + if config.Accounts.RunAs != "" { + sb.WriteString(fmt.Sprintf(" run-as: %s\n", config.Accounts.RunAs)) + } + if len(config.Accounts.Users) > 0 { + sb.WriteString(" users:\n") + for _, u := range config.Accounts.Users { + sb.WriteString(fmt.Sprintf(" - username: %s\n", u.Username)) + if u.UID != 0 { + sb.WriteString(fmt.Sprintf(" uid: %d\n", u.UID)) + } + if u.Shell != "" { + sb.WriteString(fmt.Sprintf(" shell: %s\n", u.Shell)) + } + } + } + if len(config.Accounts.Groups) > 0 { + sb.WriteString(" groups:\n") + for _, g := range config.Accounts.Groups { + sb.WriteString(fmt.Sprintf(" - groupname: %s\n", g.Groupname)) + if g.GID != 0 { + sb.WriteString(fmt.Sprintf(" gid: %d\n", g.GID)) + } + } + } + } + + if len(config.Paths) > 0 { + sb.WriteString("paths:\n") + for _, p := range config.Paths { + sb.WriteString(fmt.Sprintf(" - path: %s\n", p.Path)) + sb.WriteString(fmt.Sprintf(" type: %s\n", p.Type)) + if p.UID != 0 { + sb.WriteString(fmt.Sprintf(" uid: %d\n", p.UID)) + } + if p.GID != 0 { + sb.WriteString(fmt.Sprintf(" gid: %d\n", p.GID)) + } + if p.Permissions != "" { + sb.WriteString(fmt.Sprintf(" permissions: %s\n", p.Permissions)) + } + if p.Source != "" { + sb.WriteString(fmt.Sprintf(" source: %s\n", p.Source)) + } + } + } + + return sb.String(), nil +} diff --git a/pkg/apko/apko_test.go b/pkg/apko/apko_test.go new file mode 100644 index 0000000..4c67192 --- /dev/null +++ b/pkg/apko/apko_test.go @@ -0,0 +1,176 @@ +/* +Copyright 2025 Chainguard, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package apko + +import ( + "testing" + + "github.com/chainguard-dev/dfc/pkg/dfc" +) + +func TestConvertDockerfileToApko(t *testing.T) { + tests := []struct { + name string + dockerfile *dfc.Dockerfile + want *ApkoConfig + wantErr bool + }{ + { + name: "simple dockerfile", + dockerfile: &dfc.Dockerfile{ + Lines: []*dfc.DockerfileLine{ + { + From: &dfc.FromDetails{ + Base: "cgr.dev/ORG/alpine", + Tag: "latest-dev", + }, + }, + { + Run: &dfc.RunDetails{ + Packages: []string{"nginx"}, + }, + }, + { + Raw: "WORKDIR /usr/share/nginx", + }, + { + Raw: "ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin", + }, + { + Raw: "USER nginx", + }, + }, + }, + want: &ApkoConfig{ + Contents: struct { + Repositories []string `yaml:"repositories"` + Packages []string `yaml:"packages"` + }{ + Repositories: []string{"https://dl-cdn.alpinelinux.org/alpine/edge/main"}, + Packages: []string{"alpine-base", "nginx"}, + }, + WorkDir: "/usr/share/nginx", + Environment: map[string]string{ + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin", + }, + Accounts: struct { + Users []User `yaml:"users,omitempty"` + Groups []Group `yaml:"groups,omitempty"` + RunAs string `yaml:"run-as,omitempty"` + }{ + RunAs: "nginx", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMap, err := ConvertDockerfileToApko(tt.dockerfile) + if (err != nil) != tt.wantErr { + t.Errorf("ConvertDockerfileToApko() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Since we expect a single stage, get the first (and should be only) stage + if len(gotMap) != 1 { + t.Errorf("ConvertDockerfileToApko() returned %d stages, expected 1", len(gotMap)) + return + } + + var got *ApkoConfig + for _, config := range gotMap { + got = config + break + } + + // Compare repositories + if len(got.Contents.Repositories) != len(tt.want.Contents.Repositories) { + t.Errorf("ConvertDockerfileToApko() repositories = %v, want %v", got.Contents.Repositories, tt.want.Contents.Repositories) + } + + // Compare packages + if len(got.Contents.Packages) != len(tt.want.Contents.Packages) { + t.Errorf("ConvertDockerfileToApko() packages = %v, want %v", got.Contents.Packages, tt.want.Contents.Packages) + } + + // Compare workdir + if got.WorkDir != tt.want.WorkDir { + t.Errorf("ConvertDockerfileToApko() workdir = %v, want %v", got.WorkDir, tt.want.WorkDir) + } + + // Compare environment + if len(got.Environment) != len(tt.want.Environment) { + t.Errorf("ConvertDockerfileToApko() environment = %v, want %v", got.Environment, tt.want.Environment) + } + + // Compare run-as user + if got.Accounts.RunAs != tt.want.Accounts.RunAs { + t.Errorf("ConvertDockerfileToApko() run-as = %v, want %v", got.Accounts.RunAs, tt.want.Accounts.RunAs) + } + }) + } +} + +func TestGenerateApkoYAML(t *testing.T) { + tests := []struct { + name string + config *ApkoConfig + want string + wantErr bool + }{ + { + name: "simple config", + config: &ApkoConfig{ + Contents: struct { + Repositories []string `yaml:"repositories"` + Packages []string `yaml:"packages"` + }{ + Repositories: []string{"https://dl-cdn.alpinelinux.org/alpine/edge/main"}, + Packages: []string{"alpine-base", "nginx"}, + }, + WorkDir: "/usr/share/nginx", + Environment: map[string]string{ + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin", + }, + Accounts: struct { + Users []User `yaml:"users,omitempty"` + Groups []Group `yaml:"groups,omitempty"` + RunAs string `yaml:"run-as,omitempty"` + }{ + RunAs: "nginx", + }, + }, + want: `contents: + repositories: + - https://dl-cdn.alpinelinux.org/alpine/edge/main + packages: + - alpine-base + - nginx +work-dir: /usr/share/nginx +environment: + PATH: /usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin +accounts: + run-as: nginx +`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GenerateApkoYAML(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateApkoYAML() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GenerateApkoYAML() = %v, want %v", got, tt.want) + } + }) + } +}