From 649d0d87f9b9d76396b6ab14ed0b8c1b12b025c5 Mon Sep 17 00:00:00 2001 From: Layla Manley Date: Sun, 23 Jul 2023 02:30:27 -0400 Subject: [PATCH] Logging & Godot Downloader --- Dockerfile | 14 +++ go.mod | 8 ++ go.sum | 4 + internal/downloader.go | 160 ++++++++++++++++++++++++++++++ internal/godot4.go | 56 +++++++++++ internal/godot_wrapper.go | 24 +++++ internal/os.go | 9 ++ logging/github_actions.go | 136 +++++++++++++++++++++++++ logging/logger.go | 202 ++++++++++++++++++++++++++++++++++++++ main.go | 51 ++++++++++ utils/unzip.go | 64 ++++++++++++ 11 files changed, 728 insertions(+) create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/downloader.go create mode 100644 internal/godot4.go create mode 100644 internal/godot_wrapper.go create mode 100644 internal/os.go create mode 100644 logging/github_actions.go create mode 100644 logging/logger.go create mode 100644 main.go create mode 100644 utils/unzip.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ee5f2f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1-alpine3.18 AS builder + +RUN go build main.go -o /usr/local/bin/godot-build-tool + +FROM alpine:3.18 +RUN apk add --no-cache unzip + +RUN wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub \ + && wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r1/glibc-2.35-r1.apk \ + && apk add glibc-2.35-r1.apk + +WORKDIR /opt +COPY --from=builder /usr/local/bin/godot-build-tool /opt/godot-build-tool +CMD ["/opt/godot-build-tool"] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8a25f44 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/yeslayla/godot-build-tools + +go 1.20 + +require ( + github.com/sethvargo/go-envconfig v0.9.0 // indirect + github.com/sethvargo/go-githubactions v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..757224e --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/sethvargo/go-envconfig v0.9.0 h1:Q6FQ6hVEeTECULvkJZakq3dZMeBQ3JUpcKMfPQbKMDE= +github.com/sethvargo/go-envconfig v0.9.0/go.mod h1:Iz1Gy1Sf3T64TQlJSvee81qDhf7YIlt8GMUX6yyNFs0= +github.com/sethvargo/go-githubactions v1.1.0 h1:mg03w+b+/s5SMS298/2G6tHv8P0w0VhUFaqL1THIqzY= +github.com/sethvargo/go-githubactions v1.1.0/go.mod h1:qIboSF7yq2Qnaw2WXDsqCReM0Lo1gU4QXUWmhBC3pxE= diff --git a/internal/downloader.go b/internal/downloader.go new file mode 100644 index 0000000..143aeb4 --- /dev/null +++ b/internal/downloader.go @@ -0,0 +1,160 @@ +package internal + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + + "github.com/yeslayla/godot-build-tools/logging" + "github.com/yeslayla/godot-build-tools/utils" +) + +type DownloaderOptions struct { + DownloadRepositoryURL string + BinDir string +} + +type Downloader struct { + downloadRepositoryURL string + bin string + + logger logging.Logger +} + +func NewDownloader(targetOS TargetOS, logger logging.Logger, options *DownloaderOptions) *Downloader { + var url string = options.DownloadRepositoryURL + if url == "" { + url = "https://downloads.tuxfamily.org/godotengine/" + } + var binDir string = options.BinDir + if binDir == "" { + switch targetOS { + case TargetOSLinux: + home, _ := os.UserHomeDir() + binDir = filepath.Join(home, "/.local/bin") + case TargetOSWindows: + binDir = "C:\\Program Files (x86)\\Godot" + case TargetOSMacOS: + binDir = "/Applications/Godot" + } + } + + return &Downloader{ + downloadRepositoryURL: url, + bin: binDir, + logger: logger, + } +} + +func getRemoteFileFormat(targetOS TargetOS, version string) string { + switch targetOS { + case TargetOSLinux: + if version[0] == '3' { + return "Godot_v%s-%s_x11.64.zip" + } + return "Godot_v%s-%s_linux.x86_64.zip" + case TargetOSWindows: + return "Godot_v%s-%s_win64.exe.zip" + case TargetOSMacOS: + return "Godot_v%s-%s_macos.universal.zip" + } + + return "" +} + +func (d *Downloader) DownloadGodot(targetOS TargetOS, version string, release string) (string, error) { + + var fileName string = fmt.Sprintf(getRemoteFileFormat(targetOS, version), version, release) + + tempDir, _ := os.MkdirTemp("", "godot-build-tools") + outFile := filepath.Join(tempDir, fileName) + out, err := os.Create(outFile) + if err != nil { + return "", fmt.Errorf("failed to create output file: %s", err) + } + defer out.Close() + + downloadURL, err := url.Parse(d.downloadRepositoryURL) + if err != nil { + return "", fmt.Errorf("failed to parse download repository URL: %s", err) + } + + downloadURL.Path = path.Join(downloadURL.Path, version) + if release != "stable" { + downloadURL.Path = path.Join(downloadURL.Path, release) + } + + downloadURL.Path = path.Join(downloadURL.Path, fileName) + d.logger.Debugf("Download URL: %s", downloadURL.String()) + + resp, err := http.Get(downloadURL.String()) + if err != nil { + return "", fmt.Errorf("failed to download Godot: %s", err) + } + defer resp.Body.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return "", fmt.Errorf("failed to write Godot package to output file: %s", err) + } + + return outFile, nil +} + +func (d *Downloader) UnzipGodot(targetOS TargetOS, godotPackage string) (string, error) { + files, err := utils.Unzip(godotPackage) + if err != nil { + return "", fmt.Errorf("failed to unzip Godot package: %s", err) + } + + // Look for godot binary + for _, file := range files { + switch targetOS { + case TargetOSLinux: + if path.Ext(file) == ".x86_64" || path.Ext(file) == ".64" { + return file, nil + } + case TargetOSWindows: + if path.Ext(file) == ".exe" { + return file, nil + } + case TargetOSMacOS: + if path.Ext(file) == ".universal" { + return file, nil + } + } + } + + return "", fmt.Errorf("failed to find godot binary in Godot package") +} + +func (d *Downloader) InstallGodot(godotPackage string, targetOS TargetOS, version string, release string) (string, error) { + + // Unzip package + godotUnzipBinPath, err := d.UnzipGodot(targetOS, godotPackage) + if err != nil { + return "", fmt.Errorf("failed to unzip Godot package: %s", err) + } + + godotBin := path.Base(godotUnzipBinPath) + godotBinPath := filepath.Join(d.bin, godotBin) + + // Copy Godot binary to bin directory + data, err := ioutil.ReadFile(godotUnzipBinPath) + if err != nil { + return "", fmt.Errorf("failed to read Godot binary: %s", err) + } + err = ioutil.WriteFile(godotBinPath, data, 0755) + if err != nil { + return "", fmt.Errorf("failed to write Godot binary: %s", err) + } + + _ = os.Remove(godotUnzipBinPath) + + return godotBinPath, nil +} diff --git a/internal/godot4.go b/internal/godot4.go new file mode 100644 index 0000000..7163d4c --- /dev/null +++ b/internal/godot4.go @@ -0,0 +1,56 @@ +package internal + +import "strings" + +type Godot4ArgBuilder struct { + args []string +} + +func NewGodot4ArgBuilder(projectDir string) GodotArgBuilder { + return &Godot4ArgBuilder{ + args: []string{"--path", projectDir}, + } +} + +func (b *Godot4ArgBuilder) AddHeadlessFlag() { + b.args = append(b.args, "--headless") +} + +func (b *Godot4ArgBuilder) AddDebugFlag() { + b.args = append(b.args, "--debug") +} + +func (b *Godot4ArgBuilder) AddVerboseFlag() { + b.args = append(b.args, "--verbose") +} + +func (b *Godot4ArgBuilder) AddQuietFlag() { + b.args = append(b.args, "--quiet") +} + +func (b *Godot4ArgBuilder) AddDumpGDExtensionInterfaceFlag() { + b.args = append(b.args, "--dump-gdextension-interface") +} + +func (b *Godot4ArgBuilder) AddDumpExtensionApiFlag() { + b.args = append(b.args, "--dump-extension-api") +} + +func (b *Godot4ArgBuilder) AddCheckOnlyFlag() { + b.args = append(b.args, "--check-only") +} + +func (b *Godot4ArgBuilder) AddExportFlag(exportType ExportType) { + switch exportType { + case ExportTypeRelease: + b.args = append(b.args, "--export") + case ExportTypeDebug: + b.args = append(b.args, "--export-debug") + case ExportTypePack: + b.args = append(b.args, "--export-pack") + } +} + +func (b *Godot4ArgBuilder) GenerateArgs() string { + return strings.Join(b.args, " ") +} diff --git a/internal/godot_wrapper.go b/internal/godot_wrapper.go new file mode 100644 index 0000000..064f421 --- /dev/null +++ b/internal/godot_wrapper.go @@ -0,0 +1,24 @@ +package internal + +type ExportType uint8 + +const ( + ExportTypeRelease ExportType = iota + ExportTypeDebug + ExportTypePack +) + +type GodotArgBuilder interface { + AddHeadlessFlag() + AddDebugFlag() + AddVerboseFlag() + AddQuietFlag() + + AddDumpGDExtensionInterfaceFlag() + AddDumpExtensionApiFlag() + AddCheckOnlyFlag() + + AddExportFlag(exportType ExportType) + + GenerateArgs() string +} diff --git a/internal/os.go b/internal/os.go new file mode 100644 index 0000000..1f5791a --- /dev/null +++ b/internal/os.go @@ -0,0 +1,9 @@ +package internal + +type TargetOS uint8 + +const ( + TargetOSLinux TargetOS = iota + TargetOSWindows + TargetOSMacOS +) diff --git a/logging/github_actions.go b/logging/github_actions.go new file mode 100644 index 0000000..9dbb849 --- /dev/null +++ b/logging/github_actions.go @@ -0,0 +1,136 @@ +package logging + +import ( + "fmt" + "log" + "os" +) + +// GitHubActionsLogger is a logger that logs to GitHub Actions. +type GitHubActionsLogger struct { + info *log.Logger + warn *log.Logger + err *log.Logger + debug *log.Logger +} + +// NewGitHubActionsLogger creates a new GitHubActionsLogger. +func NewGitHubActionsLogger(debug bool) Logger { + var debugLogger *log.Logger + if debug { + debugLogger = log.New(os.Stdout, "::debug::", 0) + } + + return &GitHubActionsLogger{ + info: log.New(os.Stdout, "", 0), + warn: log.New(os.Stdout, "::warning::", 0), + err: log.New(os.Stderr, "::error::", 0), + debug: debugLogger, + } +} + +// Infof logs an info message. +func (l *GitHubActionsLogger) Infof(format string, args ...interface{}) { + l.info.Printf(format, args...) +} + +// Warnf logs a warning message. +func (l *GitHubActionsLogger) Warnf(format string, args ...interface{}) { + l.warn.Printf(format, args...) +} + +// Errorf logs an error message. +func (l *GitHubActionsLogger) Errorf(format string, args ...interface{}) { + l.err.Printf(format, args...) +} + +// Debugf logs a debug message if debug logging is enabled. +func (l *GitHubActionsLogger) Debugf(format string, args ...interface{}) { + if l.debug != nil { + l.debug.Printf(format, args...) + } +} + +// NoticeMessage sends a notice message to GitHub Actions. +func (l *GitHubActionsLogger) NoticeMessage(message string, input NoticeMessageInput) { + var prefix string = "::notice" + if input.Title != nil { + prefix += " title=" + *input.Title + } + if input.Filename != nil { + prefix += " file=" + *input.Filename + } + if input.Line != nil { + prefix += " line=" + fmt.Sprint(*input.Line) + } + if input.EndLine != nil { + prefix += " endLine=" + fmt.Sprint(*input.EndLine) + } + if input.Col != nil { + prefix += " col=" + fmt.Sprint(*input.Col) + } + if input.EndCol != nil { + prefix += " endColumn=" + fmt.Sprint(*input.EndCol) + } + + l.info.Printf("%s::%s", prefix, message) +} + +// StartGroup groups together log messages. +func (l *GitHubActionsLogger) StartGroup(name string) { + l.info.Printf("::group::%s", name) +} + +// EndGroup ends a group. +func (l *GitHubActionsLogger) EndGroup() { + l.info.Println("::endgroup::") +} + +// Mask masks a value in log output. +func (l *GitHubActionsLogger) Mask(value string) { + l.info.Printf("::add-mask::%s", value) +} + +// SetOutput sets an output parameter. +func (l *GitHubActionsLogger) SetOutput(name string, value string) { + outputFile := os.Getenv("GITHUB_OUTPUT") + if outputFile == "" { + l.Errorf("GITHUB_OUTPUT is not set") + return + } + + f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + l.Errorf("failed to open output file: %v", err) + return + } + defer f.Close() + + if _, err := f.WriteString(fmt.Sprint(name, "=", value)); err != nil { + l.Errorf("failed to write output file: %v", err) + return + } + +} + +// SetSummary sets a job's summary in markdown format. +func (l *GitHubActionsLogger) SetSummary(summary string) { + summaryFile := os.Getenv("GITHUB_STEP_SUMMARY") + if summaryFile == "" { + l.Errorf("GITHUB_STEP_SUMMARY is not set") + return + } + + f, err := os.OpenFile(summaryFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + l.Errorf("failed to open summary file: %v", err) + return + } + defer f.Close() + + if _, err := f.WriteString(summary); err != nil { + l.Errorf("failed to write summary file: %v", err) + return + } + +} diff --git a/logging/logger.go b/logging/logger.go new file mode 100644 index 0000000..585d13c --- /dev/null +++ b/logging/logger.go @@ -0,0 +1,202 @@ +package logging + +import ( + "fmt" + "log" + "os" + "strings" +) + +// Logger is an interface for logging. +type Logger interface { + Infof(format string, args ...interface{}) + Warnf(format string, args ...interface{}) + Errorf(format string, args ...interface{}) + Debugf(format string, args ...interface{}) + + Mask(value string) + + StartGroup(name string) + EndGroup() + + NoticeMessage(message string, input NoticeMessageInput) + SetOutput(name string, value string) + SetSummary(summary string) +} + +// DefaultLogger is a logger that logs to the console. +type DefaultLogger struct { + info *log.Logger + warn *log.Logger + err *log.Logger + debug *log.Logger + + outputsFile string + summaryFile string + + groups []string + masks []string +} + +// NoticeMessageInput holds optional parameters for a notice message. +type NoticeMessageInput struct { + Title *string + Filename *string + Line *int + EndLine *int + Col *int + EndCol *int +} + +// LoggerOptions holds options for creating a new logger. +type LoggerOptions struct { + OutputsFile string + SummaryFile string + Debug bool +} + +// NewLogger creates a new default logger. +func NewLogger(options *LoggerOptions) Logger { + + var debugLogger *log.Logger + if options.Debug { + debugLogger = log.New(os.Stdout, "DEBUG ", log.LstdFlags) + } + + return &DefaultLogger{ + info: log.New(os.Stdout, "INFO ", log.LstdFlags), + warn: log.New(os.Stdout, "WARNING ", log.LstdFlags), + err: log.New(os.Stderr, "ERROR ", log.LstdFlags), + debug: debugLogger, + groups: []string{}, + masks: []string{}, + + outputsFile: options.OutputsFile, + summaryFile: options.SummaryFile, + } +} + +// formatMessage wraps the message with the current group and removes any masked values. +func (l *DefaultLogger) formatMessage(message string) string { + message = l.removeMasks(message) + message = l.addGroups(message) + return message +} + +// removeMasks removes any masked values from the message. +func (l *DefaultLogger) removeMasks(message string) string { + for _, mask := range l.masks { + message = strings.ReplaceAll(message, mask, "********") + } + return message +} + +// addGroups adds the current group to the message. +func (l *DefaultLogger) addGroups(message string) string { + for _, group := range l.groups { + message = fmt.Sprint(group, " - ", message) + } + return message +} + +// Infof logs an info message. +func (l *DefaultLogger) Infof(format string, args ...interface{}) { + l.info.Printf(l.formatMessage(format), args...) +} + +// Warnf logs a warning message. +func (l *DefaultLogger) Warnf(format string, args ...interface{}) { + l.warn.Printf(l.formatMessage(format), args...) +} + +// Errorf logs an error message. +func (l *DefaultLogger) Errorf(format string, args ...interface{}) { + l.err.Printf(l.formatMessage(format), args...) +} + +// Debugf logs a debug message if debug logging is enabled. +func (l *DefaultLogger) Debugf(format string, args ...interface{}) { + if l.debug != nil { + l.debug.Printf(l.formatMessage(format), args...) + } +} + +// NoticeMessage sends a notice about a line. +func (l *DefaultLogger) NoticeMessage(message string, input NoticeMessageInput) { + var prefix string = "" + if input.Title != nil { + prefix += " title=" + *input.Title + } + if input.Filename != nil { + prefix += " file=" + *input.Filename + } + if input.Line != nil { + prefix += " line=" + fmt.Sprint(*input.Line) + } + if input.EndLine != nil { + prefix += " endLine=" + fmt.Sprint(*input.EndLine) + } + if input.Col != nil { + prefix += " col=" + fmt.Sprint(*input.Col) + } + if input.EndCol != nil { + prefix += " endColumn=" + fmt.Sprint(*input.EndCol) + } + + l.info.Printf(l.formatMessage(fmt.Sprintf("%s %s", prefix, message))) +} + +// Mask hides a value in the log output. +func (l *DefaultLogger) Mask(value string) { + l.masks = append(l.masks, value) +} + +// StartGroup groups together log messages. +func (l *DefaultLogger) StartGroup(name string) { + l.groups = append(l.groups, name) +} + +// EndGroup ends a group. +func (l *DefaultLogger) EndGroup() { + if len(l.groups) > 0 { + l.groups = l.groups[:len(l.groups)-1] + } +} + +// SetOutput outputs a key-value pair to the outputs file. +func (l *DefaultLogger) SetOutput(name string, value string) { + if l.outputsFile == "" { + return + } + + f, err := os.OpenFile(l.outputsFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + l.Errorf("failed to open outputs file: %s", err) + return + } + defer f.Close() + + if _, err := f.WriteString(fmt.Sprintf("%s=%s", name, value)); err != nil { + l.Errorf("failed to write to outputs file: %s", err) + return + } +} + +// SetSummary outputs a markdown-formatted summary to the summary file. +func (l *DefaultLogger) SetSummary(summary string) { + if l.summaryFile == "" { + return + } + + f, err := os.OpenFile(l.summaryFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + l.Errorf("failed to open summary file: %s", err) + return + } + defer f.Close() + + if _, err := f.WriteString(summary); err != nil { + l.Errorf("failed to write to summary file: %s", err) + return + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..684ffb6 --- /dev/null +++ b/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "runtime" + + "github.com/yeslayla/godot-build-tools/internal" + "github.com/yeslayla/godot-build-tools/logging" +) + +func main() { + logger := logging.NewLogger(&logging.LoggerOptions{}) + + var targetOS internal.TargetOS + switch runtime.GOOS { + case "linux": + targetOS = internal.TargetOSLinux + case "windows": + targetOS = internal.TargetOSWindows + case "darwin": + targetOS = internal.TargetOSMacOS + } + + GodotSetup(logger, targetOS, "3.3.2", "stable") + +} + +func GodotSetup(logger logging.Logger, targetOS internal.TargetOS, version string, release string) (string, bool) { + logger.StartGroup("Godot Setup") + defer logger.EndGroup() + downloader := internal.NewDownloader(internal.TargetOSLinux, logger, &internal.DownloaderOptions{}) + + logger.Infof("Downloading Godot") + godotPackage, err := downloader.DownloadGodot(internal.TargetOSLinux, version, release) + if err != nil { + logger.Errorf("Failed to download Godot: %s", err) + return "", false + } + defer os.Remove(godotPackage) + logger.Infof("Godot package: %s", godotPackage) + + logger.Infof("Installing Godot") + godotBin, err := downloader.InstallGodot(godotPackage, internal.TargetOSLinux, version, release) + if err != nil { + logger.Errorf("Failed to install Godot: %s", err) + return "", false + } + logger.Infof("Godot binary: %s", godotBin) + + return godotBin, true +} diff --git a/utils/unzip.go b/utils/unzip.go new file mode 100644 index 0000000..4fc2f48 --- /dev/null +++ b/utils/unzip.go @@ -0,0 +1,64 @@ +package utils + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" +) + +// Unzip unzips a zip archive and returns the paths of the unzipped files. +func Unzip(archivePath string) ([]string, error) { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return nil, fmt.Errorf("failed to open Godot package: %s", err) + } + defer reader.Close() + + destDir, err := filepath.Abs(path.Dir(archivePath)) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path of Godot package: %s", err) + } + + var unzippedFiles []string = make([]string, 0) + for _, file := range reader.File { + filePath := filepath.Join(destDir, file.Name) + if !strings.HasPrefix(filePath, filepath.Clean(destDir)+string(os.PathSeparator)) { + return unzippedFiles, fmt.Errorf("%s: illegal file path", filePath) + } + + if file.FileInfo().IsDir() { + if err := os.MkdirAll(filePath, os.ModePerm); err != nil { + return unzippedFiles, fmt.Errorf("failed to create directory: %s", err) + } + continue + } + + if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { + return unzippedFiles, fmt.Errorf("failed to create directory: %s", err) + } + + destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return unzippedFiles, fmt.Errorf("failed to open file: %s", err) + } + defer destFile.Close() + + zippedFile, err := file.Open() + if err != nil { + return unzippedFiles, fmt.Errorf("failed to open zipped file: %s", err) + } + defer zippedFile.Close() + + if _, err := io.Copy(destFile, zippedFile); err != nil { + return unzippedFiles, fmt.Errorf("failed to copy file: %s", err) + } + + unzippedFiles = append(unzippedFiles, filePath) + } + + return unzippedFiles, nil +}