diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..9863e8b --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,13 @@ +coverage: + precision: 2 + round: nearest + range: "70...100" + status: + project: + default: + target: auto + threshold: 15% + patch: + default: + target: 30% + threshold: 30% diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 038fe0b..8f3916d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -6,8 +6,6 @@ on: tags: - v* pull_request: - pull_request_target: - types: [opened, edited] env: GO_VERSION: "~1.20" diff --git a/README.md b/README.md index df031bc..373bb26 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Spin up your entire dev stack with one command. - [Example Configurations](#example-configurations) - [Integrations](#integrations) - [Integration: dotenv-vault](#integration-dotenv-vault) + - [Integration: Infisical](#integration-infisical) - [Integration: Telegram Notifications](#integration-telegram-notifications) - [Integration: Slack Notifications](#integration-slack-notifications) - [Integration: Desktop Notifications](#integration-desktop-notifications) @@ -499,6 +500,23 @@ env: - dotenv://vault # loads .env.vault, if it exists ``` +### Integration: Infisical + +`StackUp` includes an integration for loading secrets from an `Infisical` workspace (see the [Infisical website](https://infisical.com/)). + +You must define an environment variable _(either in a `.env` file or manually)_ named `INFISICAL_API_TOKEN` that contains an Infisical project's service token. + +The `Infisical` integration supports loading secrets from a workspace, environment, or both. To load secrets from a workspace (project), add an entry to the `env` section with the value `infisical://workspace-id:environment`. The `workspace-id` is the id of the workspace to load secrets from, and the `environment` is the name of the environment to load secrets from. + +```yaml +env: + - MY_ENV_VAR_ONE=test1234 + # load all secrets from the Infisical workspace with the id 123425fa2002e3a200d7a300 from the dev environment: + - infisical://123425fa2002e3a200d7a300:dev +``` + +Note that all secrets the service token has access to will be imported into the environment. + ### Integration: Telegram Notifications `StackUp` includes an integration for sending notifications via Telegram. To configure the integration, see the [Telegram Notifications](#configuration-settings-notifications-telegram) section of the [Configuration: Settings](#configuration-settings) documentation. diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index cfe95b0..3880f1b 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -92,11 +92,9 @@ tasks: - cp -f ./.env {{.BUILD_OUTPUT_DIR}} update-checksums: - dir: templates/remote-includes cmds: - - sha256sum *.yaml > checksums.sha256.txt - - git add checksums.sha256.txt - - git commit -m "update checksum files" + - sha256sum ./templates/remote-includes/*.yaml > ./templates/remote-includes/checksums.sha256.txt + - sed 's/\.\/templates\/remote-includes\///g' ./templates/remote-includes/checksums.sha256.txt --in-place autobuild: interactive: true diff --git a/go.mod b/go.mod index 95bf925..f5ac60a 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rs/xid v1.5.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/objx v0.5.0 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/technoweenie/multipartstreamer v1.0.1 // indirect golang.org/x/crypto v0.12.0 // indirect diff --git a/go.sum b/go.sum index 563313c..2436402 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,7 @@ github.com/slack-go/slack v0.12.3 h1:92/dfFU8Q5XP6Wp5rr5/T5JHLM5c5Smtn53fhToAP88 github.com/slack-go/slack v0.12.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/lib/app/tasks.go b/lib/app/tasks.go index c721c8a..4dc518c 100644 --- a/lib/app/tasks.go +++ b/lib/app/tasks.go @@ -53,7 +53,7 @@ type ScheduledTask struct { TaskReferenceContract } -func (task *Task) canRunOnCurrentPlatform() bool { +func (task *Task) CanRunOnCurrentPlatform() bool { if task.Platforms == nil || len(task.Platforms) == 0 { return true } @@ -67,7 +67,7 @@ func (task *Task) canRunOnCurrentPlatform() bool { return false } -func (task *Task) canRunConditionally() bool { +func (task *Task) CanRunConditionally() bool { if len(strings.TrimSpace(task.If)) == 0 { return true } @@ -163,12 +163,12 @@ func (task *Task) prepareRun() (bool, func()) { task.Path = task.JsEngine.Evaluate(task.Path).(string) } - if !task.canRunConditionally() { + if !task.CanRunConditionally() { support.SkippedMessageWithSymbol(task.GetDisplayName()) return false, nil } - if !task.canRunOnCurrentPlatform() { + if !task.CanRunOnCurrentPlatform() { support.SkippedMessageWithSymbol("Task '" + task.GetDisplayName() + "' is not supported on this operating system.") return false, nil } diff --git a/lib/app/tasks_test.go b/lib/app/tasks_test.go new file mode 100644 index 0000000..d51dae9 --- /dev/null +++ b/lib/app/tasks_test.go @@ -0,0 +1,123 @@ +package app_test + +import ( + "runtime" + "testing" + + "github.com/stackup-app/stackup/lib/app" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockJsEngine struct { + MockEvaluate func(script string) interface{} + mock.Mock +} + +func (m *MockJsEngine) Evaluate(script string) interface{} { + return m.MockEvaluate(script) +} + +func TestGetDisplayName(t *testing.T) { + assert := assert.New(t) + + // Test case: When Include field has a value with "https://" + task1 := app.Task{ + Include: "https://example.com", + Name: "TestName", + Id: "TestId", + Uuid: "TestUuid", + } + assert.Equal("example.com", task1.GetDisplayName()) + + // Test case: When Include field has a value without "https://" + task2 := app.Task{ + Include: "example.com", + Name: "TestName", + Id: "TestId", + Uuid: "TestUuid", + } + assert.Equal("example.com", task2.GetDisplayName()) + + // Test case: When Name field has a value + task3 := app.Task{ + Name: "TestName", + Id: "TestId", + Uuid: "TestUuid", + } + assert.Equal("TestName", task3.GetDisplayName()) + + // Test case: When Id field has a value + task4 := app.Task{ + Id: "TestId", + Uuid: "TestUuid", + } + assert.Equal("TestId", task4.GetDisplayName()) + + // Test case: When only Uuid field has a value + task5 := app.Task{ + Uuid: "TestUuid", + } + assert.Equal("TestUuid", task5.GetDisplayName()) + + // Test case: When no fields have values + task6 := app.Task{} + assert.Equal("", task6.GetDisplayName()) +} + +func TestCanRunOnCurrentPlatform(t *testing.T) { + assert := assert.New(t) + + // Test case: When Platforms is nil + task1 := &app.Task{} + assert.True(task1.CanRunOnCurrentPlatform()) + + // Test case: When Platforms is empty + task2 := &app.Task{Platforms: []string{}} + assert.True(task2.CanRunOnCurrentPlatform()) + + // Test case: When Platforms contains the current platform (case insensitive) + task3 := &app.Task{Platforms: []string{runtime.GOOS}} + assert.True(task3.CanRunOnCurrentPlatform()) + + // Test case: When Platforms does not contain the current platform + task4 := &app.Task{Platforms: []string{"someotherplatform"}} + assert.False(task4.CanRunOnCurrentPlatform()) +} + +// func TestCanRunConditionally(t *testing.T) { +// assert := assert.New(t) + +// // Test case: When If field is empty +// task1 := &app.Task{If: ""} +// assert.True(task1.CanRunConditionally()) + +// // Test case: When JsEngine.Evaluate returns true +// getJsEngine := func(mockEval func(script string) interface{}) interface{} { +// engine := &MockJsEngine{ +// MockEvaluate: mockEval, +// } + +// return engine +// } + +// jsengine2 := getJsEngine(func(script string) interface{} { +// return true +// }) + +// task2 := &app.Task{ +// If: "{{ some condition }}", +// JsEngine: jsengine2.(*scripting.JavaScriptEngine), +// } +// assert.True(task2.CanRunConditionally()) + +// // Test case: When JsEngine.Evaluate returns false +// jsengine3 := getJsEngine(func(script string) interface{} { +// return false +// }) +// task3 := &app.Task{ +// If: "some condition", +// JsEngine: jsengine3.(*scripting.JavaScriptEngine), +// } +// assert.False(task3.CanRunConditionally()) +// } diff --git a/lib/app/workflow.go b/lib/app/workflow.go index 5f3163e..a84a7d3 100644 --- a/lib/app/workflow.go +++ b/lib/app/workflow.go @@ -2,6 +2,7 @@ package app import ( "errors" + "fmt" "os" "strings" "sync" @@ -12,6 +13,7 @@ import ( "github.com/stackup-app/stackup/lib/consts" "github.com/stackup-app/stackup/lib/debug" "github.com/stackup-app/stackup/lib/gateway" + "github.com/stackup-app/stackup/lib/integrations" "github.com/stackup-app/stackup/lib/messages" "github.com/stackup-app/stackup/lib/scripting" "github.com/stackup-app/stackup/lib/settings" @@ -40,6 +42,7 @@ type StackupWorkflow struct { Cache *cache.Cache JsEngine *scripting.JavaScriptEngine Gateway *gateway.Gateway + Integrations map[string]integrations.Integration ProcessMap *sync.Map CommandStartCb types.CommandCallback ExitAppFunc func() @@ -47,7 +50,7 @@ type StackupWorkflow struct { } func CreateWorkflow(gw *gateway.Gateway, processMap *sync.Map) *StackupWorkflow { - return &StackupWorkflow{ + result := &StackupWorkflow{ Settings: &settings.Settings{}, Preconditions: []*WorkflowPrecondition{}, Tasks: []*Task{}, @@ -55,7 +58,16 @@ func CreateWorkflow(gw *gateway.Gateway, processMap *sync.Map) *StackupWorkflow Includes: []WorkflowInclude{}, Gateway: gw, ProcessMap: processMap, + Integrations: map[string]integrations.Integration{}, } + + result.Integrations = integrations.List(result.AsContract) + + return result +} + +func (workflow *StackupWorkflow) AsContract() types.AppWorkflowContract { + return workflow } func (workflow *StackupWorkflow) FindTaskById(id string) (any, bool) { @@ -82,6 +94,10 @@ func (workflow *StackupWorkflow) FindTaskByUuid(uuid string) *Task { return nil } +func (workflow *StackupWorkflow) GetEnvSection() []string { + return workflow.Env +} + func (workflow *StackupWorkflow) TryLoadDotEnvVaultFile() { if !utils.ArrayContains(workflow.Env, "dotenv://vault") { return @@ -109,7 +125,17 @@ func (workflow *StackupWorkflow) Initialize(engine *scripting.JavaScriptEngine, workflow.JsEngine = engine utils.ImportEnvDefsIntoEnvironment(workflow.Env) - workflow.TryLoadDotEnvVaultFile() + + for _, integration := range workflow.Integrations { + if integration.IsEnabled() { + err := integration.Run() + + if err != nil { + fmt.Printf(" integration error [%s]: %v\n", integration.Name(), err) + } + } + } + workflow.InitializeSections() workflow.processIncludes() } diff --git a/lib/app/workflow_test.go b/lib/app/workflow_test.go new file mode 100644 index 0000000..246e382 --- /dev/null +++ b/lib/app/workflow_test.go @@ -0,0 +1,50 @@ +package app_test + +import ( + "sync" + "testing" + + "github.com/stackup-app/stackup/lib/app" + "github.com/stackup-app/stackup/lib/gateway" + "github.com/stretchr/testify/assert" +) + +func TestCreateWorkflow(t *testing.T) { + gw := &gateway.Gateway{} // Mock or initialize as needed + processMap := &sync.Map{} + workflow := app.CreateWorkflow(gw, processMap) + + assert.NotNil(t, workflow) + assert.NotNil(t, workflow.Settings) + assert.NotNil(t, workflow.Preconditions) + assert.NotNil(t, workflow.Tasks) + assert.NotNil(t, workflow.Includes) + assert.NotNil(t, workflow.Gateway) + assert.NotNil(t, workflow.ProcessMap) + assert.NotNil(t, workflow.Integrations) +} + +func TestAsContract(t *testing.T) { + workflow := &app.StackupWorkflow{} + contract := workflow.AsContract() + + assert.NotNil(t, contract) +} + +func TestFindTaskById(t *testing.T) { + workflow := &app.StackupWorkflow{ + Tasks: []*app.Task{ + {Id: "test1"}, + {Id: "test2"}, + }, + } + + taskAny, found := workflow.FindTaskById("test1") + var task *app.Task = taskAny.(*app.Task) + + assert.True(t, found) + assert.Equal(t, "test1", task.Id) + + _, notFound := workflow.FindTaskById("test3") + assert.False(t, notFound) +} diff --git a/lib/integrations/dotenv_vault/dotenv_vault.go b/lib/integrations/dotenv_vault/dotenv_vault.go new file mode 100644 index 0000000..6de6c63 --- /dev/null +++ b/lib/integrations/dotenv_vault/dotenv_vault.go @@ -0,0 +1,46 @@ +package dotenvvault + +import ( + "os" + + "github.com/dotenv-org/godotenvvault" + "github.com/stackup-app/stackup/lib/types" + "github.com/stackup-app/stackup/lib/utils" +) + +type DotEnvVaultIntegration struct { + workflow func() types.AppWorkflowContract +} + +func New(getWorkflow func() types.AppWorkflowContract) DotEnvVaultIntegration { + return DotEnvVaultIntegration{workflow: getWorkflow} +} + +func (in DotEnvVaultIntegration) Name() string { + return "dotenv-vault" +} + +func (in DotEnvVaultIntegration) IsEnabled() bool { + return utils.ArrayContains(in.workflow().GetEnvSection(), "dotenv://vault") +} + +func (in DotEnvVaultIntegration) Run() error { + if !in.IsEnabled() { + return nil + } + + if !utils.IsFile(utils.WorkingDir(".env.vault")) { + return nil + } + + vars, err := godotenvvault.Read() + if err != nil { + return err + } + + for k, v := range vars { + os.Setenv(k, v) + } + + return nil +} diff --git a/lib/integrations/dotenv_vault/dotenv_vault_test.go b/lib/integrations/dotenv_vault/dotenv_vault_test.go new file mode 100644 index 0000000..c919c18 --- /dev/null +++ b/lib/integrations/dotenv_vault/dotenv_vault_test.go @@ -0,0 +1,104 @@ +package dotenvvault_test + +import ( + "testing" + + dotenvvault "github.com/stackup-app/stackup/lib/integrations/dotenv_vault" + "github.com/stackup-app/stackup/lib/settings" + "github.com/stackup-app/stackup/lib/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// Mocking godotenvvault.Read +type MockGodotenvvault struct { + Env []string + mock.Mock +} + +func (m *MockGodotenvvault) Read() (map[string]string, error) { + args := m.Called() + return args.Get(0).(map[string]string), args.Error(1) +} + +// Mocking the AppWorkflowContract for testing +type MockWorkflow struct { + mock.Mock + Env []string +} + +// FindTaskById implements types.AppWorkflowContract. +func (*MockWorkflow) FindTaskById(id string) (any, bool) { + panic("unimplemented") +} + +// GetJsEngine implements types.AppWorkflowContract. +func (*MockWorkflow) GetJsEngine() *types.JavaScriptEngineContract { + panic("unimplemented") +} + +// GetSettings implements types.AppWorkflowContract. +func (*MockWorkflow) GetSettings() *settings.Settings { + panic("unimplemented") +} + +func (m *MockWorkflow) GetEnvSection() []string { + return m.Env +} +func TestDotEnvVaultIntegration_Run(t *testing.T) { + // Mocking godotenvvault + mockGodotenvvault := new(MockGodotenvvault) + + // Test case: IsEnabled is false + workflow := func() types.AppWorkflowContract { + return &MockWorkflow{Env: []string{}} + } + integration := dotenvvault.New(func() types.AppWorkflowContract { + return workflow() + }) + + err := integration.Run() + assert.Nil(t, err) + + // Test case: IsEnabled is true but .env.vault file doesn't exist + workflow = func() types.AppWorkflowContract { + return &MockWorkflow{Env: []string{"dotenv://vault"}} + } + integration = dotenvvault.New(func() types.AppWorkflowContract { + return workflow() + }) + + err = integration.Run() + assert.Nil(t, err) + + // Test case: IsEnabled is true, .env.vault file exists, and godotenvvault.Read returns env variables + mockGodotenvvault.On("Read").Return(map[string]string{"KEY": "VALUE"}, nil) + err = integration.Run() + assert.Nil(t, err) +} + +func TestDotEnvVaultIntegration_Name(t *testing.T) { + integration := dotenvvault.New(func() types.AppWorkflowContract { + return &MockWorkflow{} + }) + assert.Equal(t, "dotenv-vault", integration.Name()) +} + +func TestDotEnvVaultIntegration_IsEnabled(t *testing.T) { + // Test case: dotenv://vault is not in the envSection + integration := dotenvvault.New(func() types.AppWorkflowContract { + return &MockWorkflow{ + Env: []string{}, + } + }) + assert.False(t, integration.IsEnabled()) + + // Test case: dotenv://vault is in the envSection + integration = dotenvvault.New(func() types.AppWorkflowContract { + return &MockWorkflow{ + Env: []string{"dotenv://vault"}, + } + }) + + assert.True(t, integration.IsEnabled()) +} diff --git a/lib/integrations/infisical/client.go b/lib/integrations/infisical/client.go new file mode 100644 index 0000000..9694b97 --- /dev/null +++ b/lib/integrations/infisical/client.go @@ -0,0 +1,305 @@ +package infisical + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "os" + "strings" +) + +type Client struct { + APIEndpoint string + HTTPClient *http.Client +} + +func NewClient(endpoint string) *Client { + return &Client{ + APIEndpoint: endpoint, + HTTPClient: &http.Client{}, + } +} + +type TokenData struct { + EncryptedKey string `json:"encryptedKey"` + Iv string `json:"iv"` + Tag string `json:"tag"` + Name string `json:"name"` +} + +type Secret struct { + Name string `json:"name"` + Value string `json:"value"` + Type string `json:"type"` + WorkspaceID string `json:"workspaceId"` + Environment string `json:"environment"` + Path string `json:"path"` + SecretKeyCiphertext string `json:"secretKeyCiphertext"` + SecretKeyIV string `json:"secretKeyIV"` + SecretKeyTag string `json:"secretKeyTag"` + SecretKeyHash string `json:"secretKeyHash"` + SecretValueCiphertext string `json:"secretValueCiphertext"` + SecretValueIV string `json:"secretValueIV"` + SecretValueTag string `json:"secretValueTag"` + SecretValueHash string `json:"secretValueHash"` +} + +type CreateSecretRequest struct { + SecretName string `json:"secretName"` + WorkspaceID string `json:"workspaceId"` + Environment string `json:"environment"` + Type string `json:"type"` + SecretKeyCiphertext string `json:"secretKeyCiphertext"` + SecretKeyIV string `json:"secretKeyIV"` + SecretKeyTag string `json:"secretKeyTag"` + SecretValueCiphertext string `json:"secretValueCiphertext"` + SecretValueIV string `json:"secretValueIV"` + SecretValueTag string `json:"secretValueTag"` + Path string `json:"path"` +} + +type UpdateSecretRequest struct { + SecretName string `json:"secretName"` + WorkspaceID string `json:"workspaceId"` + Environment string `json:"environment"` + Type string `json:"type"` + SecretValueCiphertext string `json:"secretValueCiphertext"` + SecretValueIV string `json:"secretValueIV"` + SecretValueTag string `json:"secretValueTag"` +} + +func (c *Client) makeRequest(method, endpoint string, payload interface{}) (*http.Response, error) { + url := c.APIEndpoint + endpoint + var req *http.Request + var err error + + if method == http.MethodGet || method == http.MethodDelete { + req, err = http.NewRequest(method, url, nil) + } else { + jsonPayload, _ := json.Marshal(payload) + req, err = http.NewRequest(method, url, bytes.NewBuffer(jsonPayload)) + req.Header.Set("Content-Type", "application/json") + } + + if err != nil { + return nil, err + } + + token := os.Getenv("INFISICAL_API_TOKEN") + apiKey := os.Getenv("INFISICAL_API_KEY") + + req.Header.Set("User-Agent", "StackUp-cli/v1") + + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + if apiKey != "" { + req.Header.Del("Authorization") + req.Header.Set("X-API-KEY", apiKey) + } + + if apiKey == "" && token == "" { + return nil, errors.New("INFISICAL_API_KEY or INFISICAL_API_TOKEN environment variable must be set") + } + + return c.HTTPClient.Do(req) +} + +func decrypt(ciphertext, iv, tag, secret string) (string, error) { + cipherTextBytes, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + + ivBytes, err := base64.StdEncoding.DecodeString(iv) + if err != nil { + return "", err + } + + tagBytes, err := base64.StdEncoding.DecodeString(tag) + if err != nil { + return "", err + } + + secretBytes := []byte(secret) + + block, err := aes.NewCipher(secretBytes) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCMWithNonceSize(block, len(ivBytes)) + if err != nil { + return "", err + } + + combinedCipherText := append(cipherTextBytes, tagBytes...) + plainText, err := gcm.Open(nil, ivBytes, combinedCipherText, nil) + if err != nil { + return "", errors.New("failed to decrypt") + } + + return string(plainText), nil +} + +func (c *Client) GetSecrets(workspaceId, environment, path string, includeImports bool) ([]Secret, error) { + endpoint := "/api/v3/secrets?workspaceId=" + workspaceId + "&environment=" + environment + "&path=" + path + if includeImports { + endpoint += "&include_imports=true" + } + + resp, err := c.makeRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var secretsResponse struct { + Secrets []Secret `json:"secrets"` + } + + // body := make([]byte, 81920) + // resp.Body.Read(body) + // fmt.Printf("resp.Body: %v\n", string(body)) + + err = json.NewDecoder(resp.Body).Decode(&secretsResponse) + if err != nil { + return nil, err + } + + result := secretsResponse.Secrets + token := os.Getenv("INFISICAL_API_TOKEN") + + lastIndex := strings.LastIndex(token, ".") + tokenSecret := "" + + if lastIndex != -1 && lastIndex+1 < len(token) { + tokenSecret = token[lastIndex+1:] + } + + tokenData, _ := c.GetTokenData(workspaceId, environment) + + secretKey, err := decrypt(tokenData.EncryptedKey, tokenData.Iv, tokenData.Tag, tokenSecret) + if err != nil { + return nil, err + } + + for _, secret := range result { + if secret.SecretKeyCiphertext != "" { + plainTextKey, err := decrypt(secret.SecretKeyCiphertext, secret.SecretKeyIV, secret.SecretKeyTag, secretKey) + if err != nil { + return nil, err + } + + plainTextValue, err := decrypt(secret.SecretValueCiphertext, secret.SecretValueIV, secret.SecretValueTag, secretKey) + if err != nil { + return nil, err + } + + secret.Name = plainTextKey + secret.Value = plainTextValue + } + } + + return result, nil +} + +func (c *Client) GetTokenData(workspaceId, environment string) (TokenData, error) { + endpoint := "/api/v2/service-token" + + resp, err := c.makeRequest(http.MethodGet, endpoint, nil) + if err != nil { + return TokenData{}, err + } + defer resp.Body.Close() + + var secretResponse TokenData + + err = json.NewDecoder(resp.Body).Decode(&secretResponse) + if err != nil { + return TokenData{}, err + } + + return secretResponse, nil +} + +func (c *Client) GetSecret(workspaceId, environment, secretName, path string) (Secret, error) { + endpoint := "/api/v3/secrets/" + secretName + "?workspaceId=" + workspaceId + "&environment=" + environment + "&path=" + path + + resp, err := c.makeRequest(http.MethodGet, endpoint, nil) + if err != nil { + return Secret{}, err + } + defer resp.Body.Close() + + var secretResponse struct { + Secret Secret `json:"secret"` + } + err = json.NewDecoder(resp.Body).Decode(&secretResponse) + if err != nil { + return Secret{}, err + } + + return secretResponse.Secret, nil +} + +func (c *Client) CreateSecret(secret CreateSecretRequest) (Secret, error) { + endpoint := "/api/v3/secrets/" + secret.SecretName + + resp, err := c.makeRequest(http.MethodPost, endpoint, secret) + if err != nil { + return Secret{}, err + } + defer resp.Body.Close() + + var secretResponse struct { + Secret Secret `json:"secret"` + } + err = json.NewDecoder(resp.Body).Decode(&secretResponse) + if err != nil { + return Secret{}, err + } + + return secretResponse.Secret, nil +} + +func (c *Client) UpdateSecret(secret UpdateSecretRequest) (Secret, error) { + endpoint := "/api/v3/secrets/" + secret.SecretName + + resp, err := c.makeRequest(http.MethodPatch, endpoint, secret) + if err != nil { + return Secret{}, err + } + defer resp.Body.Close() + + var secretResponse struct { + Secret Secret `json:"secret"` + } + err = json.NewDecoder(resp.Body).Decode(&secretResponse) + if err != nil { + return Secret{}, err + } + + return secretResponse.Secret, nil +} + +func (c *Client) DeleteSecret(workspaceId, environment, secretName, path string) error { + endpoint := "/api/v3/secrets/" + secretName + "?workspaceId=" + workspaceId + "&environment=" + environment + "&path=" + path + + resp, err := c.makeRequest(http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New("Failed to delete secret") + } + + return nil +} diff --git a/lib/integrations/infisical/infiscal_test.go b/lib/integrations/infisical/infiscal_test.go new file mode 100644 index 0000000..1fd162c --- /dev/null +++ b/lib/integrations/infisical/infiscal_test.go @@ -0,0 +1,123 @@ +package infisical + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetSecrets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + secrets := []Secret{ + {Name: "test-secret", Value: "test-value"}, + } + json.NewEncoder(w).Encode(map[string][]Secret{"secrets": secrets}) + })) + defer server.Close() + + client := NewClient(server.URL) + secrets, err := client.GetSecrets("workspaceId", "environment", "path", false) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(secrets) != 1 { + t.Fatalf("Expected 1 secret, got %d", len(secrets)) + } +} + +func TestGetSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + secret := Secret{Name: "test-secret", Value: "test-value"} + json.NewEncoder(w).Encode(map[string]Secret{"secret": secret}) + })) + defer server.Close() + + client := NewClient(server.URL) + secret, err := client.GetSecret("workspaceId", "environment", "test-secret", "path") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if secret.Name != "test-secret" { + t.Fatalf("Expected secret name to be test-secret, got %s", secret.Name) + } + + if secret.Value != "test-value" { + t.Fatalf("Expected secret value to be test-value, got %s", secret.Value) + } +} + +func TestCreateSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + secret := Secret{Name: "test-secret", Value: "test-value"} + json.NewEncoder(w).Encode(map[string]Secret{"secret": secret}) + })) + defer server.Close() + + client := NewClient(server.URL) + req := CreateSecretRequest{SecretName: "test-secret"} + secret, err := client.CreateSecret(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if secret.Name != "test-secret" { + t.Fatalf("Expected secret name to be test-secret, got %s", secret.Name) + } +} + +func TestUpdateSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + secret := Secret{Name: "test-secret", Value: "updated-value"} + json.NewEncoder(w).Encode(map[string]Secret{"secret": secret}) + })) + defer server.Close() + + client := NewClient(server.URL) + req := UpdateSecretRequest{SecretName: "test-secret"} + secret, err := client.UpdateSecret(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if secret.Value != "updated-value" { + t.Fatalf("Expected secret value to be updated-value, got %s", secret.Value) + } +} + +func TestDeleteSecret(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL) + err := client.DeleteSecret("workspaceId", "environment", "test-secret", "path") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestMakeRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + })) + defer server.Close() + + client := NewClient(server.URL) + resp, err := client.makeRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + buf := new(bytes.Buffer) + buf.ReadFrom(resp.Body) + if buf.String() != "OK" { + t.Fatalf("Expected response body to be OK, got %s", buf.String()) + } +} diff --git a/lib/integrations/infisical/infisical.go b/lib/integrations/infisical/infisical.go new file mode 100644 index 0000000..f2736f5 --- /dev/null +++ b/lib/integrations/infisical/infisical.go @@ -0,0 +1,102 @@ +package infisical + +import ( + "errors" + "os" + "strings" + + "github.com/stackup-app/stackup/lib/types" +) + +type InfisicalIntegration struct { + workflow func() types.AppWorkflowContract + endpoint string + client *Client +} + +func New(getWorkflow func() types.AppWorkflowContract) InfisicalIntegration { + return InfisicalIntegration{workflow: getWorkflow, endpoint: ""} +} + +func (in InfisicalIntegration) Name() string { + return "infisical" +} + +func (in InfisicalIntegration) IsEnabled() bool { + for _, value := range in.workflow().GetEnvSection() { + if strings.HasPrefix(value, "infisical://") { + in.endpoint = strings.TrimPrefix(value, "infisical://") + return true + } + } + return false +} + +// ParseInfiscalURL parses the given string in the format `infiscal://:/` +// and returns the workspace_id, environment_name, and path. +func (in InfisicalIntegration) parseInfisicalURL(infisicalUrl string) (string, string, string, error) { + url := os.ExpandEnv(infisicalUrl) + + const prefix = "infisical://" + if !strings.HasPrefix(url, prefix) { + return "", "", "", errors.New("invalid prefix") + } + + url = strings.TrimPrefix(url, prefix) + parts := strings.SplitN(url, "/", 2) + if len(parts) == 0 { + return "", "", "", errors.New("invalid workspace and environment format") + } + + if len(parts) < 2 { + parts = append(parts, "") + } + + workspaceAndEnv := parts[0] + path := parts[1] + + workspaceAndEnvParts := strings.SplitN(workspaceAndEnv, ":", 2) + if len(workspaceAndEnvParts) != 2 { + return "", "", "", errors.New("invalid workspace and environment format") + } + + workspaceID := workspaceAndEnvParts[0] + environmentName := workspaceAndEnvParts[1] + + return workspaceID, environmentName, path, nil +} + +func (in InfisicalIntegration) Run() error { + if !in.IsEnabled() { + return nil + } + + in.client = NewClient("https://app.infisical.com") + + for _, value := range in.workflow().GetEnvSection() { + err := in.processEnvEntry(value) + if err != nil { + return err + } + } + + return nil +} + +func (in InfisicalIntegration) processEnvEntry(str string) error { + workspaceID, environmentName, path, err := in.parseInfisicalURL(str) + + secrets, err := in.client.GetSecrets(workspaceID, environmentName, "/"+path, true) + + if err != nil { + return err + } + + for _, secret := range secrets { + if secret.Name != "" { + os.Setenv(secret.Name, secret.Value) + } + } + + return nil +} diff --git a/lib/integrations/integration.go b/lib/integrations/integration.go new file mode 100644 index 0000000..c24718b --- /dev/null +++ b/lib/integrations/integration.go @@ -0,0 +1,23 @@ +package integrations + +import ( + dotenvvaultintegration "github.com/stackup-app/stackup/lib/integrations/dotenv_vault" + infisicalintegration "github.com/stackup-app/stackup/lib/integrations/infisical" + "github.com/stackup-app/stackup/lib/types" +) + +type Integration interface { + Name() string + IsEnabled() bool + Run() error +} + +func List(getWorkflow func() types.AppWorkflowContract) map[string]Integration { + dotenvvault := dotenvvaultintegration.New(getWorkflow) + infisical := infisicalintegration.New(getWorkflow) + + return map[string]Integration{ + dotenvvault.Name(): dotenvvault, + infisical.Name(): infisical, + } +} diff --git a/lib/types/types.go b/lib/types/types.go index 2642988..7838aa5 100644 --- a/lib/types/types.go +++ b/lib/types/types.go @@ -45,6 +45,7 @@ type AppWorkflowContract interface { FindTaskById(id string) (any, bool) GetSettings() *settings.Settings GetJsEngine() *JavaScriptEngineContract + GetEnvSection() []string } type AppWorkflowContractPtr *AppWorkflowContract