package state import ( "bytes" "context" "errors" "fmt" "os" "path" "path/filepath" "strings" "github.com/rs/zerolog/log" "go.dagger.io/dagger/keychain" "go.dagger.io/dagger/mod" "go.dagger.io/dagger/stdlib" "gopkg.in/yaml.v3" ) var ( ErrNotInit = errors.New("not initialized") ErrAlreadyInit = errors.New("already initialized") ErrNotExist = errors.New("environment doesn't exist") ErrExist = errors.New("environment already exists") ) const ( daggerDir = ".dagger" envDir = "env" stateDir = "state" planDir = "plan" manifestFile = "values.yaml" computedFile = "computed.json" universeVersionConstraint = ">= 0.1, < 0.2" ) type Project struct { Path string } func Init(ctx context.Context, dir string) (*Project, error) { root, err := filepath.Abs(dir) if err != nil { return nil, err } daggerRoot := path.Join(root, daggerDir) if err := os.Mkdir(daggerRoot, 0755); err != nil { if errors.Is(err, os.ErrExist) { return nil, ErrAlreadyInit } return nil, err } if err := os.Mkdir(path.Join(daggerRoot, envDir), 0755); err != nil { return nil, err } if err := vendorUniverse(ctx, root); err != nil { return nil, err } return &Project{ Path: root, }, nil } func Open(ctx context.Context, dir string) (*Project, error) { _, err := os.Stat(path.Join(dir, daggerDir)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrNotInit } return nil, err } root, err := filepath.Abs(dir) if err != nil { return nil, err } return &Project{ Path: root, }, nil } func Current(ctx context.Context) (*Project, error) { current, err := os.Getwd() if err != nil { return nil, err } // Walk every parent directory to find .dagger for { _, err := os.Stat(path.Join(current, daggerDir, envDir)) if err == nil { return Open(ctx, current) } parent := filepath.Dir(current) if parent == current { break } current = parent } return nil, ErrNotInit } func (w *Project) envPath(name string) string { return path.Join(w.Path, daggerDir, envDir, name) } func (w *Project) List(ctx context.Context) ([]*State, error) { var ( environments = []*State{} err error ) files, err := os.ReadDir(path.Join(w.Path, daggerDir, envDir)) if err != nil { return nil, err } for _, f := range files { if !f.IsDir() { continue } st, err := w.Get(ctx, f.Name()) if err != nil { // If the environment doesn't exist (e.g. no values.yaml, skip silently) if !errors.Is(err, ErrNotExist) { log. Ctx(ctx). Err(err). Str("name", f.Name()). Msg("failed to load environment") } continue } environments = append(environments, st) } return environments, nil } func (w *Project) Get(ctx context.Context, name string) (*State, error) { envPath, err := filepath.Abs(w.envPath(name)) if err != nil { return nil, err } if _, err := os.Stat(envPath); err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrNotExist } return nil, err } manifest, err := os.ReadFile(path.Join(envPath, manifestFile)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrNotExist } return nil, err } manifest, err = keychain.Decrypt(ctx, manifest) if err != nil { return nil, fmt.Errorf("unable to decrypt state: %w", err) } var st State if err := yaml.Unmarshal(manifest, &st); err != nil { return nil, err } st.Path = envPath // FIXME: Backward compat: Support for old-style `.dagger/env//plan` if st.Plan.Module == "" { planPath := path.Join(envPath, planDir) if _, err := os.Stat(planPath); err == nil { planRelPath, err := filepath.Rel(w.Path, planPath) if err != nil { return nil, err } st.Plan.Module = planRelPath } } st.Project = w.Path computed, err := os.ReadFile(path.Join(envPath, stateDir, computedFile)) if err == nil { st.Computed = string(computed) } return &st, nil } func (w *Project) Save(ctx context.Context, st *State) error { data, err := yaml.Marshal(st) if err != nil { return err } manifestPath := path.Join(st.Path, manifestFile) currentEncrypted, err := os.ReadFile(manifestPath) if err != nil { return err } currentPlain, err := keychain.Decrypt(ctx, currentEncrypted) if err != nil { return fmt.Errorf("unable to decrypt state: %w", err) } // Only update the encrypted file if there were changes if !bytes.Equal(data, currentPlain) { encrypted, err := keychain.Reencrypt(ctx, manifestPath, data) if err != nil { return err } if err := os.WriteFile(manifestPath, encrypted, 0600); err != nil { return err } } if st.Computed != "" { state := path.Join(st.Path, stateDir) if err := os.MkdirAll(state, 0755); err != nil { return err } err := os.WriteFile( path.Join(state, "computed.json"), []byte(st.Computed), 0600) if err != nil { return err } } return nil } func (w *Project) Create(ctx context.Context, name string, plan Plan) (*State, error) { if _, err := w.Get(ctx, name); err == nil { return nil, ErrExist } pkg, err := w.cleanPackageName(ctx, plan.Package) if err != nil { return nil, err } envPath, err := filepath.Abs(w.envPath(name)) if err != nil { return nil, err } // Environment directory if err := os.MkdirAll(envPath, 0755); err != nil { return nil, err } manifestPath := path.Join(envPath, manifestFile) st := &State{ Path: envPath, Project: w.Path, Plan: Plan{ Package: pkg, }, Name: name, } data, err := yaml.Marshal(st) if err != nil { return nil, err } key, err := keychain.Default(ctx) if err != nil { return nil, err } encrypted, err := keychain.Encrypt(ctx, manifestPath, data, key) if err != nil { return nil, err } if err := os.WriteFile(manifestPath, encrypted, 0600); err != nil { return nil, err } err = os.WriteFile( path.Join(envPath, ".gitignore"), []byte("# dagger state\nstate/**\n"), 0600, ) if err != nil { return nil, err } return st, nil } func (w *Project) cleanPackageName(ctx context.Context, pkg string) (string, error) { lg := log. Ctx(ctx). With(). Str("package", pkg). Logger() if pkg == "" { return pkg, nil } // If the package is not a path, then it must be a domain (e.g. foo.bar/mypackage) if _, err := os.Stat(pkg); err != nil { if !errors.Is(err, os.ErrNotExist) { return "", err } // Make sure the domain is in the correct form if !strings.Contains(pkg, ".") || !strings.Contains(pkg, "/") { return "", fmt.Errorf("invalid package %q", pkg) } return pkg, nil } p, err := filepath.Abs(pkg) if err != nil { lg.Error().Err(err).Msg("unable to resolve path") return "", err } if !strings.HasPrefix(p, w.Path) { lg.Fatal().Err(err).Msg("package is outside the project") return "", err } p, err = filepath.Rel(w.Path, p) if err != nil { lg.Fatal().Err(err).Msg("unable to resolve path") return "", err } if !strings.HasPrefix(p, ".") { p = "./" + p } return p, nil } func cueModInit(ctx context.Context, parentDir string) error { lg := log.Ctx(ctx) modDir := path.Join(parentDir, "cue.mod") if err := os.Mkdir(modDir, 0755); err != nil { if !errors.Is(err, os.ErrExist) { return err } } modFile := path.Join(modDir, "module.cue") if _, err := os.Stat(modFile); err != nil { if !errors.Is(err, os.ErrNotExist) { return err } lg.Debug().Str("mod", parentDir).Msg("initializing cue.mod") if err := os.WriteFile(modFile, []byte("module: \"\"\n"), 0600); err != nil { return err } } if err := os.Mkdir(path.Join(modDir, "usr"), 0755); err != nil { if !errors.Is(err, os.ErrExist) { return err } } if err := os.Mkdir(path.Join(modDir, "pkg"), 0755); err != nil { if !errors.Is(err, os.ErrExist) { return err } } return nil } func vendorUniverse(ctx context.Context, p string) error { // ensure cue module is initialized if err := cueModInit(ctx, p); err != nil { return err } // add universe to `.gitignore` if err := os.WriteFile( path.Join(p, "cue.mod", "pkg", ".gitignore"), []byte(fmt.Sprintf("# dagger universe\n%s\n", stdlib.PackageName)), 0600, ); err != nil { return err } log.Ctx(ctx).Debug().Str("mod", p).Msg("vendoring universe") if _, err := mod.Install(ctx, p, "alpha.dagger.io", universeVersionConstraint); err != nil { return err } return nil }