diff --git a/.github/workflows/example.yml b/.github/workflows/example.yml index 1ba9e7db..fa202ffc 100644 --- a/.github/workflows/example.yml +++ b/.github/workflows/example.yml @@ -544,6 +544,45 @@ jobs: retention-days: 14 # ANCHOR_END: example_build_universal + # Example of building with pruning enabled + # ANCHOR: example_build_prune + build-with-pruning: + needs: + - changes + - skip-check + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + prune: ['true', 'false'] + if: ${{ ! (github.event_name == 'pull_request_review' && github.actor != 'github-actions[bot]') && needs.skip-check.outputs.changes == 'true' }} + # Skip if pull_request_review on PR not made by a bot + steps: + - name: Cleanup + run: | + rm -rf ./* || true + rm -rf ./.??* || true + - name: Checkout + uses: actions/checkout@v4 + + - name: firmware-action + uses: ./ + # uses: 9elements/firmware-action + with: + config: 'tests/example_config__depends.json' + target: 'universal-example-B' + recursive: 'true' + prune: ${{ matrix.prune }} + compile: ${{ needs.changes.outputs.compile }} + + - name: Get artifacts + uses: actions/upload-artifact@v4 + with: + name: prune + path: output-universal-example-B-${{ matrix.prune }} + retention-days: 14 + # ANCHOR_END: example_build_prune + # Example of running on non-Linux systems test-operating-systems: strategy: diff --git a/action.yml b/action.yml index fed3e739..e8c5836a 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,12 @@ inputs: Build target recursively, with all of its dependencies. required: false default: 'false' + prune: + description: | + Remove Dagger container and its volumes after each module (only in recursive mode). + Enable this when building complex firmware stack in single job recursively and you are running out of disk space. + required: false + default: 'false' compile: description: | Compile the action from source instead of downloading pre-compiled binary from releases. @@ -119,6 +125,7 @@ runs: INPUT_CONFIG: ${{ inputs.config }} INPUT_TARGET: ${{ inputs.target }} INPUT_RECURSIVE: ${{ inputs.recursive }} + INPUT_PRUNE: ${{ inputs.prune }} - name: run_windows if: ${{ runner.os == 'Windows' }} @@ -129,3 +136,4 @@ runs: INPUT_CONFIG: ${{ inputs.config }} INPUT_TARGET: ${{ inputs.target }} INPUT_RECURSIVE: ${{ inputs.recursive }} + INPUT_PRUNE: ${{ inputs.prune }} diff --git a/cmd/firmware-action/container/container.go b/cmd/firmware-action/container/container.go index cc06f0c6..7d704cfc 100644 --- a/cmd/firmware-action/container/container.go +++ b/cmd/firmware-action/container/container.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "os" + "os/exec" "path/filepath" "regexp" "runtime" @@ -312,3 +313,123 @@ func GetArtifacts(ctx context.Context, container *dagger.Container, artifacts *[ return nil } + +// CleanupAfterContainer performs cleanup operations after container use +func CleanupAfterContainer(ctx context.Context) error { + // Unfortunately it is not possible to only remove the container used for building the module. + // Dagger Engine somehow absorbs the other containers into itself (possibly into it's volume, not sure). + // So to actually free up a disk space by deleting a container we have to delete the whole dagger engine + // container and it's volume. + // + // This function is used to free up disk space on constrained environments like GitHub Actions. + // GitHub-hosted public runners have only 14GB of disk space available. + // If user wants to build complex firmware stacks in single job recursively, they will easily run + // out of disk space. + // + // WARNING: This will completely stop the Dagger engine. Any subsequent Dagger + // operations will need to reinitialize the Dagger client. + + slog.Info("Cleaning up Dagger container resources") + + // Step 1: Find the Dagger engine container + findCmd := exec.CommandContext(ctx, "docker", "container", "ls", "--filter", "name=dagger-engine", "--format", "{{.ID}}") + containerID, err := findCmd.Output() + if err != nil { + slog.Error( + "Failed to find Dagger engine container", + slog.Any("error", err), + ) + return err + } + + containerIDStr := strings.TrimSpace(string(containerID)) + if containerIDStr == "" { + slog.Info("No Dagger engine container found to clean up") + return nil + } + + // Step 2: Stop the Dagger engine container + slog.Debug( + "Stopping Dagger engine container", + slog.String("containerID", containerIDStr), + ) + stopCmd := exec.CommandContext(ctx, "docker", "container", "stop", containerIDStr) + stopOutput, err := stopCmd.CombinedOutput() + if err != nil { + slog.Error( + "Failed to stop Dagger engine container", + slog.String("output", strings.TrimSpace(string(stopOutput))), + slog.Any("error", err), + ) + return err + } + + // Step 3: Remove the Dagger engine container + slog.Debug( + "Removing Dagger engine container", + slog.String("containerID", containerIDStr), + ) + rmCmd := exec.CommandContext(ctx, "docker", "container", "rm", containerIDStr) + rmOutput, err := rmCmd.CombinedOutput() + if err != nil { + slog.Error( + "Failed to remove Dagger engine container", + slog.String("output", strings.TrimSpace(string(rmOutput))), + slog.Any("error", err), + ) + return err + } + + // Step 4: Find and remove Dagger volumes + volCmd := exec.CommandContext(ctx, "docker", "volume", "ls", "--filter", "dangling=true", "--format", "{{.Name}}") + volumes, err := volCmd.Output() + if err != nil { + slog.Warn( + "Failed to list Docker volumes", + slog.Any("error", err), + ) + // Continue even if this fails + } else { + volumeList := strings.Split(strings.TrimSpace(string(volumes)), "\n") + for _, vol := range volumeList { + if vol == "" { + continue + } + slog.Debug( + "Removing Docker volume", + slog.String("volume", vol), + ) + rmVolCmd := exec.CommandContext(ctx, "docker", "volume", "rm", vol) + rmVolOutput, err := rmVolCmd.CombinedOutput() + if err != nil { + slog.Warn( + "Failed to remove Docker volume", + slog.String("volume", vol), + slog.String("output", strings.TrimSpace(string(rmVolOutput))), + slog.Any("error", err), + ) + // Continue with other volumes even if one fails + } + } + } + + // Step 5: Run system prune to clean up any remaining resources + pruneCmd := exec.CommandContext(ctx, "docker", "system", "prune", "-f") + pruneOutput, err := pruneCmd.CombinedOutput() + slog.Debug( + "Docker system prune output", + slog.String("command", "docker system prune -f"), + slog.String("output", strings.TrimSpace(string(pruneOutput))), + ) + if err != nil { + slog.Error( + "Failed to prune Docker system", + slog.String("output", strings.TrimSpace(string(pruneOutput))), + slog.Any("error", err), + ) + return err + } + + slog.Info("Dagger container resources cleaned up successfully") + return nil +} diff --git a/cmd/firmware-action/environment/environment.go b/cmd/firmware-action/environment/environment.go index 61219afe..28b33ab4 100644 --- a/cmd/firmware-action/environment/environment.go +++ b/cmd/firmware-action/environment/environment.go @@ -21,3 +21,11 @@ func FetchEnvVars(variables []string) map[string]string { return result } + +// DetectGithub function returns True when the execution environment is detected to be GitHub CI +func DetectGithub() bool { + // Check for GitHub + // https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + _, exists := os.LookupEnv("GITHUB_ACTIONS") + return exists +} diff --git a/cmd/firmware-action/main.go b/cmd/firmware-action/main.go index 2eda5600..433553f4 100644 --- a/cmd/firmware-action/main.go +++ b/cmd/firmware-action/main.go @@ -14,6 +14,7 @@ import ( "regexp" "strings" + "github.com/9elements/firmware-action/cmd/firmware-action/environment" "github.com/9elements/firmware-action/cmd/firmware-action/filesystem" "github.com/9elements/firmware-action/cmd/firmware-action/logging" "github.com/9elements/firmware-action/cmd/firmware-action/recipes" @@ -50,8 +51,9 @@ var CLI struct { Config []string `type:"path" required:"" default:"${config_file}" help:"Path to configuration file, supports multiple flags to use multiple configuration files"` Build struct { - Target string `required:"" help:"Select which target to build, use ID from configuration file"` - Recursive bool `help:"Build recursively with all dependencies and payloads"` + Target string `required:"" help:"Select which target to build, use ID from configuration file"` + Recursive bool `help:"Build recursively with all dependencies and payloads"` + PruneDockerContainers bool `help:"Remove Dagger container and its volumes after each module (only in recursive mode)"` } `cmd:"build" help:"Build a target defined in configuration file. For interactive debugging preface the command with 'dagger run --interactive', for example 'dagger run --interactive $(which firmware-action) build --config=...'. To install dagger follow instructions at https://dagger.io/"` GenerateConfig struct{} `cmd:"generate-config" help:"Generate empty configuration file"` @@ -85,6 +87,7 @@ func run(ctx context.Context) error { slog.Any("input/config", CLI.Config), slog.String("input/target", CLI.Build.Target), slog.Bool("input/recursive", CLI.Build.Recursive), + slog.Bool("input/prune", CLI.Build.PruneDockerContainers), ) // Check if submodules were initialized @@ -133,6 +136,7 @@ submodule_out: ctx, CLI.Build.Target, CLI.Build.Recursive, + CLI.Build.PruneDockerContainers, myConfig, recipes.Execute, ) @@ -163,9 +167,7 @@ submodule_out: func getInputsFromEnvironment() (string, error) { // Check for GitHub - // https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables - _, exists := os.LookupEnv("GITHUB_ACTIONS") - if exists { + if environment.DetectGithub() { return parseGithub() } @@ -292,6 +294,7 @@ func parseGithub() (string, error) { CLI.Config = strings.Split(action.GetInput("config"), "\n") CLI.Build.Target = action.GetInput("target") CLI.Build.Recursive = regexTrue.MatchString(action.GetInput("recursive")) + CLI.Build.PruneDockerContainers = regexTrue.MatchString(action.GetInput("prune")) CLI.JSON = regexTrue.MatchString(action.GetInput("json")) return "GitHub", nil diff --git a/cmd/firmware-action/recipes/recipes.go b/cmd/firmware-action/recipes/recipes.go index 51885410..ae152107 100644 --- a/cmd/firmware-action/recipes/recipes.go +++ b/cmd/firmware-action/recipes/recipes.go @@ -15,6 +15,7 @@ import ( "sync" "dagger.io/dagger" + "github.com/9elements/firmware-action/cmd/firmware-action/container" "github.com/9elements/firmware-action/cmd/firmware-action/filesystem" "github.com/heimdalr/dag" ) @@ -67,6 +68,7 @@ func Build( ctx context.Context, target string, recursive bool, + pruneDocker bool, config *Config, executor func(context.Context, string, *Config) error, ) ([]BuildResults, error) { @@ -139,6 +141,16 @@ func Build( if err != nil && !errors.Is(err, ErrBuildUpToDate) { break } + + // Prune the Dagger Engine to free disk space + // It is fine to do here because the `executor` function connects and disconnects + // to Dagger Engine (it is self-contained) + if pruneDocker { + err = container.CleanupAfterContainer(ctx) + if err != nil { + break + } + } } } else { // else build only the target diff --git a/cmd/firmware-action/recipes/recipes_test.go b/cmd/firmware-action/recipes/recipes_test.go index ec6a44b4..c6f23fc3 100644 --- a/cmd/firmware-action/recipes/recipes_test.go +++ b/cmd/firmware-action/recipes/recipes_test.go @@ -250,6 +250,7 @@ func TestBuild(t *testing.T) { ctx, tc.target, tc.recursive, + false, // do not prune &tc.config, executeDummy, ) @@ -262,6 +263,7 @@ func TestBuild(t *testing.T) { ctx, "pizza", recursive, + false, // do not prune &testConfigDependencyHell, executeDummy, ) diff --git a/tests/example_config.json b/tests/example_config.json index ac5bc731..15e713f7 100644 --- a/tests/example_config.json +++ b/tests/example_config.json @@ -17,6 +17,44 @@ "input_files": null } }, + "universal": { + "universal-example-A": { + "depends": null, + "sdk_url": "golang:latest", + "repo_path": "./", + "container_output_dirs": null, + "container_output_files": [ + "test.txt" + ], + "output_dir": "output-universal-example-A/", + "input_dirs": null, + "input_files": null, + "container_input_dir": "inputs/", + "build_commands": [ + "echo 'hello world'", + "touch test.txt" + ] + }, + "universal-example-B": { + "depends": [ + "universal-example-A" + ], + "sdk_url": "golang:latest", + "repo_path": "./", + "container_output_dirs": null, + "container_output_files": [ + "test.txt" + ], + "output_dir": "output-universal-example-B/", + "input_dirs": null, + "input_files": null, + "container_input_dir": "inputs/", + "build_commands": [ + "echo 'hello world'", + "touch test.txt" + ] + } + }, "edk2": { "edk2-example": { "depends": null, diff --git a/tests/example_config__depends.json b/tests/example_config__depends.json new file mode 100644 index 00000000..437b5851 --- /dev/null +++ b/tests/example_config__depends.json @@ -0,0 +1,40 @@ +{ + "universal": { + "universal-example-A": { + "depends": null, + "sdk_url": "golang:latest", + "repo_path": "./", + "container_output_dirs": null, + "container_output_files": [ + "test.txt" + ], + "output_dir": "output-universal-example-A/", + "input_dirs": null, + "input_files": null, + "container_input_dir": "inputs/", + "build_commands": [ + "echo 'hello world'", + "touch test.txt" + ] + }, + "universal-example-B": { + "depends": [ + "universal-example-A" + ], + "sdk_url": "golang:latest", + "repo_path": "./", + "container_output_dirs": null, + "container_output_files": [ + "test.txt" + ], + "output_dir": "output-universal-example-B/", + "input_dirs": null, + "input_files": null, + "container_input_dir": "inputs/", + "build_commands": [ + "echo 'hello world'", + "touch test.txt" + ] + } + } +}