diff --git a/src/engine/segment.go b/src/engine/segment.go index ebc9a928..dd263b72 100644 --- a/src/engine/segment.go +++ b/src/engine/segment.go @@ -202,6 +202,8 @@ const ( PLASTIC SegmentType = "plastic" // Project version PROJECT SegmentType = "project" + // PULUMI writes the pulumi user, store and stack + PULUMI SegmentType = "pulumi" // PYTHON writes the virtual env name PYTHON SegmentType = "python" // QUASAR writes the QUASAR version and context @@ -322,6 +324,7 @@ var Segments = map[SegmentType]func() SegmentWriter{ PHP: func() SegmentWriter { return &segments.Php{} }, PLASTIC: func() SegmentWriter { return &segments.Plastic{} }, PROJECT: func() SegmentWriter { return &segments.Project{} }, + PULUMI: func() SegmentWriter { return &segments.Pulumi{} }, PYTHON: func() SegmentWriter { return &segments.Python{} }, QUASAR: func() SegmentWriter { return &segments.Quasar{} }, R: func() SegmentWriter { return &segments.R{} }, diff --git a/src/segments/pulumi.go b/src/segments/pulumi.go new file mode 100644 index 00000000..e514893d --- /dev/null +++ b/src/segments/pulumi.go @@ -0,0 +1,220 @@ +package segments + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + + "github.com/jandedobbeleer/oh-my-posh/src/platform" + "github.com/jandedobbeleer/oh-my-posh/src/properties" + "gopkg.in/yaml.v3" +) + +const ( + FetchStack properties.Property = "fetch_stack" + FetchAbout properties.Property = "fetch_about" + + JSON string = "json" + YAML string = "yaml" + + pulumiJSON string = "Pulumi.json" + pulumiYAML string = "Pulumi.yaml" +) + +type Pulumi struct { + props properties.Properties + env platform.Environment + + Stack string + Name string + + workspaceSHA1 string + + backend +} + +type backend struct { + URL string `json:"url"` + User string `json:"user"` +} + +type pulumiFileSpec struct { + Name string `yaml:"name" json:"name"` +} + +type pulumiWorkSpaceFileSpec struct { + Stack string `json:"stack"` +} + +func (p *Pulumi) Template() string { + return "\U000f0d46 {{ .Stack }}{{if .User }} :: {{ .User }}@{{ end }}{{ if .URL }}{{ .URL }}{{ end }}" +} + +func (p *Pulumi) Init(props properties.Properties, env platform.Environment) { + p.props = props + p.env = env +} + +func (p *Pulumi) Enabled() bool { + if !p.env.HasCommand("pulumi") { + return false + } + + err := p.getProjectName() + if err != nil { + p.env.Error(err) + return false + } + + if p.props.GetBool(FetchStack, false) { + p.getPulumiStackName() + } + + if p.props.GetBool(FetchAbout, false) { + p.getPulumiAbout() + } + + return true +} + +func (p *Pulumi) getPulumiStackName() { + if len(p.Name) == 0 || len(p.workspaceSHA1) == 0 { + p.env.Debug("pulumi project name or workspace sha1 is empty") + return + } + + stackNameFile := p.Name + "-" + p.workspaceSHA1 + "-" + "workspace.json" + + homedir := p.env.Home() + + workspaceCacheDir := filepath.Join(homedir, ".pulumi", "workspaces") + if !p.env.HasFolder(workspaceCacheDir) || !p.env.HasFilesInDir(workspaceCacheDir, stackNameFile) { + return + } + + workspaceCacheFile := filepath.Join(workspaceCacheDir, stackNameFile) + workspaceCacheFileContent := p.env.FileContent(workspaceCacheFile) + + var pulumiWorkspaceSpec pulumiWorkSpaceFileSpec + err := json.Unmarshal([]byte(workspaceCacheFileContent), &pulumiWorkspaceSpec) + if err != nil { + p.env.Error(fmt.Errorf("pulumi workspace file decode error")) + return + } + + p.env.DebugF("pulumi stack name: %s", pulumiWorkspaceSpec.Stack) + p.Stack = pulumiWorkspaceSpec.Stack +} + +func (p *Pulumi) getProjectName() error { + var kind, fileName string + for _, file := range []string{pulumiYAML, pulumiJSON} { + if p.env.HasFiles(file) { + fileName = file + kind = filepath.Ext(file)[1:] + } + } + + if len(kind) == 0 { + return fmt.Errorf("no pulumi spec file found") + } + + var pulumiFileSpec pulumiFileSpec + var err error + + pulumiFile := p.env.FileContent(fileName) + + switch kind { + case YAML: + err = yaml.Unmarshal([]byte(pulumiFile), &pulumiFileSpec) + case JSON: + err = json.Unmarshal([]byte(pulumiFile), &pulumiFileSpec) + default: + err = fmt.Errorf("unknown pulumi spec file format") + } + + if err != nil { + p.env.Error(err) + return nil + } + + p.Name = pulumiFileSpec.Name + + sha1HexString := func(value string) string { + h := sha1.New() + + _, err := h.Write([]byte(value)) + if err != nil { + p.env.Error(err) + return "" + } + + return hex.EncodeToString(h.Sum(nil)) + } + + p.workspaceSHA1 = sha1HexString(p.env.Pwd() + "/" + fileName) + + return nil +} + +func (p *Pulumi) getPulumiAbout() { + if len(p.Stack) == 0 { + p.env.Error(fmt.Errorf("pulumi stack name is empty, use `fetch_stack` property to enable stack fetching")) + return + } + + cacheKey := "pulumi-" + p.Name + "-" + p.Stack + "-" + p.workspaceSHA1 + "-about" + + getAboutCache := func(key string) (*backend, error) { + aboutBackend, OK := p.env.Cache().Get(key) + if (!OK || len(aboutBackend) == 0) || (OK && len(aboutBackend) == 0) { + return nil, fmt.Errorf("no data in cache") + } + + var backend *backend + err := json.Unmarshal([]byte(aboutBackend), &backend) + if err != nil { + p.env.DebugF("unable to decode about cache: %s", aboutBackend) + p.env.Error(fmt.Errorf("pulling about cache decode error")) + return nil, err + } + + return backend, nil + } + + aboutBackend, err := getAboutCache(cacheKey) + if err == nil { + p.backend = *aboutBackend + return + } + + aboutOutput, err := p.env.RunCommand("pulumi", "about", "--json") + + if err != nil { + p.env.Error(fmt.Errorf("unable to get pulumi about output")) + return + } + + var about struct { + Backend *backend `json:"backend"` + } + + err = json.Unmarshal([]byte(aboutOutput), &about) + if err != nil { + p.env.Error(fmt.Errorf("pulumi about output decode error")) + return + } + + if about.Backend == nil { + p.env.Debug("pulumi about backend is not set") + return + } + + p.backend = *about.Backend + + cacheTimeout := p.props.GetInt(properties.CacheTimeout, 43800) + jso, _ := json.Marshal(about.Backend) + p.env.Cache().Set(cacheKey, string(jso), cacheTimeout) +} diff --git a/src/segments/pulumi_test.go b/src/segments/pulumi_test.go new file mode 100644 index 00000000..bd277283 --- /dev/null +++ b/src/segments/pulumi_test.go @@ -0,0 +1,234 @@ +package segments + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/jandedobbeleer/oh-my-posh/src/mock" + "github.com/jandedobbeleer/oh-my-posh/src/properties" + "github.com/stretchr/testify/assert" + mock2 "github.com/stretchr/testify/mock" +) + +func TestPulumi(t *testing.T) { + cases := []struct { + Case string + YAMLConfig string + JSONConfig string + + HasCommand bool + + FetchStack bool + Stack string + StackError error + + HasWorkspaceFolder bool + WorkSpaceFile string + + FetchAbout bool + About string + AboutError error + AboutCache string + + ExpectedString string + ExpectedEnabled bool + }{ + { + Case: "no pulumi command", + ExpectedEnabled: false, + HasCommand: false, + }, + { + Case: "pulumi command is present, but no pulumi file", + ExpectedEnabled: false, + HasCommand: true, + }, + { + Case: "pulumi file YAML is present", + ExpectedString: "\U000f0d46", + ExpectedEnabled: true, + HasCommand: true, + YAMLConfig: ` +name: oh-my-posh +runtime: golang +description: A Console App +`, + }, + { + Case: "pulumi file JSON is present", + ExpectedString: "\U000f0d46", + ExpectedEnabled: true, + HasCommand: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + }, + { + Case: "no stack present", + ExpectedString: "\U000f0d46 1337", + ExpectedEnabled: true, + HasCommand: true, + HasWorkspaceFolder: true, + FetchStack: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{ "stack": "1337" }`, + }, + { + Case: "pulumi stack", + ExpectedString: "\U000f0d46 1337", + ExpectedEnabled: true, + HasCommand: true, + HasWorkspaceFolder: true, + FetchStack: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{ "stack": "1337" }`, + }, + { + Case: "pulumi URL", + ExpectedString: "\U000f0d46 1337 :: posh-user@s3://test-pulumi-state-test", + ExpectedEnabled: true, + HasCommand: true, + HasWorkspaceFolder: true, + FetchStack: true, + FetchAbout: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{ "stack": "1337" }`, + About: `{ "backend": { "url": "s3://test-pulumi-state-test", "user":"posh-user" } }`, + }, + { + Case: "pulumi URL - cache", + ExpectedString: "\U000f0d46 1337 :: posh-user@s3://test-pulumi-state-test", + ExpectedEnabled: true, + HasCommand: true, + HasWorkspaceFolder: true, + FetchStack: true, + FetchAbout: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{ "stack": "1337" }`, + AboutCache: `{ "url": "s3://test-pulumi-state-test", "user":"posh-user" }`, + }, + // Error flows + { + Case: "pulumi file JSON error", + ExpectedString: "\U000f0d46", + ExpectedEnabled: true, + FetchStack: true, + HasCommand: true, + JSONConfig: `{`, + }, + { + Case: "pulumi workspace file JSON error", + ExpectedString: "\U000f0d46", + ExpectedEnabled: true, + FetchStack: true, + HasCommand: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{`, + HasWorkspaceFolder: true, + }, + { + Case: "pulumi URL, no fetch_stack set", + ExpectedString: "\U000f0d46", + ExpectedEnabled: true, + HasCommand: true, + FetchAbout: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + }, + { + Case: "pulumi URL - cache error", + ExpectedString: "\U000f0d46 1337 :: posh-user@s3://test-pulumi-state-test-output", + ExpectedEnabled: true, + HasCommand: true, + HasWorkspaceFolder: true, + FetchStack: true, + FetchAbout: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{ "stack": "1337" }`, + AboutCache: `{`, + About: `{ "backend": { "url": "s3://test-pulumi-state-test-output", "user":"posh-user" } }`, + }, + { + Case: "pulumi URL - about error", + ExpectedString: "\U000f0d46 1337", + ExpectedEnabled: true, + HasCommand: true, + HasWorkspaceFolder: true, + FetchStack: true, + FetchAbout: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{ "stack": "1337" }`, + AboutError: errors.New("error"), + }, + { + Case: "pulumi URL - about decode error", + ExpectedString: "\U000f0d46 1337", + ExpectedEnabled: true, + HasCommand: true, + HasWorkspaceFolder: true, + FetchStack: true, + FetchAbout: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{ "stack": "1337" }`, + About: `{`, + }, + { + Case: "pulumi URL - about backend is nil", + ExpectedString: "\U000f0d46 1337", + ExpectedEnabled: true, + HasCommand: true, + HasWorkspaceFolder: true, + FetchStack: true, + FetchAbout: true, + JSONConfig: `{ "name": "oh-my-posh" }`, + WorkSpaceFile: `{ "stack": "1337" }`, + About: `{}`, + }, + } + + for _, tc := range cases { + env := new(mock.MockedEnvironment) + + env.On("HasCommand", "pulumi").Return(tc.HasCommand) + env.On("RunCommand", "pulumi", []string{"stack", "ls", "--json"}).Return(tc.Stack, tc.StackError) + env.On("RunCommand", "pulumi", []string{"about", "--json"}).Return(tc.About, tc.AboutError) + + env.On("Pwd").Return("/home/foobar/Work/oh-my-posh/pulumi/projects/awesome-project") + env.On("Home").Return(filepath.Clean("/home/foobar")) + env.On("Error", mock2.Anything) + env.On("Debug", mock2.Anything) + env.On("DebugF", mock2.Anything, mock2.Anything) + + env.On("HasFiles", pulumiYAML).Return(len(tc.YAMLConfig) > 0) + env.On("FileContent", pulumiYAML).Return(tc.YAMLConfig, nil) + + env.On("HasFiles", pulumiJSON).Return(len(tc.JSONConfig) > 0) + env.On("FileContent", pulumiJSON).Return(tc.JSONConfig, nil) + + env.On("HasFolder", filepath.Clean("/home/foobar/.pulumi/workspaces")).Return(tc.HasWorkspaceFolder) + workspaceFile := "oh-my-posh-c62b7b6786c5c5a85896576e46a25d7c9f888e92-workspace.json" + env.On("HasFilesInDir", filepath.Clean("/home/foobar/.pulumi/workspaces"), workspaceFile).Return(len(tc.WorkSpaceFile) > 0) + env.On("FileContent", filepath.Clean("/home/foobar/.pulumi/workspaces/"+workspaceFile)).Return(tc.WorkSpaceFile, nil) + + cache := &mock.MockedCache{} + cache.On("Get", "pulumi-oh-my-posh-1337-c62b7b6786c5c5a85896576e46a25d7c9f888e92-about").Return(tc.AboutCache, len(tc.AboutCache) > 0) + cache.On("Set", mock2.Anything, mock2.Anything, mock2.Anything) + + env.On("Cache").Return(cache) + + pulumi := &Pulumi{ + env: env, + props: properties.Map{ + FetchStack: tc.FetchStack, + FetchAbout: tc.FetchAbout, + }, + } + + assert.Equal(t, tc.ExpectedEnabled, pulumi.Enabled(), tc.Case) + + if !tc.ExpectedEnabled { + continue + } + + var got = renderTemplate(env, pulumi.Template(), pulumi) + assert.Equal(t, tc.ExpectedString, got, tc.Case) + } +} diff --git a/themes/schema.json b/themes/schema.json index 3e4e91cf..1582a047 100644 --- a/themes/schema.json +++ b/themes/schema.json @@ -295,6 +295,7 @@ "php", "plastic", "project", + "pulumi", "root", "ruby", "rust", @@ -1957,6 +1958,19 @@ } } }, + { + "if": { + "properties": { + "type": { + "const": "pulumi" + } + } + }, + "then": { + "title": "Pulumi Segment", + "description": "https://ohmyposh.dev/docs/segments/pulumi" + } + }, { "if": { "properties": { diff --git a/website/docs/segments/pulumi.mdx b/website/docs/segments/pulumi.mdx new file mode 100644 index 00000000..0b1177dc --- /dev/null +++ b/website/docs/segments/pulumi.mdx @@ -0,0 +1,56 @@ +--- +id: pulumi +title: Pulumi +sidebar_label: Pulumi +--- + +## What + +Display the currently active pulumi logged-in user, url and stack. + +:::caution +This requires a pulumi binary in your PATH and will only show in directories that contain a `Pulumi.yaml` file. +::: + +## Sample Configuration + +import Config from "@site/src/components/Config.js"; + + + +## Properties + +| Name | Type | Default | Description | +| ------------- | --------- | ------- | ---------------------------------------------------------------------------------- | +| `fetch_stack` | `boolean` | `false` | fetch the current stack name | +| `fetch_about` | `boolean` | `false` | fetch the URL and user for the current stask. Requires `fetch_stack` set to `true` | + +## Template ([info][templates]) + +:::note default template + +```template +{{ .Stack }}{{if .User }} :: {{ .User }}@{{ end }}{{ if .URL }}{{ .URL }}{{ end }} +``` + +::: + +### Properties + +| Name | Type | Description | +| -------- | -------- | -------------------------------------------------- | +| `.Stack` | `string` | the current stack name | +| `.User` | `string` | is the current logged in user | +| `.Url` | `string` | the URL of the state where pulumi stores resources | + +[templates]: /docs/configuration/templates diff --git a/website/sidebars.js b/website/sidebars.js index a74ee249..0aba4195 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -104,6 +104,7 @@ module.exports = { "segments/php", "segments/plastic", "segments/project", + "segments/pulumi", "segments/python", "segments/quasar", "segments/r",