From 402a01d51f2572115dfec7d815550a8854a26871 Mon Sep 17 00:00:00 2001 From: Josua Schmid Date: Tue, 17 Mar 2026 15:31:37 +0100 Subject: [PATCH] Add gitonce support with --from-local-dir --- api/gitonce/client.go | 260 ++++++++++++++++++++++++++++++ api/gitonce/client_test.go | 262 +++++++++++++++++++++++++++++++ create/application.go | 23 ++- create/application_test.go | 50 ++++++ internal/test/gitonce_service.go | 104 ++++++++++++ update/application.go | 19 +++ update/application_test.go | 37 +++++ 7 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 api/gitonce/client.go create mode 100644 api/gitonce/client_test.go create mode 100644 internal/test/gitonce_service.go diff --git a/api/gitonce/client.go b/api/gitonce/client.go new file mode 100644 index 0000000..5af8d30 --- /dev/null +++ b/api/gitonce/client.go @@ -0,0 +1,260 @@ +// Package gitonce provides a client to upload local directories to the gitonce +// service, which converts them to one-time-use git repositories. +package gitonce + +import ( + "archive/zip" + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const DefaultUploadURL = "https://gitonce.21ddead.deploio.app/upload" + +type uploadResponse struct { + Message string `json:"message"` + URL string `json:"url"` + Commit string `json:"commit"` +} + +// UploadResult holds the result of a successful gitonce upload. +type UploadResult struct { + // URL is the one-time-use git repository URL. + URL string + // Commit is the commit hash of the uploaded content. + Commit string +} + +// UploadDirectory zips the given local directory and uploads it to the gitonce +// service at uploadURL. It returns the upload result containing the URL and +// commit hash. +func UploadDirectory(ctx context.Context, dir, uploadURL string) (UploadResult, error) { + buf, err := zipDirectory(dir) + if err != nil { + return UploadResult{}, fmt.Errorf("zipping directory %q: %w", dir, err) + } + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + fw, err := mw.CreateFormFile("zipfile", "source.zip") + if err != nil { + return UploadResult{}, fmt.Errorf("creating form file: %w", err) + } + if _, err := io.Copy(fw, buf); err != nil { + return UploadResult{}, fmt.Errorf("writing zip to form: %w", err) + } + if err := mw.Close(); err != nil { + return UploadResult{}, fmt.Errorf("closing multipart writer: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &body) + if err != nil { + return UploadResult{}, fmt.Errorf("creating upload request: %w", err) + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return UploadResult{}, fmt.Errorf("uploading to gitonce: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return UploadResult{}, fmt.Errorf("reading response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return UploadResult{}, fmt.Errorf("gitonce upload failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var uploadResp uploadResponse + if err := json.Unmarshal(respBody, &uploadResp); err != nil { + return UploadResult{}, fmt.Errorf("decoding gitonce response: %w", err) + } + + if uploadResp.URL == "" { + return UploadResult{}, fmt.Errorf("gitonce returned empty URL") + } + + return UploadResult{URL: uploadResp.URL, Commit: uploadResp.Commit}, nil +} + +func zipDirectory(dir string) (*bytes.Buffer, error) { + files, err := filesToZip(dir) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + zw := zip.NewWriter(buf) + + for _, rel := range files { + f, err := zw.Create(rel) + if err != nil { + return nil, err + } + src, err := os.Open(filepath.Join(dir, rel)) + if err != nil { + return nil, err + } + _, cpErr := io.Copy(f, src) + src.Close() + if cpErr != nil { + return nil, cpErr + } + } + + if err := zw.Close(); err != nil { + return nil, err + } + + return buf, nil +} + +// filesToZip returns the list of relative file paths to include in the zip. +// If dir is a git repository, only tracked files are included. +// Otherwise, all files are included while respecting .gitignore if present. +func filesToZip(dir string) ([]string, error) { + if isGitRepo(dir) { + return gitTrackedFiles(dir) + } + return walkWithGitignore(dir) +} + +// isGitRepo reports whether dir is inside a git repository. +func isGitRepo(dir string) bool { + _, err := os.Stat(filepath.Join(dir, ".git")) + return err == nil +} + +// gitTrackedFiles returns relative paths of files tracked by git in dir. +func gitTrackedFiles(dir string) ([]string, error) { + cmd := exec.Command("git", "ls-files") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("running git ls-files: %w", err) + } + var files []string + for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { + if line != "" { + files = append(files, line) + } + } + return files, nil +} + +// walkWithGitignore walks dir and returns relative file paths, excluding files +// matched by .gitignore patterns if a .gitignore file is present at dir root. +func walkWithGitignore(dir string) ([]string, error) { + patterns, err := loadGitignorePatterns(filepath.Join(dir, ".gitignore")) + if err != nil { + return nil, err + } + + var files []string + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + if info.IsDir() { + if rel == "." { + return nil + } + if matchesGitignore(patterns, rel, true) { + return filepath.SkipDir + } + return nil + } + if !matchesGitignore(patterns, rel, false) { + files = append(files, rel) + } + return nil + }) + return files, err +} + +// gitignorePattern holds a parsed .gitignore rule. +type gitignorePattern struct { + pattern string + negate bool + dirOnly bool + anchored bool // pattern contains a slash (other than trailing) +} + +func loadGitignorePatterns(path string) ([]gitignorePattern, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + + var patterns []gitignorePattern + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + p := gitignorePattern{} + if strings.HasPrefix(line, "!") { + p.negate = true + line = line[1:] + } + if strings.HasSuffix(line, "/") { + p.dirOnly = true + line = strings.TrimSuffix(line, "/") + } + // A pattern is anchored if it contains a slash after stripping a + // possible leading slash. + trimmed := strings.TrimPrefix(line, "/") + p.anchored = strings.Contains(trimmed, "/") + p.pattern = trimmed + patterns = append(patterns, p) + } + return patterns, sc.Err() +} + +// matchesGitignore reports whether rel (a slash-separated relative path) is +// matched (i.e. should be ignored) by the given patterns. +func matchesGitignore(patterns []gitignorePattern, rel string, isDir bool) bool { + rel = filepath.ToSlash(rel) + ignored := false + for _, p := range patterns { + if p.dirOnly && !isDir { + continue + } + var matched bool + if p.anchored { + matched, _ = filepath.Match(p.pattern, rel) + } else { + // match against the base name or any path component + base := filepath.Base(rel) + matched, _ = filepath.Match(p.pattern, base) + if !matched { + matched, _ = filepath.Match(p.pattern, rel) + } + } + if matched { + ignored = !p.negate + } + } + return ignored +} \ No newline at end of file diff --git a/api/gitonce/client_test.go b/api/gitonce/client_test.go new file mode 100644 index 0000000..23f09b1 --- /dev/null +++ b/api/gitonce/client_test.go @@ -0,0 +1,262 @@ +package gitonce + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" +) + +// initGitRepo initialises a minimal git repo in dir, adds the given files, and +// commits them so that git ls-files returns them. +func initGitRepo(t *testing.T, dir string, tracked []string) { + t.Helper() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + run("init") + run("config", "user.email", "test@example.com") + run("config", "user.name", "Test") + for _, f := range tracked { + run("add", f) + } + run("commit", "-m", "init") +} + +func zipNames(t *testing.T, buf *bytes.Buffer) map[string]string { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + require.NoError(t, err) + m := make(map[string]string, len(zr.File)) + for _, f := range zr.File { + rc, err := f.Open() + require.NoError(t, err) + content, err := io.ReadAll(rc) + rc.Close() + require.NoError(t, err) + m[f.Name] = string(content) + } + return m +} + +// TestFilesToZipGitRepo verifies that only git-tracked files are included when +// the directory is a git repository. +func TestFilesToZipGitRepo(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sub", "helper.go"), []byte("package sub"), 0600)) + // untracked file – must not appear in the zip + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.go"), []byte("package untracked"), 0600)) + + initGitRepo(t, dir, []string{"main.go", filepath.Join("sub", "helper.go")}) + + buf, err := zipDirectory(dir) + require.NoError(t, err) + + names := zipNames(t, buf) + + require.Equal(t, "package main", names["main.go"]) + require.Equal(t, "package sub", names[filepath.Join("sub", "helper.go")]) + require.NotContains(t, names, "untracked.go", "untracked files must be excluded") + for name := range names { + require.NotContains(t, name, ".git", ".git internals must be excluded") + } +} + +// TestFilesToZipGitignore verifies that .gitignore patterns are respected when +// the directory is not a git repository. +func TestFilesToZipGitignore(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + gitignore := "*.log\nbuild/\n# comment\n!important.log\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(gitignore), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "debug.log"), []byte("log data"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "important.log"), []byte("important"), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "build"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "output"), []byte("binary"), 0600)) + + buf, err := zipDirectory(dir) + require.NoError(t, err) + + names := zipNames(t, buf) + + require.Contains(t, names, "main.go") + require.Contains(t, names, ".gitignore") + require.Contains(t, names, "important.log", "negated pattern must be included") + require.NotContains(t, names, "debug.log", "*.log must be excluded") + require.NotContains(t, names, filepath.Join("build", "output"), "build/ directory must be excluded") +} + +// TestFilesToZipPlain verifies that all files are included when there is +// neither a .git directory nor a .gitignore. +func TestFilesToZipPlain(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "b"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "b", "file.txt"), []byte("hello"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "root.txt"), []byte("root"), 0600)) + + buf, err := zipDirectory(dir) + require.NoError(t, err) + + names := zipNames(t, buf) + var got []string + for n := range names { + got = append(got, n) + } + sort.Strings(got) + require.Equal(t, []string{filepath.Join("a", "b", "file.txt"), "root.txt"}, got) +} + +func TestZipDirectoryContentsAreRelative(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "b"), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "b", "file.txt"), []byte("hello"), 0600)) + + buf, err := zipDirectory(dir) + require.NoError(t, err) + + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + require.NoError(t, err) + + require.Len(t, zr.File, 1) + // path must be relative, not contain the temp dir prefix + require.Equal(t, filepath.Join("a", "b", "file.txt"), zr.File[0].Name) +} + +func TestUploadDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.go"), []byte("package app"), 0600)) + + t.Run("successful upload returns git URL", func(t *testing.T) { + t.Parallel() + + expectedURL := "https://gitonce.example.com/gitonce/1234-abcd.git" + expectedCommit := "b208780317e1726aa6024368fa16f3a74ff5299d" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + f, _, err := r.FormFile("zipfile") + if err != nil { + http.Error(w, "missing zipfile", http.StatusBadRequest) + return + } + defer f.Close() + + // verify the uploaded file is a valid zip + data, err := io.ReadAll(f) + require.NoError(t, err) + _, err = zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(uploadResponse{ + Message: "upload successful", + URL: expectedURL, + Commit: expectedCommit, + }) + })) + defer srv.Close() + + result, err := UploadDirectory(context.Background(), dir, srv.URL) + require.NoError(t, err) + require.Equal(t, expectedURL, result.URL) + require.Equal(t, expectedCommit, result.Commit) + }) + + t.Run("non-200 status returns error", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal server error", http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := UploadDirectory(context.Background(), dir, srv.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "500") + }) + + t.Run("empty URL in response returns error", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(uploadResponse{Message: "upload successful", URL: ""}) + })) + defer srv.Close() + + _, err := UploadDirectory(context.Background(), dir, srv.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "empty URL") + }) + + t.Run("invalid JSON response returns error", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("not json")) + })) + defer srv.Close() + + _, err := UploadDirectory(context.Background(), dir, srv.URL) + require.Error(t, err) + }) + + t.Run("non-existent directory returns error", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + _, err := UploadDirectory(context.Background(), "/nonexistent/path/to/dir", srv.URL) + require.Error(t, err) + }) + + t.Run("context cancellation is respected", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := UploadDirectory(ctx, dir, srv.URL) + require.Error(t, err) + }) +} \ No newline at end of file diff --git a/create/application.go b/create/application.go index 6e6c7ad..a29d703 100644 --- a/create/application.go +++ b/create/application.go @@ -16,6 +16,7 @@ import ( meta "github.com/ninech/apis/meta/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/gitinfo" + "github.com/ninech/nctl/api/gitonce" "github.com/ninech/nctl/api/log" "github.com/ninech/nctl/internal/application" "github.com/ninech/nctl/internal/cli" @@ -37,6 +38,7 @@ const logPrintTimeout = 10 * time.Second type applicationCmd struct { resourceCmd Git gitConfig `embed:"" prefix:"git-"` + FromLocalDir *string `help:"Path to a local directory to upload and deploy. The directory is zipped and uploaded to a one-time-use git repository. Mutually exclusive with --git-url." xor:"git-source" name:"from-local-dir" placeholder:"."` Size *string `help:"Size of the application (defaults to \"${app_default_size}\")." placeholder:"${app_default_size}"` Port *int32 `help:"Port the application is listening on (defaults to ${app_default_port})." placeholder:"${app_default_port}"` HealthProbe healthProbe `embed:"" prefix:"health-probe-"` @@ -51,6 +53,7 @@ type applicationCmd struct { WorkerJob workerJob `embed:"" prefix:"worker-job-"` ScheduledJob scheduledJob `embed:"" prefix:"scheduled-job-"` GitInformationServiceURL string `help:"URL of the git information service." default:"https://git-info.deplo.io" env:"GIT_INFORMATION_SERVICE_URL" hidden:""` + GitOnceURL string `help:"URL of the gitonce upload service." default:"${gitonce_default_url}" env:"GITONCE_URL" hidden:""` SkipRepoAccessCheck bool `help:"Skip the git repository access check." default:"false"` Debug bool `help:"Enable debug messages." default:"false"` Language string `help:"${app_language_help} Possible values: ${enum}" enum:"ruby,php,python,golang,nodejs,static," default:""` @@ -58,7 +61,7 @@ type applicationCmd struct { } type gitConfig struct { - URL string `required:"" help:"URL to the Git repository containing the application source. Both HTTPS and SSH formats are supported."` + URL string `help:"URL to the Git repository containing the application source. Both HTTPS and SSH formats are supported." xor:"git-source"` SubPath string `help:"SubPath is a path in the git repository which contains the application code. If not given, the root directory of the git repository will be used."` Revision string `default:"main" help:"Revision defines the revision of the source to deploy the application to. This can be a commit, tag or branch."` Username *string `help:"Username to use when authenticating to the git repository over HTTPS." env:"GIT_USERNAME"` @@ -126,6 +129,23 @@ const ( ) func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { + if cmd.Git.URL == "" && cmd.FromLocalDir == nil { + return fmt.Errorf("one of --git-url or --from-local-dir is required") + } + + if cmd.FromLocalDir != nil { + cmd.Successf("📦", "uploading local directory %q to gitonce", *cmd.FromLocalDir) + upload, err := gitonce.UploadDirectory(ctx, *cmd.FromLocalDir, cmd.GitOnceURL) + if err != nil { + return fmt.Errorf("uploading local directory: %w", err) + } + cmd.Git.URL = upload.URL + cmd.Git.Revision = upload.Commit + // skip the repo access check for one-time-use gitonce URLs to avoid + // consuming the single allowed clone before the actual deployment + cmd.SkipRepoAccessCheck = true + } + newApp := cmd.newApplication(client.Project) sshPrivateKey, err := cmd.Git.sshPrivateKey() @@ -730,5 +750,6 @@ func ApplicationKongVars() (kong.Vars, error) { result["app_dockerfile_path_help"] = "Specifies the path to the Dockerfile. If left empty a file " + "named Dockerfile will be searched in the application code root directory." result["app_dockerfile_build_context_help"] = "Defines the build context. If left empty, the application code root directory will be used as build context." + result["gitonce_default_url"] = gitonce.DefaultUploadURL return result, nil } diff --git a/create/application_test.go b/create/application_test.go index 915ced3..e988801 100644 --- a/create/application_test.go +++ b/create/application_test.go @@ -67,6 +67,15 @@ func TestCreateApplication(t *testing.T) { gitInfoService.Start() defer gitInfoService.Close() + gitOnceService := test.NewGitOnceService() + gitOnceService.Start() + defer gitOnceService.Close() + + localDir := t.TempDir() + if err := os.WriteFile(localDir+"/main.go", []byte("package main"), 0600); err != nil { + t.Fatal(err) + } + cases := map[string]struct { cmd applicationCmd checkApp func(t *testing.T, cmd applicationCmd, app *apps.Application) @@ -123,6 +132,7 @@ func TestCreateApplication(t *testing.T) { Wait: false, Name: "basic-auth", }, + Git: gitConfig{URL: "https://github.com/ninech/doesnotexist.git"}, Size: new("mini"), BasicAuth: new(true), SkipRepoAccessCheck: true, @@ -477,6 +487,41 @@ func TestCreateApplication(t *testing.T) { is.Equal("banana", buildEnv.Value) }, }, + "from-local-dir uploads zip and sets git URL": { + cmd: applicationCmd{ + resourceCmd: resourceCmd{ + Wait: false, + Name: "local-dir-app", + }, + FromLocalDir: &localDir, + }, + checkApp: func(t *testing.T, cmd applicationCmd, app *apps.Application) { + is := require.New(t) + is.Equal("https://gitonce.example.com/gitonce/test-repo.git", app.Spec.ForProvider.Git.URL) + // repo access check must have been skipped (SkipRepoAccessCheck set automatically) + is.True(cmd.SkipRepoAccessCheck) + }, + }, + "from-local-dir with non-existent directory returns error": { + cmd: applicationCmd{ + resourceCmd: resourceCmd{ + Wait: false, + Name: "local-dir-bad", + }, + FromLocalDir: new("/nonexistent/path"), + }, + errorExpected: true, + }, + "neither git-url nor from-local-dir returns error": { + cmd: applicationCmd{ + resourceCmd: resourceCmd{ + Wait: false, + Name: "no-source", + }, + SkipRepoAccessCheck: true, + }, + errorExpected: true, + }, } for name, tc := range cases { @@ -486,6 +531,9 @@ func TestCreateApplication(t *testing.T) { if tc.cmd.GitInformationServiceURL == "" { tc.cmd.GitInformationServiceURL = gitInfoService.URL() } + if tc.cmd.GitOnceURL == "" { + tc.cmd.GitOnceURL = gitOnceService.URL() + } gitInfoService.SetResponse(tc.gitInformationServiceResponse) app := tc.cmd.newApplication("default") @@ -513,6 +561,7 @@ func TestApplicationWait(t *testing.T) { WaitTimeout: time.Second * 5, Name: "some-name", }, + Git: gitConfig{URL: "https://github.com/ninech/doesnotexist.git"}, BasicAuth: new(true), SkipRepoAccessCheck: true, } @@ -663,6 +712,7 @@ func TestApplicationBuildFail(t *testing.T) { WaitTimeout: time.Second * 5, Name: "some-name", }, + Git: gitConfig{URL: "https://github.com/ninech/doesnotexist.git"}, SkipRepoAccessCheck: true, } project := test.DefaultProject diff --git a/internal/test/gitonce_service.go b/internal/test/gitonce_service.go new file mode 100644 index 0000000..167d3a1 --- /dev/null +++ b/internal/test/gitonce_service.go @@ -0,0 +1,104 @@ +package test + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "sync" +) + +// GitOnceUploadResponse is the response returned by the gitonce upload service. +type GitOnceUploadResponse struct { + Message string `json:"message"` + URL string `json:"url"` + Commit string `json:"commit"` +} + +type gitOnceService struct { + sync.Mutex + server *httptest.Server + logger *slog.Logger + response GitOnceUploadResponse + // receivedZip holds the raw bytes of the last uploaded zip file. + receivedZip []byte +} + +// NewGitOnceService returns a mock gitonce upload service for use in tests. +func NewGitOnceService() *gitOnceService { + g := &gitOnceService{ + logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), + response: GitOnceUploadResponse{ + Message: "upload successful", + URL: "https://gitonce.example.com/gitonce/test-repo.git", + }, + } + mux := http.NewServeMux() + mux.Handle("/upload", g) + g.server = httptest.NewUnstartedServer(mux) + return g +} + +// SetResponse sets the response returned by the mock service. +func (g *gitOnceService) SetResponse(r GitOnceUploadResponse) { + g.Lock() + defer g.Unlock() + g.response = r +} + +// ReceivedZip returns the raw bytes of the last uploaded zip. +func (g *gitOnceService) ReceivedZip() []byte { + g.Lock() + defer g.Unlock() + return g.receivedZip +} + +func (g *gitOnceService) Start() { + g.server.Start() +} + +func (g *gitOnceService) Close() { + if g.server != nil { + g.server.Close() + } +} + +// URL returns the base URL of the mock server (without /upload path). +func (g *gitOnceService) URL() string { + return g.server.URL + "/upload" +} + +func (g *gitOnceService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, "bad multipart request", http.StatusBadRequest) + return + } + + file, _, err := r.FormFile("zipfile") + if err != nil { + http.Error(w, "missing zipfile field", http.StatusBadRequest) + return + } + defer file.Close() + + data := make([]byte, 0, 1024) + buf := make([]byte, 4096) + for { + n, err := file.Read(buf) + data = append(data, buf[:n]...) + if err != nil { + break + } + } + + g.Lock() + g.receivedZip = data + resp := g.response + g.Unlock() + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + g.logger.Error("error encoding response", "error", err.Error()) + } +} \ No newline at end of file diff --git a/update/application.go b/update/application.go index ffb8d58..121f1fa 100644 --- a/update/application.go +++ b/update/application.go @@ -11,6 +11,7 @@ import ( apps "github.com/ninech/apis/apps/v1alpha1" "github.com/ninech/nctl/api" "github.com/ninech/nctl/api/gitinfo" + "github.com/ninech/nctl/api/gitonce" "github.com/ninech/nctl/internal/application" "github.com/ninech/nctl/internal/format" "k8s.io/apimachinery/pkg/api/errors" @@ -28,6 +29,7 @@ const BuildTrigger = "BUILD_TRIGGER" type applicationCmd struct { resourceCmd Git *gitConfig `embed:"" prefix:"git-"` + FromLocalDir *string `help:"Path to a local directory to upload and deploy. The directory is zipped and uploaded to a one-time-use git repository. Sets --git-url to the returned URL." name:"from-local-dir" placeholder:"."` Size *string `help:"Size of the app."` Port *int32 `help:"Port the app is listening on."` HealthProbe *healthProbe `embed:"" prefix:"health-probe-"` @@ -57,6 +59,7 @@ type applicationCmd struct { RetryBuild *bool `help:"Retries build for the application if set to true." placeholder:"false"` Pause *bool `help:"Pauses the application if set to true. Stops all costs." placeholder:"false"` GitInformationServiceURL string `help:"URL of the git information service." default:"https://git-info.deplo.io" env:"GIT_INFORMATION_SERVICE_URL" hidden:""` + GitOnceURL string `help:"URL of the gitonce upload service." default:"${gitonce_default_url}" env:"GITONCE_URL" hidden:""` SkipRepoAccessCheck bool `help:"Skip the git repository access check." default:"false"` Debug bool `help:"Enable debug messages." default:"false"` Language *string `help:"${app_language_help} Possible values: ${enum}" enum:"ruby,php,python,golang,nodejs,static,"` @@ -136,6 +139,22 @@ type dockerfileBuild struct { } func (cmd *applicationCmd) Run(ctx context.Context, client *api.Client) error { + if cmd.FromLocalDir != nil { + cmd.Successf("📦", "uploading local directory %q to gitonce", *cmd.FromLocalDir) + upload, err := gitonce.UploadDirectory(ctx, *cmd.FromLocalDir, cmd.GitOnceURL) + if err != nil { + return fmt.Errorf("uploading local directory: %w", err) + } + if cmd.Git == nil { + cmd.Git = &gitConfig{} + } + cmd.Git.URL = &upload.URL + cmd.Git.Revision = &upload.Commit + // skip the repo access check for one-time-use gitonce URLs to avoid + // consuming the single allowed clone before the actual deployment + cmd.SkipRepoAccessCheck = true + } + app := &apps.Application{ ObjectMeta: metav1.ObjectMeta{ Name: cmd.Name, diff --git a/update/application_test.go b/update/application_test.go index a2c83a2..8107064 100644 --- a/update/application_test.go +++ b/update/application_test.go @@ -2,6 +2,7 @@ package update import ( "io" + "os" "strings" "testing" "time" @@ -34,6 +35,15 @@ func TestApplication(t *testing.T) { gitInfoService.Start() defer gitInfoService.Close() + gitOnceService := test.NewGitOnceService() + gitOnceService.Start() + defer gitOnceService.Close() + + localDir := t.TempDir() + if err := os.WriteFile(localDir+"/main.go", []byte("package main"), 0600); err != nil { + t.Fatal(err) + } + existingApp := &apps.Application{ ObjectMeta: metav1.ObjectMeta{ Name: "some-name", @@ -607,6 +617,30 @@ func TestApplication(t *testing.T) { is.Equal("main", updated.Spec.ForProvider.Git.Revision) }, }, + "from-local-dir uploads zip and updates git URL": { + orig: existingApp, + cmd: applicationCmd{ + resourceCmd: resourceCmd{ + Name: existingApp.Name, + }, + FromLocalDir: &localDir, + }, + checkApp: func(t *testing.T, cmd applicationCmd, orig, updated *apps.Application) { + is := require.New(t) + is.Equal("https://gitonce.example.com/gitonce/test-repo.git", updated.Spec.ForProvider.Git.URL) + is.True(cmd.SkipRepoAccessCheck) + }, + }, + "from-local-dir with non-existent directory returns error": { + orig: existingApp, + cmd: applicationCmd{ + resourceCmd: resourceCmd{ + Name: existingApp.Name, + }, + FromLocalDir: new("/nonexistent/path/to/dir"), + }, + errorExpected: true, + }, } for name, tc := range cases { @@ -616,6 +650,9 @@ func TestApplication(t *testing.T) { if tc.cmd.GitInformationServiceURL == "" { tc.cmd.GitInformationServiceURL = gitInfoService.URL() } + if tc.cmd.GitOnceURL == "" { + tc.cmd.GitOnceURL = gitOnceService.URL() + } gitInfoService.SetResponse(tc.gitInformationServiceResponse) objects := []client.Object{tc.orig}