diff --git a/pkg/apis/internal.acorn.io/v1/appimage.go b/pkg/apis/internal.acorn.io/v1/appimage.go index 738968223..f3885318f 100644 --- a/pkg/apis/internal.acorn.io/v1/appimage.go +++ b/pkg/apis/internal.acorn.io/v1/appimage.go @@ -29,8 +29,10 @@ type VCS struct { Modified bool `json:"modified,omitempty"` // Untracked a true value indicates the build contained untracked files according to git Untracked bool `json:"untracked,omitempty"` - // Acornfile contains the path and filename within the git repository that was used to build the running app + // Acornfile the path and filename within the vcs repository that was used to build the running app Acornfile string `json:"acornfile,omitempty"` + // BuildContext the context provided + BuildContext string `json:"buildContext,omitempty"` } type Platform struct { diff --git a/pkg/cli/dev.go b/pkg/cli/dev.go index 1afdf1fed..183f45e4c 100644 --- a/pkg/cli/dev.go +++ b/pkg/cli/dev.go @@ -1,6 +1,7 @@ package cli import ( + "fmt" "io" "github.com/acorn-io/runtime/pkg/autoupgrade" @@ -43,10 +44,10 @@ acorn dev --name wandering-sound type Dev struct { RunArgs - BidirectionalSync bool `usage:"In interactive mode download changes in addition to uploading" short:"b"` - Replace bool `usage:"Replace the app with only defined values, resetting undefined fields to default values" json:"replace,omitempty"` // Replace sets patchMode to false, resulting in a full update, resetting all undefined fields to their defaults - Clone string `usage:"Clone a running app"` - HelpAdvanced bool `usage:"Show verbose help text"` + BidirectionalSync bool `usage:"In interactive mode download changes in addition to uploading" short:"b"` + Replace bool `usage:"Replace the app with only defined values, resetting undefined fields to default values" json:"replace,omitempty"` // Replace sets patchMode to false, resulting in a full update, resetting all undefined fields to their defaults + Clone bool `usage:"Clone the vcs repository for the given app"` + HelpAdvanced bool `usage:"Show verbose help text"` out io.Writer client ClientFactory } @@ -62,20 +63,26 @@ func (s *Dev) Run(cmd *cobra.Command, args []string) error { } var imageSource imagesource.ImageSource - if s.Clone == "" { + if !s.Clone { imageSource = imagesource.NewImageSource(s.client.AcornConfigFile(), s.File, s.ArgsFile, args, nil, z.Dereference(s.AutoUpgrade)) + } else if s.Name == "" { + return fmt.Errorf("clone option must be used when running dev on an existing app") } else { // Get info from the running app - app, err := c.AppGet(cmd.Context(), s.Clone) + app, err := c.AppGet(cmd.Context(), s.Name) if err != nil { return err } - acornfile, err := vcs.AcornfileFromApp(cmd.Context(), app) + acornfile, buildContext, err := vcs.ImageInfoFromApp(cmd.Context(), app) if err != nil { return err } + bc := app.Status.Staged.AppImage.VCS.BuildContext + if bc != "" { + args = append(args, buildContext) + } imageSource = imagesource.NewImageSource(s.client.AcornConfigFile(), acornfile, s.ArgsFile, args, nil, z.Dereference(s.AutoUpgrade)) } diff --git a/pkg/client/build.go b/pkg/client/build.go index fd84013c6..2d20b0c0e 100644 --- a/pkg/client/build.go +++ b/pkg/client/build.go @@ -69,7 +69,7 @@ func (c *DefaultClient) AcornImageBuild(ctx context.Context, file string, opts * return nil, err } - vcs := vcs.VCS(file) + vcs := vcs.VCS(file, opts.Cwd) builder, err := c.getOrCreateBuilder(ctx, opts.BuilderName) if err != nil { diff --git a/pkg/openapi/generated/openapi_generated.go b/pkg/openapi/generated/openapi_generated.go index 4ac647dd7..e0d8a6280 100644 --- a/pkg/openapi/generated/openapi_generated.go +++ b/pkg/openapi/generated/openapi_generated.go @@ -12332,7 +12332,14 @@ func schema_pkg_apis_internalacornio_v1_VCS(ref common.ReferenceCallback) common }, "acornfile": { SchemaProps: spec.SchemaProps{ - Description: "Acornfile contains the path and filename within the git repository that was used to build the running app", + Description: "Acornfile the path and filename within the vcs repository that was used to build the running app", + Type: []string{"string"}, + Format: "", + }, + }, + "buildContext": { + SchemaProps: spec.SchemaProps{ + Description: "BuildContext the context provided", Type: []string{"string"}, Format: "", }, diff --git a/pkg/vcs/vcs.go b/pkg/vcs/vcs.go index 2cd1203fd..f27501c0c 100644 --- a/pkg/vcs/vcs.go +++ b/pkg/vcs/vcs.go @@ -2,7 +2,6 @@ package vcs import ( "context" - "errors" "fmt" "net/url" "os" @@ -12,14 +11,19 @@ import ( apiv1 "github.com/acorn-io/runtime/pkg/apis/api.acorn.io/v1" v1 "github.com/acorn-io/runtime/pkg/apis/internal.acorn.io/v1" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport/ssh" ) -func VCS(filePath string) (result v1.VCS) { +func VCS(filePath, buildContextPath string) (result v1.VCS) { absPath, err := filepath.Abs(filePath) if err != nil { return } + buildContext, err := filepath.Abs(buildContextPath) + if err != nil { + return + } repo, err := git.PlainOpenWithOptions(filepath.Dir(absPath), &git.PlainOpenOptions{ DetectDotGit: true, }) @@ -43,6 +47,11 @@ func VCS(filePath string) (result v1.VCS) { sb.WriteString(w.Filesystem.Root()) sb.WriteRune(filepath.Separator) acornfile := strings.TrimPrefix(absPath, sb.String()) + if buildContext == w.Filesystem.Root() { + buildContext = "." + } else { + buildContext = strings.TrimPrefix(buildContext, sb.String()) + } var ( modified, untracked bool @@ -59,11 +68,12 @@ func VCS(filePath string) (result v1.VCS) { } result = v1.VCS{ - Revision: head.Hash().String(), - Clean: !modified && !untracked, - Modified: modified, - Untracked: untracked, - Acornfile: acornfile, + Revision: head.Hash().String(), + Clean: !modified && !untracked, + Modified: modified, + Untracked: untracked, + Acornfile: acornfile, + BuildContext: buildContext, } // Set optional remotes field @@ -79,17 +89,19 @@ func VCS(filePath string) (result v1.VCS) { return } -func AcornfileFromApp(ctx context.Context, app *apiv1.App) (string, error) { - +func ImageInfoFromApp(ctx context.Context, app *apiv1.App) (string, string, error) { vcs := app.Status.Staged.AppImage.VCS - if len(vcs.Remotes) == 0 { - return "", fmt.Errorf("clone can only be done on an app built from a git repository") + return "", "", fmt.Errorf("clone can only be done on an app built from a git repository") + } + if vcs.Acornfile == "" { + return "", "", fmt.Errorf("app has no acornfile information in vcs") } + // Create auth object to use when fetching and cloning git repos auth, err := ssh.NewSSHAgentAuth("git") if err != nil { - return "", err + return "", "", err } for _, remote := range vcs.Remotes { @@ -101,42 +113,64 @@ func AcornfileFromApp(ctx context.Context, app *apiv1.App) (string, error) { gitUrl = remote } - // TODO workdir named after git repo, cloned app name, or just this app's name? - idx := strings.LastIndex(gitUrl, "/") - if idx < 0 || idx >= len(gitUrl) { - fmt.Printf("failed to determine repository name %q\n", gitUrl) + idx := strings.LastIndex(remote, "/") + if idx < 0 || idx >= len(remote) { + fmt.Printf("failed to determine repository name %q\n", remote) continue } - workdir := filepath.Clean(strings.TrimSuffix(gitUrl[idx+1:], ".git")) + workdir := filepath.Clean(strings.TrimSuffix(remote[idx+1:], ".git")) - // Clone git repo - _, err = git.PlainCloneContext(ctx, workdir, false, &git.CloneOptions{ + // Clone git repo and checkout revision + fmt.Printf("# Cloning repository %q into directory %q\n", gitUrl, workdir) + repo, err := git.PlainCloneContext(ctx, workdir, false, &git.CloneOptions{ URL: gitUrl, - Progress: os.Stderr, Auth: auth, + Progress: os.Stderr, }) - // TODO handle ErrRepositoryAlreadyExists some way if err != nil { fmt.Printf("failed to clone repository %q: %s\n", gitUrl, err.Error()) continue } + w, err := repo.Worktree() + if err != nil { + fmt.Printf("failed to get worktree from repository %q: %s\n", workdir, err.Error()) + continue + } + err = w.Checkout(&git.CheckoutOptions{ + Hash: plumbing.NewHash(vcs.Revision), + }) + if err != nil { + fmt.Printf("failed to checkout revision %q for repository %q: %s\n", vcs.Revision, workdir, err.Error()) + continue + } + // Create the Acornfile in the repository acornfile := filepath.Join(workdir, vcs.Acornfile) - // TODO if acornfile exists but is different than what is cached should we overwrite? - if _, err := os.Stat(acornfile); errors.Is(err, os.ErrNotExist) { - // Acornfile does not exist so we should create it - err = os.WriteFile(acornfile, []byte(app.Status.Staged.AppImage.Acornfile), 0666) - if err != nil { - fmt.Printf("failed to create file %q in repository %q: %s", acornfile, gitUrl, err.Error()) - // TODO we hit an error state but already cloned the repo, should we clean up the repo we cloned? - continue + err = os.WriteFile(acornfile, []byte(app.Status.Staged.AppImage.Acornfile), 0666) + if err != nil { + fmt.Printf("failed to create file %q in repository %q: %s\n", acornfile, workdir, err.Error()) + continue + } + + // Determine if the Acornfile is dirty or not + s, err := w.Status() + if err == nil { + if !s.IsClean() { + fmt.Printf("running with a dirty Acornfile %q\n", acornfile) } } else { - fmt.Printf("could not check for file %q in repository %q: %s", acornfile, gitUrl, err.Error()) - // TODO we hit an error state but already cloned the repo, should we clean up the repo we cloned? - continue + fmt.Printf("failed to get status from worktree %q: %s\n", workdir, err.Error()) + } + + // Get the build context + var buildContext string + if vcs.BuildContext == "." { + buildContext = workdir + } else { + buildContext = filepath.Join(workdir, vcs.BuildContext) } - return acornfile, nil + + return acornfile, buildContext, nil } - return "", fmt.Errorf("failed to resolve an acornfile from the app") + return "", "", fmt.Errorf("failed to resolve an acornfile from the app") }