pulumi/sdk/go/common/workspace/workspace.go

211 lines
5.5 KiB
Go

// 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, "-")
}