Skip to content

Commit

Permalink
POD-779 | Initial integration of devpod-crane (loft-sh#1230)
Browse files Browse the repository at this point in the history
* Add config-source flag to workspace up command

* Init crane client implementation

* Switch to switch for detecting client type

* Init usage of crane

* Move signing management fully to crane

* Simplify usage of crane

* Factor out reading raw config in workspace run implementation

* Move devcontainer config methods to a separate file

* Add docstrings in crane package
  • Loading branch information
janekbaraniewski authored Aug 22, 2024
1 parent fbffe09 commit 6812c57
Show file tree
Hide file tree
Showing 7 changed files with 283 additions and 154 deletions.
14 changes: 9 additions & 5 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ func NewUpCmd(flags *flags.GlobalFlags) *cobra.Command {
upCmd.Flags().StringArrayVar(&cmd.IDEOptions, "ide-option", []string{}, "IDE option in the form KEY=VALUE")
upCmd.Flags().StringVar(&cmd.DevContainerImage, "devcontainer-image", "", "The container image to use, this will override the devcontainer.json value in the project")
upCmd.Flags().StringVar(&cmd.DevContainerPath, "devcontainer-path", "", "The path to the devcontainer.json relative to the project")
upCmd.Flags().StringVar(&cmd.DevContainerSource, "devcontainer-source", "", "External devcontainer.json source")
upCmd.Flags().StringArrayVar(&cmd.ProviderOptions, "provider-option", []string{}, "Provider option in the form KEY=VALUE")
upCmd.Flags().BoolVar(&cmd.Recreate, "recreate", false, "If true will remove any existing containers and recreate them")
upCmd.Flags().BoolVar(&cmd.Reset, "reset", false, "If true will remove any existing containers including sources, and recreate them")
Expand Down Expand Up @@ -342,17 +343,19 @@ func (cmd *UpCmd) devPodUp(
// get result
var result *config2.Result

// check what client we have
if workspaceClient, ok := client.(client2.WorkspaceClient); ok {
result, err = cmd.devPodUpMachine(ctx, devPodConfig, workspaceClient, log)
switch client := client.(type) {
case client2.WorkspaceClient:
result, err = cmd.devPodUpMachine(ctx, devPodConfig, client, log)
if err != nil {
return nil, err
}
} else if proxyClient, ok := client.(client2.ProxyClient); ok {
result, err = cmd.devPodUpProxy(ctx, proxyClient, log)
case client2.ProxyClient:
result, err = cmd.devPodUpProxy(ctx, client, log)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported client type: %T", client)
}

// save result to file
Expand Down Expand Up @@ -473,6 +476,7 @@ func (cmd *UpCmd) devPodUpMachine(
client.AgentPath(),
workspaceInfo,
)

if log.GetLevel() == logrus.DebugLevel {
agentCommand += " --debug"
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/devcontainer/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func (r *runner) stopDockerCompose(ctx context.Context, projectName string) erro
return errors.Wrap(err, "find docker compose")
}

parsedConfig, _, err := r.prepare(r.WorkspaceConfig.CLIOptions)
parsedConfig, _, err := r.getSubstitutedConfig(r.WorkspaceConfig.CLIOptions)
if err != nil {
return errors.Wrap(err, "get parsed config")
}
Expand All @@ -69,7 +69,7 @@ func (r *runner) deleteDockerCompose(ctx context.Context, projectName string) er
return errors.Wrap(err, "find docker compose")
}

parsedConfig, _, err := r.prepare(r.WorkspaceConfig.CLIOptions)
parsedConfig, _, err := r.getSubstitutedConfig(r.WorkspaceConfig.CLIOptions)
if err != nil {
return errors.Wrap(err, "get parsed config")
}
Expand Down
147 changes: 147 additions & 0 deletions pkg/devcontainer/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package devcontainer

import (
"os"
"path"
"path/filepath"

"github.com/loft-sh/devpod/pkg/devcontainer/config"
"github.com/loft-sh/devpod/pkg/devcontainer/crane"
"github.com/loft-sh/devpod/pkg/language"
provider2 "github.com/loft-sh/devpod/pkg/provider"
"github.com/pkg/errors"
)

func (r *runner) getRawConfig(options provider2.CLIOptions) (*config.DevContainerConfig, error) {
if r.WorkspaceConfig.Workspace.DevContainerConfig != nil {
rawParsedConfig := config.CloneDevContainerConfig(r.WorkspaceConfig.Workspace.DevContainerConfig)
if r.WorkspaceConfig.Workspace.DevContainerPath != "" {
rawParsedConfig.Origin = path.Join(filepath.ToSlash(r.LocalWorkspaceFolder), r.WorkspaceConfig.Workspace.DevContainerPath)
} else {
rawParsedConfig.Origin = path.Join(filepath.ToSlash(r.LocalWorkspaceFolder), ".devcontainer.devpod.json")
}
return rawParsedConfig, nil
} else if r.WorkspaceConfig.Workspace.Source.Container != "" {
return &config.DevContainerConfig{
DevContainerConfigBase: config.DevContainerConfigBase{
// Default workspace directory for containers
// Upon inspecting the container, this would be updated to the correct folder, if found set
WorkspaceFolder: "/",
},
RunningContainer: config.RunningContainer{
ContainerID: r.WorkspaceConfig.Workspace.Source.Container,
},
Origin: "",
}, nil
} else if options.DevContainerSource != "" && crane.IsAvailable() {
localWorkspaceFolder, err := crane.PullConfigFromSource(options.DevContainerSource, r.Log)
if err != nil {
return nil, err
}

return config.ParseDevContainerJSON(
localWorkspaceFolder,
r.WorkspaceConfig.Workspace.DevContainerPath,
)
}

localWorkspaceFolder := r.LocalWorkspaceFolder
// if a subpath is specified, let's move to it

if r.WorkspaceConfig.Workspace.Source.GitSubPath != "" {
localWorkspaceFolder = filepath.Join(r.LocalWorkspaceFolder, r.WorkspaceConfig.Workspace.Source.GitSubPath)
}

// parse the devcontainer json
rawParsedConfig, err := config.ParseDevContainerJSON(
localWorkspaceFolder,
r.WorkspaceConfig.Workspace.DevContainerPath,
)

// We want to fail only in case of real errors, non-existing devcontainer.jon
// will be gracefully handled by the auto-detection mechanism
if err != nil && !os.IsNotExist(err) {
return nil, errors.Wrap(err, "parsing devcontainer.json")
} else if rawParsedConfig == nil {
r.Log.Infof("Couldn't find a devcontainer.json")
return r.getDefaultConfig(options)
}
return rawParsedConfig, nil
}

func (r *runner) getDefaultConfig(options provider2.CLIOptions) (*config.DevContainerConfig, error) {
defaultConfig := &config.DevContainerConfig{}
if options.FallbackImage != "" {
r.Log.Infof("Using fallback image %s", options.FallbackImage)
defaultConfig.ImageContainer = config.ImageContainer{
Image: options.FallbackImage,
}
} else {
r.Log.Infof("Try detecting project programming language...")
defaultConfig = language.DefaultConfig(r.LocalWorkspaceFolder, r.Log)
}

defaultConfig.Origin = path.Join(filepath.ToSlash(r.LocalWorkspaceFolder), ".devcontainer.json")
err := config.SaveDevContainerJSON(defaultConfig)
if err != nil {
return nil, errors.Wrap(err, "write default devcontainer.json")
}
return defaultConfig, nil
}

func (r *runner) getSubstitutedConfig(options provider2.CLIOptions) (*config.SubstitutedConfig, *config.SubstitutionContext, error) {
rawConfig, err := r.getRawConfig(options)
if err != nil {
return nil, nil, err
}

return r.substitute(options, rawConfig)
}

func (r *runner) substitute(
options provider2.CLIOptions,
rawParsedConfig *config.DevContainerConfig,
) (*config.SubstitutedConfig, *config.SubstitutionContext, error) {
configFile := rawParsedConfig.Origin

// get workspace folder within container
workspaceMount, containerWorkspaceFolder := getWorkspace(
r.LocalWorkspaceFolder,
r.WorkspaceConfig.Workspace.ID,
rawParsedConfig,
)
substitutionContext := &config.SubstitutionContext{
DevContainerID: r.ID,
LocalWorkspaceFolder: r.LocalWorkspaceFolder,
ContainerWorkspaceFolder: containerWorkspaceFolder,
Env: config.ListToObject(os.Environ()),

WorkspaceMount: workspaceMount,
}

// substitute & load
parsedConfig := &config.DevContainerConfig{}
err := config.Substitute(substitutionContext, rawParsedConfig, parsedConfig)
if err != nil {
return nil, nil, err
}
if parsedConfig.WorkspaceFolder != "" {
substitutionContext.ContainerWorkspaceFolder = parsedConfig.WorkspaceFolder
}
if parsedConfig.WorkspaceMount != "" {
substitutionContext.WorkspaceMount = parsedConfig.WorkspaceMount
}

if options.DevContainerImage != "" {
parsedConfig.Build = nil
parsedConfig.Dockerfile = ""
parsedConfig.DockerfileContainer = config.DockerfileContainer{}
parsedConfig.ImageContainer = config.ImageContainer{Image: options.DevContainerImage}
}

parsedConfig.Origin = configFile
return &config.SubstitutedConfig{
Config: parsedConfig,
Raw: rawParsedConfig,
}, substitutionContext, nil
}
97 changes: 97 additions & 0 deletions pkg/devcontainer/crane/run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package crane

import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"

"github.com/loft-sh/log"
)

var (
craneSigningKey string
)

const (
PullCommand = "pull"
DecryptCommand = "decrypt"

GitCrane = "git"

BinPath = "devpod-crane" // FIXME

tmpDirTemplate = "devpod-crane-*"
)

type Content struct {
Files map[string]string `json:"files"`
}

// IsAvailable checks if devpod crane is installed in host system
func IsAvailable() bool {
_, err := exec.LookPath(BinPath)
return err == nil
}

func runCommand(command string, args ...string) (string, error) {
cmd := exec.Command(BinPath, append([]string{command}, args...)...)

var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf

if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to execute command: %v, error: %w", errBuf.String(), err)
}

return outBuf.String(), nil
}

// PullConfigFromSource pulls devcontainer config from configSource using git crane and returns config path
func PullConfigFromSource(configSource string, log log.Logger) (string, error) {
data, err := runCommand(PullCommand, GitCrane, configSource)
if err != nil {
return "", err
}

if craneSigningKey != "" {
data, err = runCommand(DecryptCommand, data, "--key", craneSigningKey)
if err != nil {
return "", err
}
}

content := &Content{}
if err := json.Unmarshal([]byte(data), content); err != nil {
return "", err
}

return createContentDirectory(content)
}

func createContentDirectory(content *Content) (string, error) {
tmpDir, err := os.MkdirTemp("", tmpDirTemplate)
if err != nil {
return "", fmt.Errorf("failed to create temporary directory: %w", err)
}

for filename, fileContent := range content.Files {
filePath := filepath.Join(tmpDir, filename)

dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return "", err
}

err := os.WriteFile(filePath, []byte(fileContent), os.ModePerm)
if err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("failed to write file %s: %w", filename, err)
}
}

return tmpDir, nil
}
2 changes: 1 addition & 1 deletion pkg/devcontainer/prebuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func (r *runner) Build(ctx context.Context, options provider.BuildOptions) (stri
return "", fmt.Errorf("building only supported with docker driver")
}

substitutedConfig, substitutionContext, err := r.prepare(options.CLIOptions)
substitutedConfig, substitutionContext, err := r.getSubstitutedConfig(options.CLIOptions)
if err != nil {
return "", err
}
Expand Down
Loading

0 comments on commit 6812c57

Please sign in to comment.