// Copyright 2016-2021, Pulumi Corporation. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package workspace import ( //nolint:gosec "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" ) // W offers functionality for interacting with Pulumi workspaces. type W interface { Settings() *Settings // returns a mutable pointer to the optional workspace settings info. Save() error // saves any modifications to the workspace. } type projectWorkspace struct { name tokens.PackageName // the package this workspace is associated with. project string // the path to the Pulumi.[yaml|json] file for this project. settings *Settings // settings for this workspace. } var ( cache = make(map[string]W) cacheMutex sync.RWMutex ) func loadFromCache(key string) (W, bool) { cacheMutex.RLock() defer cacheMutex.RUnlock() w, ok := cache[key] return w, ok } func upsertIntoCache(key string, w W) { contract.Requiref(w != nil, "w", "cannot be nil") cacheMutex.Lock() defer cacheMutex.Unlock() cache[key] = w } // New creates a new workspace using the current working directory. func New() (W, error) { cwd, err := os.Getwd() if err != nil { return nil, err } return NewFrom(cwd) } // NewFrom creates a new Pulumi workspace in the given directory. Requires a Pulumi.yaml file be present in the // folder hierarchy between dir and the .pulumi folder. func NewFrom(dir string) (W, error) { absDir, err := filepath.Abs(dir) if err != nil { return nil, err } dir = absDir if w, ok := loadFromCache(dir); ok { return w, nil } path, err := DetectProjectPathFrom(dir) if err != nil { return nil, err } else if path == "" { return nil, fmt.Errorf("no Pulumi.yaml project file found (searching upwards from %s). If you have not "+ "created a project yet, use `pulumi new` to do so", dir) } proj, err := LoadProject(path) if err != nil { return nil, err } w := &projectWorkspace{ name: proj.Name, project: path, } err = w.readSettings() if err != nil { return nil, fmt.Errorf("unable to read workspace settings: %w", err) } upsertIntoCache(dir, w) return w, nil } func (pw *projectWorkspace) Settings() *Settings { return pw.settings } func (pw *projectWorkspace) Save() error { settingsFile := pw.settingsPath() // If the settings file is empty, don't write an new one, and delete the old one if present. Since we put workspaces // under ~/.pulumi/workspaces, cleaning them out when possible prevents us from littering a bunch of files in the // home directory. if pw.settings.IsEmpty() { err := os.Remove(settingsFile) if err != nil && !os.IsNotExist(err) { return err } return nil } err := os.MkdirAll(filepath.Dir(settingsFile), 0o700) if err != nil { return err } b, err := json.MarshalIndent(pw.settings, "", " ") if err != nil { return err } return atomicWriteFile(settingsFile, b) } // atomicWriteFile provides a rename based atomic write through a temporary file. func atomicWriteFile(path string, b []byte) error { tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)) if err != nil { return fmt.Errorf("failed to create temporary file %s: %w", path, err) } defer func() { contract.Ignore(os.Remove(tmp.Name())) }() if err = tmp.Chmod(0o600); err != nil { return fmt.Errorf("failed to set temporary file permission: %w", err) } if _, err = tmp.Write(b); err != nil { return fmt.Errorf("failed to write to temporary file: %w", err) } if err = tmp.Sync(); err != nil { return err } if err = tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), path) } func (pw *projectWorkspace) readSettings() error { settingsPath := pw.settingsPath() b, err := os.ReadFile(settingsPath) if err != nil && os.IsNotExist(err) { // not an error to not have an existing settings file. pw.settings = &Settings{} return nil } else if err != nil { return err } var settings Settings err = json.Unmarshal(b, &settings) if err != nil { return fmt.Errorf("could not parse file %s: %w", settingsPath, err) } pw.settings = &settings return nil } func (pw *projectWorkspace) settingsPath() string { uniqueFileName := string(pw.name) + "-" + sha1HexString(pw.project) + "-" + WorkspaceFile path, err := GetPulumiPath(WorkspaceDir, uniqueFileName) contract.AssertNoErrorf(err, "could not get workspace path") return path } // sha1HexString returns a hex string of the sha1 hash of value. func sha1HexString(value string) string { //nolint:gosec h := sha1.New() _, err := h.Write([]byte(value)) contract.AssertNoErrorf(err, "error hashing string") return hex.EncodeToString(h.Sum(nil)) } // qnameFileName takes a qname and cleans it for use as a filename (by replacing tokens.QNameDelimter with a dash) func qnameFileName(nm tokens.QName) string { return strings.ReplaceAll(string(nm), tokens.QNameDelimiter, "-") }