├── .github ├── ISSUE_TEMPLATE.md ├── dependabot.yml └── workflows │ └── release.yml ├── aws ├── ssm │ ├── plugin_other.go │ ├── plugin_windows.go │ ├── session_parameters.go │ ├── get_document_name.go │ ├── ecs.go │ ├── ec2.go │ └── plugin.go ├── ecs │ ├── exec_command.go │ ├── get_service.go │ ├── get_target_group_health.go │ ├── get_tasks.go │ ├── outputs.go │ ├── remoter.go │ └── statuser.go ├── ec2 │ ├── outputs.go │ └── remoter.go └── beanstalk │ ├── outputs.go │ ├── get_instances.go │ └── remoter.go ├── git ├── repo.go ├── get_vcs_url.go ├── root_dir.go └── git_ignore.go ├── tfconfig ├── config.go ├── config_windows.go ├── config_unix.go └── creds.go ├── cmd ├── flag_block.go ├── cancellable_action.go ├── profile_action.go ├── flag_profile.go ├── cli_os_writers.go ├── flag_app_source.go ├── flag_stack.go ├── flag_app.go ├── watch_action.go ├── flag_app_version.go ├── app_env_context.go ├── profile.go ├── setup_profile_cmd.go ├── flag_env.go ├── flag_org.go ├── set_org.go ├── app_workspace_action.go ├── flag_ssh.go ├── block_workspace_action.go ├── configure.go ├── table_buffer.go ├── find_app_details.go ├── up.go ├── perform_run.go ├── run.go ├── create_deploy.go ├── outputs.go ├── plan.go ├── apps.go ├── launch.go ├── ns_status.go ├── ssh.go ├── apply.go ├── perform_env_run.go ├── stacks.go ├── exec.go ├── iac.go ├── wait_for.go ├── logs.go ├── wait.go ├── push.go └── module_survey.go ├── workspaces ├── clear_local.go ├── init.go ├── scan_local.go ├── write_backend.go ├── manifest.go ├── run_config.go ├── select.go └── capabilities.go ├── config ├── nullstone_dir.go ├── cleanse_api_key.go ├── port_forward.go └── profile.go ├── .gitignore ├── app_urls ├── intent_workflow.go ├── run.go ├── base_url.go └── workspace_workflow.go ├── Makefile ├── vcs ├── get_git_repo.go ├── get_commit_sha.go └── get_commit_info.go ├── runs ├── set_module_version.go ├── set_run_config_vars.go ├── wait_for_terminal_run.go └── stream_logs.go ├── admin ├── provider.go ├── statuser.go ├── providers.go ├── remoter.go └── all │ └── providers.go ├── nullstone ├── main.go └── README.md ├── modules ├── generate_subdomain.go ├── generate_domain.go ├── generate_capability.go ├── next_patch_test.go ├── package.go ├── manifest.go ├── register.go ├── next_patch.go └── generate.go ├── gcp └── gke │ ├── exec_command.go │ ├── outputs.go │ ├── get_pod_name.go │ └── remoter.go ├── artifacts ├── find_latest_version_test.go ├── targzer.go ├── glob_many.go ├── find_latest_version.go ├── package_module.go └── version_info.go ├── LICENSE ├── k8s ├── exec_options.go ├── exec_command.go └── port_forwarder.go ├── nullstone.json ├── .goreleaser.yml ├── app └── build.go ├── Formula └── nullstone.rb ├── iac ├── discover.go ├── process.go └── test.go ├── README.md └── docs └── main.go /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | ## Intended Outcome 5 | 6 | 7 | ## How will it work? 8 | 9 | -------------------------------------------------------------------------------- /aws/ssm/plugin_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package ssm 5 | 6 | const ( 7 | osSessionManagerPluginPath = "/usr/local/bin/session-manager-plugin" 8 | ) 9 | -------------------------------------------------------------------------------- /git/repo.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "github.com/go-git/go-git/v5" 4 | 5 | func RepoFromDir(dir string) *git.Repository { 6 | repo, err := git.PlainOpen(".") 7 | if err == git.ErrRepositoryNotExists { 8 | return nil 9 | } 10 | return repo 11 | } 12 | -------------------------------------------------------------------------------- /tfconfig/config.go: -------------------------------------------------------------------------------- 1 | package tfconfig 2 | 3 | import "path/filepath" 4 | 5 | func GetCredentialsFilename() (string, error) { 6 | dir, err := configDir() 7 | if err != nil { 8 | return "", err 9 | } 10 | return filepath.Join(dir, "credentials.tfrc"), nil 11 | } 12 | -------------------------------------------------------------------------------- /cmd/flag_block.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var BlockFlag = &cli.StringFlag{ 6 | Name: "block", 7 | Usage: "Name of the block to use for this operation", 8 | EnvVars: []string{"NULLSTONE_BLOCK", "NULLSTONE_APP"}, 9 | Required: true, 10 | } 11 | -------------------------------------------------------------------------------- /workspaces/clear_local.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var ( 8 | tfDir = ".terraform/" 9 | ) 10 | 11 | func HasLocalConfigured() bool { 12 | _, err := os.Lstat(tfDir) 13 | return err == nil 14 | } 15 | 16 | func ClearLocalConfiguration() error { 17 | return os.RemoveAll(tfDir) 18 | } 19 | -------------------------------------------------------------------------------- /cmd/cancellable_action.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | ) 9 | 10 | func CancellableAction(fn func(ctx context.Context) error) error { 11 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 12 | defer stop() 13 | return fn(ctx) 14 | } 15 | -------------------------------------------------------------------------------- /config/nullstone_dir.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path" 7 | ) 8 | 9 | var ( 10 | NullstoneDir string 11 | ) 12 | 13 | func init() { 14 | home, err := os.UserHomeDir() 15 | if err != nil { 16 | log.Fatalf("unable to find home directory: %s\n", err) 17 | } 18 | NullstoneDir = path.Join(home, ".nullstone") 19 | } 20 | -------------------------------------------------------------------------------- /cmd/profile_action.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | "gopkg.in/nullstone-io/go-api-client.v0" 6 | ) 7 | 8 | type ProfileFn func(cfg api.Config) error 9 | 10 | func ProfileAction(c *cli.Context, fn ProfileFn) error { 11 | _, cfg, err := SetupProfileCmd(c) 12 | if err != nil { 13 | return err 14 | } 15 | return fn(cfg) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | 17 | # IntelliJ 18 | .idea/ 19 | dist/ 20 | -------------------------------------------------------------------------------- /workspaces/init.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | func Init(ctx context.Context) error { 10 | process := "terraform" 11 | args := []string{ 12 | "init", 13 | "-reconfigure", 14 | } 15 | cmd := exec.CommandContext(ctx, process, args...) 16 | cmd.Stderr = os.Stderr 17 | cmd.Stdout = os.Stdout 18 | cmd.Stdin = os.Stdin 19 | return cmd.Run() 20 | } 21 | -------------------------------------------------------------------------------- /config/cleanse_api_key.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "strings" 4 | 5 | // CleanseApiKey removes characters like \r or \n that cause issues with authentication 6 | // These characters accidentally get introduced by users copy/pasting into a file usually 7 | func CleanseApiKey(apiKey string) string { 8 | apiKey = strings.Replace(apiKey, "\r", "", -1) 9 | apiKey = strings.Replace(apiKey, "\n", "", -1) 10 | return apiKey 11 | } 12 | -------------------------------------------------------------------------------- /app_urls/intent_workflow.go: -------------------------------------------------------------------------------- 1 | package app_urls 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/nullstone-io/go-api-client.v0" 6 | "gopkg.in/nullstone-io/go-api-client.v0/types" 7 | ) 8 | 9 | func GetIntentWorkflow(cfg api.Config, iw types.IntentWorkflow) string { 10 | u := GetBaseUrl(cfg) 11 | u.Path = fmt.Sprintf("orgs/%s/stacks/%d/envs/%d/activity/workflows/%d", 12 | iw.OrgName, iw.StackId, iw.EnvId, iw.Id) 13 | return u.String() 14 | } 15 | -------------------------------------------------------------------------------- /cmd/flag_profile.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var ProfileFlag = &cli.StringFlag{ 6 | Name: "profile", 7 | EnvVars: []string{"NULLSTONE_PROFILE"}, 8 | Value: "default", 9 | Usage: "Name of the profile to use for the operation", 10 | } 11 | 12 | func GetProfile(c *cli.Context) string { 13 | val := c.String(ProfileFlag.Name) 14 | if val == "" { 15 | return ProfileFlag.Value 16 | } 17 | return val 18 | } 19 | -------------------------------------------------------------------------------- /cmd/cli_os_writers.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/nullstone-io/deployment-sdk/logging" 5 | "github.com/urfave/cli/v2" 6 | "io" 7 | ) 8 | 9 | var _ logging.OsWriters = CliOsWriters{} 10 | 11 | type CliOsWriters struct { 12 | Context *cli.Context 13 | } 14 | 15 | func (c CliOsWriters) Stdout() io.Writer { 16 | return c.Context.App.Writer 17 | } 18 | 19 | func (c CliOsWriters) Stderr() io.Writer { 20 | return c.Context.App.ErrWriter 21 | } 22 | -------------------------------------------------------------------------------- /app_urls/run.go: -------------------------------------------------------------------------------- 1 | package app_urls 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/nullstone-io/go-api-client.v0" 6 | "gopkg.in/nullstone-io/go-api-client.v0/types" 7 | ) 8 | 9 | func GetRun(cfg api.Config, workspace types.Workspace, run types.Run) string { 10 | u := GetBaseUrl(cfg) 11 | u.Path = fmt.Sprintf("orgs/%s/stacks/%d/envs/%d/blocks/%d/activity/runs/%s", 12 | workspace.OrgName, workspace.StackId, workspace.EnvId, workspace.BlockId, run.Uid) 13 | return u.String() 14 | } 15 | -------------------------------------------------------------------------------- /aws/ssm/plugin_windows.go: -------------------------------------------------------------------------------- 1 | package ssm 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | var ( 9 | osSessionManagerPluginPath string 10 | ) 11 | 12 | func init() { 13 | homeDir, _ := os.UserHomeDir() 14 | absHomeDir, _ := filepath.Abs(homeDir) 15 | vol := filepath.VolumeName(absHomeDir) 16 | if vol == "" { 17 | vol = "C:" 18 | } 19 | osSessionManagerPluginPath = filepath.Join(vol, "Program Files", "Amazon", "SessionManagerPlugin", "bin", "session-manager-plugin") 20 | } 21 | -------------------------------------------------------------------------------- /app_urls/base_url.go: -------------------------------------------------------------------------------- 1 | package app_urls 2 | 3 | import ( 4 | "gopkg.in/nullstone-io/go-api-client.v0" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | func GetBaseUrl(cfg api.Config) *url.URL { 10 | u, err := url.Parse(cfg.BaseAddress) 11 | if err != nil { 12 | u = &url.URL{Scheme: "https", Host: "app.nullstone.io"} 13 | } 14 | u.Host = strings.Replace(u.Host, "api", "app", 1) 15 | if u.Host == "localhost:8443" { 16 | u.Scheme = "http" 17 | u.Host = "localhost:8090" 18 | } 19 | return u 20 | } 21 | -------------------------------------------------------------------------------- /workspaces/scan_local.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import "github.com/nullstone-io/module/config" 4 | 5 | // ScanLocal scans the working directory to build a module manifest 6 | // This is useful if a user is actively making changes to a module that have not been published 7 | func ScanLocal(dir string) (*config.Manifest, error) { 8 | tfconfig, err := config.ParseDir(".") 9 | if err != nil { 10 | return nil, err 11 | } 12 | manifest := tfconfig.ToManifest() 13 | return &manifest, nil 14 | } 15 | -------------------------------------------------------------------------------- /cmd/flag_app_source.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var AppSourceFlag = &cli.StringFlag{ 6 | Name: "source", 7 | Usage: `The source artifact to push that contains your application's build. 8 | For a container, specify the name of the docker image to push. This follows the same syntax as 'docker push NAME[:TAG]'. 9 | For a serverless zip application, specify the .zip archive to push. 10 | For a static site, specify the directory to push.`, 11 | Required: true, 12 | } 13 | -------------------------------------------------------------------------------- /cmd/flag_stack.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var StackFlag = &cli.StringFlag{ 6 | Name: "stack", 7 | Usage: "Scope this operation to a specific stack. This is only required if there are multiple blocks/apps with the same name.", 8 | EnvVars: []string{"NULLSTONE_STACK"}, 9 | } 10 | 11 | var StackRequiredFlag = &cli.StringFlag{ 12 | Name: "stack", 13 | Usage: "Name of the stack to use for this operation", 14 | EnvVars: []string{"NULLSTONE_STACK"}, 15 | Required: true, 16 | } 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := nullstone 2 | 3 | .PHONY: setup test docs 4 | 5 | .DEFAULT_GOAL: default 6 | 7 | default: setup 8 | 9 | setup: 10 | cd ~ && go get gotest.tools/gotestsum && cd - 11 | brew install goreleaser/tap/goreleaser || (cd ~ && go install github.com/goreleaser/goreleaser && cd -) 12 | 13 | build: 14 | goreleaser --snapshot --skip-publish --rm-dist 15 | 16 | test: 17 | go fmt ./... 18 | gotestsum ./... 19 | 20 | docs: 21 | go run ./docs/main.go 22 | 23 | upgrade-aws: 24 | go get -u github.com/aws/aws-sdk-go-v2/... 25 | -------------------------------------------------------------------------------- /app_urls/workspace_workflow.go: -------------------------------------------------------------------------------- 1 | package app_urls 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/nullstone-io/go-api-client.v0" 6 | "gopkg.in/nullstone-io/go-api-client.v0/types" 7 | ) 8 | 9 | func GetWorkspaceWorkflow(cfg api.Config, ww types.WorkspaceWorkflow, isApp bool) string { 10 | u := GetBaseUrl(cfg) 11 | blockType := "blocks" 12 | if isApp { 13 | blockType = "apps" 14 | } 15 | u.Path = fmt.Sprintf("orgs/%s/stacks/%d/envs/%d/%s/%d/activity/workflows/%d", 16 | ww.OrgName, ww.StackId, ww.EnvId, blockType, ww.BlockId, ww.Id) 17 | return u.String() 18 | } 19 | -------------------------------------------------------------------------------- /vcs/get_git_repo.go: -------------------------------------------------------------------------------- 1 | package vcs 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/go-git/go-git/v5" 7 | "os" 8 | ) 9 | 10 | func GetGitRepo() (*git.Repository, error) { 11 | curDir, err := os.Getwd() 12 | if err != nil { 13 | return nil, fmt.Errorf("unable to find git repository: %w", err) 14 | } 15 | repo, err := git.PlainOpenWithOptions(curDir, &git.PlainOpenOptions{ 16 | DetectDotGit: true, 17 | EnableDotGitCommonDir: true, 18 | }) 19 | if errors.Is(err, git.ErrRepositoryNotExists) { 20 | return nil, nil 21 | } 22 | return repo, err 23 | } 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /runs/set_module_version.go: -------------------------------------------------------------------------------- 1 | package runs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gopkg.in/nullstone-io/go-api-client.v0" 7 | "gopkg.in/nullstone-io/go-api-client.v0/types" 8 | ) 9 | 10 | func SetModuleVersion(ctx context.Context, cfg api.Config, workspace types.Workspace, input types.WorkspaceModuleInput) error { 11 | client := api.Client{Config: cfg} 12 | err := client.WorkspaceModule().Update(ctx, workspace.StackId, workspace.BlockId, workspace.EnvId, input) 13 | if err != nil { 14 | return fmt.Errorf("failed to update workspace variables: %w", err) 15 | } 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /cmd/flag_app.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var AppFlag = &cli.StringFlag{ 6 | Name: "app", 7 | Usage: "Name of the app to use for this operation", 8 | EnvVars: []string{"NULLSTONE_APP"}, 9 | // TODO: Set to required once we fully deprecate parsing app as first command arg 10 | // Required: true, 11 | } 12 | 13 | func GetApp(c *cli.Context) string { 14 | appName := c.String(AppFlag.Name) 15 | // TODO: Drop parsing of first command arg as app once fully deprecated 16 | if appName == "" && c.NArg() >= 1 { 17 | appName = c.Args().Get(0) 18 | } 19 | return appName 20 | } 21 | -------------------------------------------------------------------------------- /admin/provider.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | "github.com/nullstone-io/deployment-sdk/app" 6 | "github.com/nullstone-io/deployment-sdk/logging" 7 | "github.com/nullstone-io/deployment-sdk/outputs" 8 | ) 9 | 10 | type NewStatuserFunc func(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (Statuser, error) 11 | type NewRemoterFunc func(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (Remoter, error) 12 | 13 | type Provider struct { 14 | NewStatuser NewStatuserFunc 15 | NewRemoter NewRemoterFunc 16 | } 17 | -------------------------------------------------------------------------------- /aws/ssm/session_parameters.go: -------------------------------------------------------------------------------- 1 | package ssm 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/nullstone-io/nullstone.v0/config" 6 | ) 7 | 8 | func SessionParametersFromPortForwards(pfs []config.PortForward) (map[string][]string, error) { 9 | switch len(pfs) { 10 | case 0: 11 | return nil, nil 12 | case 1: 13 | m := map[string][]string{ 14 | "localPort": {pfs[0].LocalPort}, 15 | "portNumber": {pfs[0].RemotePort}, 16 | } 17 | if pfs[0].RemoteHost != "" { 18 | m["host"] = []string{pfs[0].RemoteHost} 19 | } 20 | return m, nil 21 | default: 22 | return nil, fmt.Errorf("AWS does not support more than one port forward.") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cmd/watch_action.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "github.com/gosuri/uilive" 7 | "io" 8 | "time" 9 | ) 10 | 11 | func WatchAction(ctx context.Context, watchInterval time.Duration, fn func(w io.Writer) error) error { 12 | writer := uilive.New() 13 | writer.Start() 14 | defer writer.Stop() 15 | for { 16 | buf := bytes.NewBufferString("") 17 | if err := fn(buf); err != nil { 18 | return err 19 | } 20 | io.Copy(writer, buf) 21 | if watchInterval <= 0*time.Second { 22 | return nil 23 | } 24 | select { 25 | case <-ctx.Done(): 26 | return nil 27 | case <-time.After(watchInterval): 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /aws/ssm/get_document_name.go: -------------------------------------------------------------------------------- 1 | package ssm 2 | 3 | import "github.com/aws/aws-sdk-go-v2/aws" 4 | 5 | var ( 6 | DocNameStartSSH = "AWS-StartSSHSession" 7 | DocNamePortForward = "AWS-StartPortForwardingSession" 8 | DocNamePortForwardToRemoteHost = "AWS-StartPortForwardingSessionToRemoteHost" 9 | ) 10 | 11 | func GetDocumentName(parameters map[string][]string) *string { 12 | if _, forward := parameters["portNumber"]; forward { 13 | if _, remote := parameters["host"]; remote { 14 | return aws.String(DocNamePortForwardToRemoteHost) 15 | } 16 | return aws.String(DocNamePortForward) 17 | } 18 | return aws.String(DocNameStartSSH) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/flag_app_version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | "gopkg.in/nullstone-io/nullstone.v0/vcs" 6 | ) 7 | 8 | var AppVersionFlag = &cli.StringFlag{ 9 | Name: "version", 10 | Usage: `Provide a label for your deployment. 11 | If not provided, it will default to the commit sha of the repo for the current directory.`, 12 | } 13 | 14 | func DetectAppVersion(c *cli.Context) string { 15 | version := c.String("version") 16 | if version == "" { 17 | // If user does not specify a version, use HEAD commit sha 18 | if hash, err := vcs.GetCurrentCommitSha(); err == nil && len(hash) >= 7 { 19 | return hash[0:7] 20 | } 21 | } 22 | return version 23 | } 24 | -------------------------------------------------------------------------------- /vcs/get_commit_sha.go: -------------------------------------------------------------------------------- 1 | package vcs 2 | 3 | func GetCurrentShortCommitSha() (string, error) { 4 | sha, err := GetCurrentCommitSha() 5 | if err != nil { 6 | return "", err 7 | } 8 | maxLength := 7 9 | if len(sha) < maxLength { 10 | maxLength = len(sha) 11 | } 12 | return sha[0:maxLength], nil 13 | } 14 | 15 | func GetCurrentCommitSha() (string, error) { 16 | repo, err := GetGitRepo() 17 | if err != nil { 18 | return "", err 19 | } 20 | if repo == nil { 21 | return "", nil 22 | } 23 | ref, err := repo.Head() 24 | if err != nil { 25 | return "", err 26 | } 27 | hash := ref.Hash() 28 | if hash.IsZero() { 29 | return "", nil 30 | } 31 | return hash.String(), nil 32 | } 33 | -------------------------------------------------------------------------------- /nullstone/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "gopkg.in/nullstone-io/nullstone.v0/app" 8 | "os" 9 | ) 10 | 11 | var ( 12 | version = "dev" 13 | commit = "none" 14 | date = "unknown" 15 | builtBy = "unknown" 16 | ) 17 | 18 | func main() { 19 | cliApp := app.Build() 20 | cliApp.Version = version 21 | cliApp.Metadata = map[string]interface{}{ 22 | "commit": commit, 23 | "date": date, 24 | "builtBy": builtBy, 25 | } 26 | 27 | err := cliApp.Run(os.Args) 28 | if err != nil { 29 | if errors.Is(err, context.Canceled) { 30 | os.Exit(0) 31 | } 32 | fmt.Fprintln(os.Stderr, err) 33 | os.Exit(1) 34 | } else { 35 | os.Exit(0) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/port_forward.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func ParsePortForward(arg string) (PortForward, error) { 9 | tokens := strings.SplitN(arg, ":", 3) 10 | switch len(tokens) { 11 | case 2: 12 | return PortForward{ 13 | LocalPort: tokens[0], 14 | RemoteHost: "", 15 | RemotePort: tokens[1], 16 | }, nil 17 | case 3: 18 | return PortForward{ 19 | LocalPort: tokens[0], 20 | RemoteHost: tokens[1], 21 | RemotePort: tokens[2], 22 | }, nil 23 | default: 24 | return PortForward{}, fmt.Errorf("expected :[]:") 25 | } 26 | } 27 | 28 | type PortForward struct { 29 | LocalPort string 30 | RemoteHost string 31 | RemotePort string 32 | } 33 | -------------------------------------------------------------------------------- /aws/ecs/exec_command.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | nsaws "github.com/nullstone-io/deployment-sdk/aws" 6 | "gopkg.in/nullstone-io/nullstone.v0/aws/ssm" 7 | "strings" 8 | ) 9 | 10 | func ExecCommand(ctx context.Context, infra Outputs, taskId string, containerName string, cmd []string, parameters map[string][]string) error { 11 | region := infra.Region 12 | cluster := infra.ClusterArn() 13 | if containerName == "" { 14 | containerName = infra.MainContainerName 15 | } 16 | if len(cmd) == 0 { 17 | cmd = []string{"/bin/sh"} 18 | } 19 | awsConfig := nsaws.NewConfig(infra.Deployer, region) 20 | 21 | return ssm.StartEcsSession(ctx, awsConfig, region, cluster, taskId, containerName, strings.Join(cmd, " "), parameters) 22 | } 23 | -------------------------------------------------------------------------------- /git/get_vcs_url.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | func GetVcsUrl(repo *git.Repository) (*url.URL, error) { 10 | if repo == nil { 11 | return nil, nil 12 | } 13 | 14 | remote, err := repo.Remote("origin") 15 | if err != nil { 16 | return nil, err 17 | } 18 | if remote == nil || remote.Config() == nil { 19 | return nil, nil 20 | } 21 | urls := remote.Config().URLs 22 | if len(urls) < 1 { 23 | return nil, nil 24 | } 25 | 26 | clean := strings.TrimSuffix(urls[0], ".git") 27 | if strings.HasPrefix(urls[0], "git@github.com:") { 28 | clean = strings.Replace(urls[0], "git@github.com:", "https://github.com/", 1) 29 | } 30 | 31 | return url.Parse(clean) 32 | } 33 | -------------------------------------------------------------------------------- /modules/generate_subdomain.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "gopkg.in/nullstone-io/go-api-client.v0/types" 5 | ) 6 | 7 | var ( 8 | subdomainScaffoldTfFilename = "subdomain.tf" 9 | subdomainScaffoldTf = `data "ns_subdomain" "this" { 10 | stack_id = data.ns_workspace.this.stack_id 11 | block_id = data.ns_workspace.this.block_id 12 | } 13 | 14 | locals { 15 | subdomain_dns_name = data.ns_subdomain.this.dns_name 16 | } 17 | ` 18 | ) 19 | 20 | func generateSubdomain(manifest *types.ModuleManifest) error { 21 | if manifest.Category != string(types.CategorySubdomain) { 22 | // We don't generate capabilities if not a subdomain module 23 | return nil 24 | } 25 | return generateFile(subdomainScaffoldTfFilename, subdomainScaffoldTf) 26 | } 27 | -------------------------------------------------------------------------------- /git/root_dir.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "github.com/go-git/go-git/v5" 6 | "path/filepath" 7 | ) 8 | 9 | func GetRootDir(curDir string) (string, *git.Repository, error) { 10 | if curDir == "" { 11 | curDir = "." 12 | } 13 | repo, err := git.PlainOpen(curDir) 14 | if err != nil { 15 | if errors.Is(err, git.ErrRepositoryNotExists) { 16 | return "", nil, nil 17 | } 18 | return "", nil, err 19 | } 20 | 21 | worktree, err := repo.Worktree() 22 | if err != nil { 23 | if errors.Is(err, git.ErrIsBareRepository) { 24 | return "", repo, nil 25 | } 26 | return "", repo, err 27 | } 28 | 29 | dir, err := filepath.Abs(worktree.Filesystem.Root()) 30 | if err != nil { 31 | return "", nil, err 32 | } 33 | return dir, repo, nil 34 | } 35 | -------------------------------------------------------------------------------- /admin/statuser.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import "context" 4 | 5 | type Statuser interface { 6 | // Status returns a high-level status report on the specified app env 7 | Status(ctx context.Context) (StatusReport, error) 8 | 9 | // StatusDetail returns a detailed status report on the specified app env 10 | StatusDetail(ctx context.Context) (StatusDetailReports, error) 11 | } 12 | 13 | type StatusReport struct { 14 | Fields []string 15 | Data map[string]interface{} 16 | } 17 | 18 | type StatusDetailReports []StatusDetailReport 19 | 20 | type StatusDetailReport struct { 21 | Name string 22 | Records StatusRecords 23 | } 24 | 25 | type StatusRecords []StatusRecord 26 | 27 | type StatusRecord struct { 28 | Fields []string 29 | Data map[string]interface{} 30 | } 31 | -------------------------------------------------------------------------------- /modules/generate_domain.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "gopkg.in/nullstone-io/go-api-client.v0/types" 5 | ) 6 | 7 | var ( 8 | domainScaffoldTfFilename = "domain.tf" 9 | domainScaffoldTf = `data "ns_domain" "this" { 10 | stack_id = data.ns_workspace.this.stack_id 11 | block_id = data.ns_workspace.this.block_id 12 | } 13 | 14 | locals { 15 | domain_dns_name = data.ns_domain.this.dns_name 16 | domain_fqdn = "${local.domain_dns_name}." 17 | } 18 | ` 19 | ) 20 | 21 | func generateDomain(manifest *types.ModuleManifest) error { 22 | if manifest.Category != string(types.CategoryDomain) { 23 | // We don't generate capabilities if not a domain module 24 | return nil 25 | } 26 | return generateFile(domainScaffoldTfFilename, domainScaffoldTf) 27 | } 28 | -------------------------------------------------------------------------------- /aws/ecs/get_service.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | "github.com/aws/aws-sdk-go-v2/service/ecs" 7 | ecstypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" 8 | nsaws "github.com/nullstone-io/deployment-sdk/aws" 9 | ) 10 | 11 | func GetService(ctx context.Context, infra Outputs) (*ecstypes.Service, error) { 12 | ecsClient := ecs.NewFromConfig(nsaws.NewConfig(infra.Deployer, infra.Region)) 13 | out, err := ecsClient.DescribeServices(ctx, &ecs.DescribeServicesInput{ 14 | Services: []string{infra.ServiceName}, 15 | Cluster: aws.String(infra.ClusterArn()), 16 | }) 17 | if err != nil { 18 | return nil, err 19 | } 20 | if len(out.Services) > 0 { 21 | return &out.Services[0], nil 22 | } 23 | return nil, nil 24 | } 25 | -------------------------------------------------------------------------------- /modules/generate_capability.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "gopkg.in/nullstone-io/go-api-client.v0/types" 5 | ) 6 | 7 | var ( 8 | capabilityVarsTfFilename = `variables.tf` 9 | capabilityVarsTf = `variable "app_metadata" { 10 | description = < TF will add `https://` automatically 26 | hostname := strings.Replace(strings.Replace(cfg.BaseAddress, "https://", "", 1), "http://", "", 1) 27 | backend := fmt.Sprintf(backendTmpl, hostname, cfg.OrgName, workspaceUid) 28 | if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { 29 | return err 30 | } 31 | return os.WriteFile(filename, []byte(backend), 0644) 32 | } 33 | -------------------------------------------------------------------------------- /aws/ecs/get_tasks.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | "github.com/aws/aws-sdk-go-v2/service/ecs" 7 | nsaws "github.com/nullstone-io/deployment-sdk/aws" 8 | ) 9 | 10 | func GetTasks(ctx context.Context, infra Outputs) ([]string, error) { 11 | ecsClient := ecs.NewFromConfig(nsaws.NewConfig(infra.Deployer, infra.Region)) 12 | out, err := ecsClient.ListTasks(ctx, &ecs.ListTasksInput{ 13 | Cluster: aws.String(infra.ClusterArn()), 14 | ServiceName: aws.String(infra.ServiceName), 15 | }) 16 | if err != nil { 17 | return nil, err 18 | } 19 | return out.TaskArns, nil 20 | } 21 | 22 | func GetRandomTask(ctx context.Context, infra Outputs) (string, error) { 23 | taskArns, err := GetTasks(ctx, infra) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | for _, taskArn := range taskArns { 29 | return taskArn, nil 30 | } 31 | return "", nil 32 | } 33 | -------------------------------------------------------------------------------- /modules/next_patch_test.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestBumpPatch(t *testing.T) { 11 | tests := []struct { 12 | latest string 13 | want string 14 | }{ 15 | { 16 | latest: "1", 17 | want: "1.0.1", 18 | }, 19 | { 20 | latest: "1.0", 21 | want: "1.0.1", 22 | }, 23 | { 24 | latest: "1.0.0", 25 | want: "1.0.1", 26 | }, 27 | { 28 | latest: "1.2.0", 29 | want: "1.2.1", 30 | }, 31 | { 32 | latest: "1.2.0-pre", 33 | want: "1.2.1", 34 | }, 35 | { 36 | latest: "1.2.0-pre+build", 37 | want: "1.2.1", 38 | }, 39 | } 40 | 41 | for i, test := range tests { 42 | t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { 43 | got := BumpPatch(test.latest) 44 | got = strings.TrimPrefix(got, "v") 45 | assert.Equal(t, test.want, got) 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /cmd/app_env_context.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | type ParseAppEnvFn func(stackName, appName, envName string) error 9 | 10 | func ParseAppEnv(c *cli.Context, isEnvRequired bool, fn ParseAppEnvFn) error { 11 | stackName := c.String(StackFlag.Name) 12 | appName := GetApp(c) 13 | // TODO: Drop this validation once AppFlag.Required=true 14 | if appName == "" { 15 | cli.ShowCommandHelp(c, c.Command.Name) 16 | return fmt.Errorf("App Name is required to run this command. Use --app or NULLSTONE_APP env var.") 17 | } 18 | 19 | envName := GetEnvironment(c) 20 | // TODO: Drop this validation once EnvFlag.Required=true 21 | if isEnvRequired && envName == "" { 22 | cli.ShowCommandHelp(c, c.Command.Name) 23 | return fmt.Errorf("Environment Name is required to run this command. Use --env or NULLSTONE_ENV env var.") 24 | } 25 | 26 | return fn(stackName, appName, envName) 27 | } 28 | -------------------------------------------------------------------------------- /modules/package.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/nullstone-io/go-api-client.v0/types" 6 | "gopkg.in/nullstone-io/nullstone.v0/artifacts" 7 | ) 8 | 9 | var ( 10 | moduleFilePatterns = []string{ 11 | "*.tf", 12 | "*.tf.tmpl", 13 | ".terraform.lock.hcl", 14 | "README.md", 15 | "CHANGELOG.md", 16 | } 17 | excludes = map[string]struct{}{ 18 | "__backend__.tf": {}, 19 | } 20 | ) 21 | 22 | func Package(manifest *types.ModuleManifest, version string, addlFiles []string) (string, error) { 23 | excludeFn := func(entry artifacts.GlobEntry) bool { 24 | _, ok := excludes[entry.Path] 25 | return ok 26 | } 27 | 28 | tarballFilename := fmt.Sprintf("%s.tar.gz", manifest.Name) 29 | if version != "" { 30 | tarballFilename = fmt.Sprintf("%s-%s.tar.gz", manifest.Name, version) 31 | } 32 | allPatterns := append(moduleFilePatterns, addlFiles...) 33 | return tarballFilename, artifacts.PackageModule(".", tarballFilename, allPatterns, excludeFn) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/profile.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/urfave/cli/v2" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | ) 9 | 10 | var Profile = &cli.Command{ 11 | Name: "profile", 12 | Usage: "View the current profile and its configuration", 13 | UsageText: "nullstone profile", 14 | Action: func(c *cli.Context) error { 15 | ctx := context.TODO() 16 | return ProfileAction(c, func(cfg api.Config) error { 17 | fmt.Printf("Profile: %s\n", GetProfile(c)) 18 | fmt.Printf("API Address: %s\n", cfg.BaseAddress) 19 | accessToken, err := cfg.AccessTokenSource.GetAccessToken(ctx, cfg.OrgName) 20 | if err != nil { 21 | return err 22 | } 23 | if accessToken != "" { 24 | fmt.Printf("API Key: *** redacted ***\n") 25 | } else { 26 | fmt.Printf("API Key: (not set)\n") 27 | } 28 | fmt.Printf("Is Trace Enabled: %t\n", cfg.IsTraceEnabled) 29 | fmt.Printf("Org Name: %s\n", cfg.OrgName) 30 | fmt.Println() 31 | return nil 32 | }) 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /cmd/setup_profile_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | "gopkg.in/nullstone-io/go-api-client.v0" 6 | "gopkg.in/nullstone-io/go-api-client.v0/auth" 7 | "gopkg.in/nullstone-io/nullstone.v0/config" 8 | ) 9 | 10 | func SetupProfileCmd(c *cli.Context) (*config.Profile, api.Config, error) { 11 | profile, err := config.LoadProfile(GetProfile(c)) 12 | if err != nil { 13 | return nil, api.Config{}, err 14 | } 15 | 16 | cfg := api.DefaultConfig() 17 | if profile.Address != "" { 18 | cfg.BaseAddress = profile.Address 19 | } 20 | if profile.ApiKey != "" { 21 | cfg.AccessTokenSource = auth.RawAccessTokenSource{AccessToken: profile.ApiKey} 22 | } 23 | cfg.OrgName = GetOrg(c, *profile) 24 | if cfg.OrgName == "" { 25 | return profile, cfg, ErrMissingOrg 26 | } 27 | 28 | if rats, ok := cfg.AccessTokenSource.(auth.RawAccessTokenSource); ok { 29 | cfg.AccessTokenSource = auth.RawAccessTokenSource{AccessToken: config.CleanseApiKey(rats.AccessToken)} 30 | } 31 | 32 | return profile, cfg, nil 33 | } 34 | -------------------------------------------------------------------------------- /cmd/flag_env.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var EnvFlag = &cli.StringFlag{ 6 | Name: "env", 7 | Usage: `Name of the environment to use for this operation`, 8 | EnvVars: []string{"NULLSTONE_ENV"}, 9 | Required: true, 10 | } 11 | 12 | var OldEnvFlag = &cli.StringFlag{ 13 | Name: "env", 14 | Usage: `Name of the environment to use for this operation`, 15 | EnvVars: []string{"NULLSTONE_ENV"}, 16 | // TODO: Set to required once we fully deprecate parsing app as first command arg 17 | // Required: true, 18 | } 19 | 20 | var EnvOptionalFlag = &cli.StringFlag{ 21 | Name: "env", 22 | Usage: `Name of the environment to use for this operation`, 23 | EnvVars: []string{"NULLSTONE_ENV"}, 24 | Required: false, 25 | } 26 | 27 | func GetEnvironment(c *cli.Context) string { 28 | envName := c.String(OldEnvFlag.Name) 29 | // TODO: Drop parsing of second command arg as env once fully deprecated 30 | if envName == "" && c.NArg() >= 2 { 31 | envName = c.Args().Get(1) 32 | } 33 | return envName 34 | } 35 | -------------------------------------------------------------------------------- /cmd/flag_org.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "github.com/urfave/cli/v2" 6 | "gopkg.in/nullstone-io/nullstone.v0/config" 7 | ) 8 | 9 | var ( 10 | ErrMissingOrg = errors.New("An organization has not been configured with this profile. See 'nullstone set-org -h' for more details.") 11 | ) 12 | 13 | // OrgFlag defines a flag that the CLI uses 14 | // 15 | // to contextualize API calls by that organization within Nullstone 16 | // 17 | // The organization takes the following precedence: 18 | // 19 | // `--org` flag 20 | // `NULLSTONE_ORG` env var 21 | // `~/.nullstone//org` file 22 | var OrgFlag = &cli.StringFlag{ 23 | Name: "org", 24 | EnvVars: []string{"NULLSTONE_ORG"}, 25 | Usage: `Nullstone organization name to use for this operation. If this flag is not specified, the nullstone CLI will use ~/.nullstone//org file.`, 26 | } 27 | 28 | func GetOrg(c *cli.Context, profile config.Profile) string { 29 | val := c.String(OrgFlag.Name) 30 | if val == "" { 31 | val, _ = profile.LoadOrg() 32 | } 33 | return val 34 | } 35 | -------------------------------------------------------------------------------- /aws/beanstalk/get_instances.go: -------------------------------------------------------------------------------- 1 | package beanstalk 2 | 3 | import ( 4 | "context" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | "github.com/aws/aws-sdk-go-v2/service/elasticbeanstalk" 7 | ) 8 | 9 | func GetInstances(ctx context.Context, infra Outputs) ([]string, error) { 10 | client := elasticbeanstalk.NewFromConfig(infra.AdminerConfig()) 11 | out, err := client.DescribeEnvironmentResources(ctx, &elasticbeanstalk.DescribeEnvironmentResourcesInput{ 12 | EnvironmentId: aws.String(infra.EnvironmentId), 13 | }) 14 | if err != nil { 15 | return nil, err 16 | } 17 | instanceIds := make([]string, 0) 18 | for _, instance := range out.EnvironmentResources.Instances { 19 | instanceIds = append(instanceIds, *instance.Id) 20 | } 21 | return instanceIds, nil 22 | } 23 | 24 | func GetRandomInstance(ctx context.Context, infra Outputs) (string, error) { 25 | instanceIds, err := GetInstances(ctx, infra) 26 | if err != nil { 27 | return "", err 28 | } 29 | 30 | for _, instanceId := range instanceIds { 31 | return instanceId, nil 32 | } 33 | return "", nil 34 | } 35 | -------------------------------------------------------------------------------- /cmd/set_org.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/urfave/cli/v2" 6 | "gopkg.in/nullstone-io/nullstone.v0/config" 7 | "os" 8 | ) 9 | 10 | var SetOrg = &cli.Command{ 11 | Name: "set-org", 12 | Description: "Most Nullstone CLI commands require a configured nullstone organization to operate. This command will set the organization for the current profile. If you wish to set the organization per command, use the global `--org` flag instead.", 13 | Usage: "Set the organization for the CLI", 14 | UsageText: `nullstone set-org `, 15 | Flags: []cli.Flag{}, 16 | Action: func(c *cli.Context) error { 17 | profile, err := config.LoadProfile(GetProfile(c)) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | if c.NArg() != 1 { 23 | return cli.ShowCommandHelp(c, "set-org") 24 | } 25 | 26 | orgName := c.Args().Get(0) 27 | if err := profile.SaveOrg(orgName); err != nil { 28 | return err 29 | } 30 | fmt.Fprintf(os.Stderr, "Organization set to %s for %s profile\n", orgName, profile.Name) 31 | return nil 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /artifacts/find_latest_version_test.go: -------------------------------------------------------------------------------- 1 | package artifacts 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestFindLatestVersionSequence(t *testing.T) { 10 | t.Run("no artifacts", func(t *testing.T) { 11 | artifacts := []string{} 12 | sequence := FindLatestVersionSequence("8b0ce41", artifacts) 13 | assert.Equal(t, -1, sequence) 14 | }) 15 | t.Run("one artifact", func(t *testing.T) { 16 | artifacts := []string{"8b0ce41"} 17 | sequence := FindLatestVersionSequence("8b0ce41", artifacts) 18 | assert.Equal(t, 0, sequence) 19 | }) 20 | t.Run("multiple sequences", func(t *testing.T) { 21 | artifacts := []string{"8b0ce41", "8b0ce41-1", "8b0ce41-2"} 22 | sequence := FindLatestVersionSequence("8b0ce41", artifacts) 23 | assert.Equal(t, 2, sequence) 24 | }) 25 | t.Run("multiple artifact roots, multiple sequences", func(t *testing.T) { 26 | artifacts := []string{"8b0ce41", "8b0ce41-1", "aafb1de-1", "aafb1de-2", "aafb1de-3"} 27 | sequence := FindLatestVersionSequence("8b0ce41", artifacts) 28 | assert.Equal(t, 1, sequence) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /tfconfig/config_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package tfconfig 5 | 6 | import ( 7 | "path/filepath" 8 | "syscall" 9 | "unsafe" 10 | ) 11 | 12 | var ( 13 | shell = syscall.MustLoadDLL("Shell32.dll") 14 | getFolderPath = shell.MustFindProc("SHGetFolderPathW") 15 | ) 16 | 17 | const CSIDL_APPDATA = 26 18 | 19 | func configFile() (string, error) { 20 | dir, err := homeDir() 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | return filepath.Join(dir, "terraform.rc"), nil 26 | } 27 | 28 | func configDir() (string, error) { 29 | dir, err := homeDir() 30 | if err != nil { 31 | return "", err 32 | } 33 | 34 | return filepath.Join(dir, "terraform.d"), nil 35 | } 36 | 37 | func homeDir() (string, error) { 38 | b := make([]uint16, syscall.MAX_PATH) 39 | 40 | // See: http://msdn.microsoft.com/en-us/library/windows/desktop/bb762181(v=vs.85).aspx 41 | r, _, err := getFolderPath.Call(0, CSIDL_APPDATA, 0, 0, uintptr(unsafe.Pointer(&b[0]))) 42 | if uint32(r) != 0 { 43 | return "", err 44 | } 45 | 46 | return syscall.UTF16ToString(b), nil 47 | } 48 | -------------------------------------------------------------------------------- /modules/manifest.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/nullstone-io/go-api-client.v0/types" 6 | "gopkg.in/yaml.v3" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | func ManifestFromFile(filename string) (*types.ModuleManifest, error) { 12 | file, err := os.Open(filename) 13 | if err != nil { 14 | if os.IsNotExist(err) { 15 | return nil, fmt.Errorf("module manifest file %q does not exist", filename) 16 | } 17 | } 18 | defer file.Close() 19 | 20 | manifest := types.ModuleManifest{} 21 | decoder := yaml.NewDecoder(file) 22 | if err := decoder.Decode(&manifest); err != nil { 23 | return nil, fmt.Errorf("error decoding module manifest: %w", err) 24 | } 25 | return &manifest, nil 26 | } 27 | 28 | func WriteManifestToFile(m types.ModuleManifest, filename string) error { 29 | if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { 30 | return err 31 | } 32 | file, err := os.Create(filename) 33 | if err != nil { 34 | return err 35 | } 36 | defer file.Close() 37 | encoder := yaml.NewEncoder(file) 38 | defer encoder.Close() 39 | return encoder.Encode(m) 40 | } 41 | -------------------------------------------------------------------------------- /artifacts/targzer.go: -------------------------------------------------------------------------------- 1 | package artifacts 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | func NewTargzer(w io.Writer, name string) *Targzer { 11 | gzip := gzip.NewWriter(w) 12 | gzip.Name = name 13 | return &Targzer{ 14 | gzip: gzip, 15 | tarball: tar.NewWriter(gzip), 16 | } 17 | } 18 | 19 | type Targzer struct { 20 | gzip *gzip.Writer 21 | tarball *tar.Writer 22 | } 23 | 24 | func (t *Targzer) Close() { 25 | if t.tarball != nil { 26 | t.tarball.Close() 27 | } 28 | if t.gzip != nil { 29 | t.gzip.Close() 30 | } 31 | } 32 | 33 | func (t Targzer) AddFile(header *tar.Header, r io.Reader) error { 34 | if err := t.tarball.WriteHeader(header); err != nil { 35 | return fmt.Errorf("error writing file header %s: %w", header.Name, err) 36 | } 37 | if r == nil { 38 | // If no reader was specified, there is nothing to write 39 | // This happens when we are creating a directory 40 | return nil 41 | } 42 | _, err := io.Copy(t.tarball, r) 43 | if err != nil { 44 | return fmt.Errorf("error writing file into archive %s: %w", header.Name, err) 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /modules/register.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "context" 5 | "gopkg.in/nullstone-io/go-api-client.v0" 6 | "gopkg.in/nullstone-io/go-api-client.v0/types" 7 | ) 8 | 9 | func Register(ctx context.Context, cfg api.Config, manifest *types.ModuleManifest) (*types.Module, error) { 10 | module := &types.Module{ 11 | OrgName: manifest.OrgName, 12 | Name: manifest.Name, 13 | FriendlyName: manifest.FriendlyName, 14 | Description: manifest.Description, 15 | IsPublic: manifest.IsPublic, 16 | Category: types.CategoryName(manifest.Category), 17 | Subcategory: types.SubcategoryName(manifest.Subcategory), 18 | ProviderTypes: manifest.ProviderTypes, 19 | Platform: manifest.Platform, 20 | Subplatform: manifest.Subplatform, 21 | AppCategories: manifest.AppCategories, 22 | Type: manifest.Type, 23 | Status: types.ModuleStatusPublished, 24 | } 25 | 26 | client := api.Client{Config: cfg} 27 | if err := client.Modules().Create(ctx, module.OrgName, module); err != nil { 28 | return nil, err 29 | } 30 | return client.Modules().Get(ctx, module.OrgName, module.Name) 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nullstone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /k8s/exec_options.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "k8s.io/client-go/tools/remotecommand" 7 | "k8s.io/kubectl/pkg/util/interrupt" 8 | "k8s.io/kubectl/pkg/util/term" 9 | ) 10 | 11 | type ExecOptions struct { 12 | In io.Reader 13 | Out io.Writer 14 | ErrOut io.Writer 15 | TTY bool 16 | InterruptParent *interrupt.Handler 17 | PortMappings []string 18 | } 19 | 20 | func (o *ExecOptions) CreateTTY() (term.TTY, remotecommand.TerminalSizeQueue, error) { 21 | tty := term.TTY{ 22 | In: o.In, 23 | Out: o.Out, 24 | Raw: o.TTY, 25 | } 26 | if !tty.IsTerminalIn() { 27 | return term.TTY{}, nil, fmt.Errorf("unable to use a TTY - input is not a terminal or the right kind of file") 28 | } 29 | var sizeQueue remotecommand.TerminalSizeQueue 30 | if tty.Raw { 31 | // this call spawns a goroutine to monitor/update the terminal size 32 | sizeQueue = tty.MonitorSize(tty.GetSize()) 33 | 34 | // unset p.Err if it was previously set because both stdout and stderr go over p.Out when tty is 35 | // true 36 | o.ErrOut = nil 37 | } 38 | return tty, sizeQueue, nil 39 | } 40 | -------------------------------------------------------------------------------- /runs/set_run_config_vars.go: -------------------------------------------------------------------------------- 1 | package runs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gopkg.in/nullstone-io/go-api-client.v0" 7 | "gopkg.in/nullstone-io/go-api-client.v0/types" 8 | "strings" 9 | ) 10 | 11 | // SetConfigVars takes the input vars and stores them as workspace_changes via the API 12 | // TODO: once the api supports it, return any flags that aren't valid for the module version and were skipped 13 | func SetConfigVars(ctx context.Context, cfg api.Config, workspace types.Workspace, varFlags []string) error { 14 | var variables []types.VariableInput 15 | for _, varFlag := range varFlags { 16 | tokens := strings.SplitN(varFlag, "=", 2) 17 | if len(tokens) < 2 { 18 | // We skip any variables that don't have an `=` sign 19 | continue 20 | } 21 | name, value := tokens[0], tokens[1] 22 | variables = append(variables, types.VariableInput{Key: name, Value: value}) 23 | } 24 | 25 | client := api.Client{Config: cfg} 26 | err := client.WorkspaceVariables().Update(ctx, workspace.StackId, workspace.BlockId, workspace.EnvId, variables) 27 | if err != nil { 28 | return fmt.Errorf("failed to update workspace variables: %w", err) 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /cmd/app_workspace_action.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "github.com/nullstone-io/deployment-sdk/app" 6 | "github.com/urfave/cli/v2" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "log" 9 | ) 10 | 11 | type AppWorkspaceFn func(ctx context.Context, cfg api.Config, appDetails app.Details) error 12 | 13 | func AppWorkspaceAction(c *cli.Context, fn AppWorkspaceFn) error { 14 | _, cfg, err := SetupProfileCmd(c) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | return ParseAppEnv(c, true, func(stackName, appName, envName string) error { 20 | logger := log.New(c.App.ErrWriter, "", 0) 21 | logger.Printf("Performing application command (Org=%s, App=%s, Stack=%s, Env=%s)", cfg.OrgName, appName, stackName, envName) 22 | logger.Println() 23 | 24 | appDetails, err := FindAppDetails(cfg, appName, stackName, envName) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | return CancellableAction(func(ctx context.Context) error { 30 | return fn(ctx, cfg, app.Details{ 31 | App: appDetails.App, 32 | Env: appDetails.Env, 33 | Workspace: appDetails.Workspace, 34 | Module: appDetails.Module, 35 | }) 36 | }) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /nullstone.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.149", 3 | "architecture": { 4 | "32bit": { 5 | "url": "https://github.com/nullstone-io/nullstone/releases/download/v0.0.149/nullstone_0.0.149_windows_386.tar.gz", 6 | "bin": [ 7 | "nullstone.exe" 8 | ], 9 | "hash": "89b8ea96e6694ffc0b746b9b368a0ddea338c9866e54dc3c29c08b08c186b2d1" 10 | }, 11 | "64bit": { 12 | "url": "https://github.com/nullstone-io/nullstone/releases/download/v0.0.149/nullstone_0.0.149_windows_amd64.tar.gz", 13 | "bin": [ 14 | "nullstone.exe" 15 | ], 16 | "hash": "824d055afb2a7613b3787c24fe4df092dc9185e43a0b6ba77d084ee863b158c7" 17 | }, 18 | "arm64": { 19 | "url": "https://github.com/nullstone-io/nullstone/releases/download/v0.0.149/nullstone_0.0.149_windows_arm64.tar.gz", 20 | "bin": [ 21 | "nullstone.exe" 22 | ], 23 | "hash": "35d117f12acb4ff2cdc94622984509375152ffe1516f3b6d28196a549cb59acc" 24 | } 25 | }, 26 | "homepage": "https://nullstone.io", 27 | "license": "MIT", 28 | "description": "An internal developer platform running on your cloud" 29 | } 30 | -------------------------------------------------------------------------------- /artifacts/glob_many.go: -------------------------------------------------------------------------------- 1 | package artifacts 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | ) 10 | 11 | type GlobEntry struct { 12 | Pattern string 13 | Path string 14 | Info fs.FileInfo 15 | } 16 | 17 | // GlobMany performs a glob on many patterns and returns a set of entries (path and file info header) 18 | // The resulting set of entries is unique by filepath 19 | func GlobMany(dir string, patterns []string) (map[string]GlobEntry, error) { 20 | entries := map[string]GlobEntry{} 21 | for _, pattern := range patterns { 22 | fullPattern := pattern 23 | if dir != "" { 24 | fullPattern = path.Join(dir, pattern) 25 | } 26 | 27 | matches, err := filepath.Glob(fullPattern) 28 | if err != nil { 29 | return nil, fmt.Errorf("error globbing pattern %q: %w", fullPattern, err) 30 | } 31 | 32 | for _, match := range matches { 33 | if _, ok := entries[match]; ok { 34 | continue 35 | } 36 | info, err := os.Lstat(match) 37 | if err != nil { 38 | return nil, fmt.Errorf("error finding file information for %q: %w", match, err) 39 | } 40 | entries[match] = GlobEntry{ 41 | Pattern: pattern, 42 | Path: match, 43 | Info: info, 44 | } 45 | } 46 | } 47 | return entries, nil 48 | } 49 | -------------------------------------------------------------------------------- /admin/providers.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | "github.com/nullstone-io/deployment-sdk/app" 6 | "github.com/nullstone-io/deployment-sdk/contract" 7 | "github.com/nullstone-io/deployment-sdk/logging" 8 | "github.com/nullstone-io/deployment-sdk/outputs" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | ) 11 | 12 | type Providers map[types.ModuleContractName]Provider 13 | 14 | func (s Providers) FindStatuser(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (Statuser, error) { 15 | factory := s.FindFactory(*appDetails.Module) 16 | if factory == nil || factory.NewStatuser == nil { 17 | return nil, nil 18 | } 19 | return factory.NewStatuser(ctx, osWriters, source, appDetails) 20 | } 21 | 22 | func (s Providers) FindRemoter(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (Remoter, error) { 23 | factory := s.FindFactory(*appDetails.Module) 24 | if factory == nil || factory.NewRemoter == nil { 25 | return nil, nil 26 | } 27 | return factory.NewRemoter(ctx, osWriters, source, appDetails) 28 | } 29 | 30 | func (s Providers) FindFactory(curModule types.Module) *Provider { 31 | return contract.FindInRegistrarByModule(s, &curModule) 32 | } 33 | -------------------------------------------------------------------------------- /tfconfig/config_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package tfconfig 5 | 6 | import ( 7 | "errors" 8 | "os" 9 | "os/user" 10 | "path/filepath" 11 | ) 12 | 13 | func configFile() (string, error) { 14 | dir, err := homeDir() 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | return filepath.Join(dir, ".terraformrc"), nil 20 | } 21 | 22 | func configDir() (string, error) { 23 | dir, err := homeDir() 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | return filepath.Join(dir, ".terraform.d"), nil 29 | } 30 | 31 | func homeDir() (string, error) { 32 | // First prefer the HOME environmental variable 33 | if home := os.Getenv("HOME"); home != "" { 34 | // FIXME: homeDir gets called from globalPluginDirs during init, before 35 | // the logging is set up. We should move meta initializtion outside of 36 | // init, but in the meantime we just need to silence this output. 37 | //log.Printf("[DEBUG] Detected home directory from env var: %s", home) 38 | 39 | return home, nil 40 | } 41 | 42 | // If that fails, try build-in module 43 | user, err := user.Current() 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | if user.HomeDir == "" { 49 | return "", errors.New("blank output") 50 | } 51 | 52 | return user.HomeDir, nil 53 | } 54 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | goos: 7 | - linux 8 | - windows 9 | - darwin 10 | main: ./nullstone 11 | archives: 12 | - files: 13 | - README.md 14 | - LICENSE 15 | checksum: 16 | name_template: 'checksums.txt' 17 | snapshot: 18 | name_template: "{{ .Tag }}-next" 19 | changelog: 20 | sort: asc 21 | filters: 22 | exclude: 23 | - '^docs:' 24 | - '^test:' 25 | brews: 26 | - name: nullstone 27 | homepage: https://nullstone.io 28 | description: An internal developer platform running on your cloud 29 | license: MIT 30 | directory: Formula 31 | install: |- 32 | bin.install "nullstone" 33 | repository: 34 | owner: nullstone-io 35 | name: nullstone 36 | token: "{{ .Env.GITHUB_TOKEN }}" 37 | commit_author: 38 | name: nullstone-release-bot 39 | email: dev@nullstone.io 40 | 41 | scoops: 42 | - homepage: https://nullstone.io 43 | description: An internal developer platform running on your cloud 44 | license: MIT 45 | repository: 46 | owner: nullstone-io 47 | name: nullstone 48 | token: "{{ .Env.GITHUB_TOKEN }}" 49 | commit_author: 50 | name: nullstone-release-bot 51 | email: dev@nullstone.io 52 | -------------------------------------------------------------------------------- /aws/ssm/ecs.go: -------------------------------------------------------------------------------- 1 | package ssm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/service/ecs" 8 | "github.com/aws/aws-sdk-go-v2/service/ssm" 9 | ) 10 | 11 | func StartEcsSession(ctx context.Context, config aws.Config, region, cluster, taskId, containerName, cmd string, parameters map[string][]string) error { 12 | docName := GetDocumentName(parameters) 13 | 14 | ecsClient := ecs.NewFromConfig(config) 15 | input := &ecs.ExecuteCommandInput{ 16 | Cluster: aws.String(cluster), 17 | Task: aws.String(taskId), 18 | Container: aws.String(containerName), // TODO: Allow user to select which container 19 | Command: aws.String(cmd), 20 | Interactive: true, 21 | } 22 | out, err := ecsClient.ExecuteCommand(context.Background(), input) 23 | if err != nil { 24 | return fmt.Errorf("error establishing ecs execute command: %w", err) 25 | } 26 | 27 | target := ssm.StartSessionInput{ 28 | DocumentName: docName, 29 | Target: aws.String(fmt.Sprintf("ecs:%s_%s_%s", cluster, taskId, containerName)), 30 | Parameters: parameters, 31 | } 32 | 33 | er := ecs.NewDefaultEndpointResolver() 34 | endpoint, _ := er.ResolveEndpoint(region, ecs.EndpointResolverOptions{}) 35 | 36 | return StartSession(ctx, out.Session, target, region, endpoint.URL) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/flag_ssh.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | var InstanceFlag = &cli.StringFlag{ 6 | Name: "instance", 7 | Usage: `Select a specific instance to execute the command against. 8 | This allows the user to decide which instance to connect. 9 | This is optional and by default will connect to a random instance. 10 | This is only used for workspaces that use VMs (e.g. Elastic Beanstalk, EC2 Instances, GCP VMs, Azure VMs, etc.).`, 11 | } 12 | var TaskFlag = &cli.StringFlag{ 13 | Name: "task", 14 | Usage: `Select a specific task to execute the command against. 15 | This is optional and by default will connect to a random task. 16 | This is only used by ECS and determines which task to connect.`, 17 | } 18 | var PodFlag = &cli.StringFlag{ 19 | Name: "pod", 20 | Usage: `Select a pod to execute the command against. 21 | When specified, allows you to connect to a specific pod within a replica set. 22 | This is optional and will connect to a random pod by default. 23 | This is only used by Kubernetes clusters and determines which pod in the replica to connect.`, 24 | } 25 | 26 | var ContainerFlag = &cli.StringFlag{ 27 | Name: "container", 28 | Usage: `Select a specific container within a task or pod. 29 | If using sidecars, this allows you to connect to other containers besides the primary application container.`, 30 | } 31 | -------------------------------------------------------------------------------- /app/build.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | allApp "github.com/nullstone-io/deployment-sdk/app/all" 5 | "github.com/urfave/cli/v2" 6 | allAdmin "gopkg.in/nullstone-io/nullstone.v0/admin/all" 7 | "gopkg.in/nullstone-io/nullstone.v0/cmd" 8 | "sort" 9 | ) 10 | 11 | func Build() *cli.App { 12 | appProviders := allApp.Providers 13 | adminProviders := allAdmin.Providers 14 | 15 | cliApp := cli.NewApp() 16 | cliApp.EnableBashCompletion = true 17 | cliApp.Flags = []cli.Flag{ 18 | cmd.ProfileFlag, 19 | cmd.OrgFlag, 20 | } 21 | sort.Sort(cli.FlagsByName(cliApp.Flags)) 22 | cliApp.Commands = []*cli.Command{ 23 | { 24 | Name: "version", 25 | Action: func(c *cli.Context) error { 26 | cli.ShowVersion(c) 27 | return nil 28 | }, 29 | }, 30 | cmd.Configure, 31 | cmd.SetOrg, 32 | cmd.Stacks, 33 | cmd.Envs, 34 | cmd.Apps, 35 | cmd.Blocks, 36 | cmd.Modules, 37 | cmd.Workspaces, 38 | cmd.Iac, 39 | cmd.Up(), 40 | cmd.Plan(), 41 | cmd.Apply(), 42 | cmd.Outputs(), 43 | cmd.Wait(), 44 | cmd.Push(appProviders), 45 | cmd.Deploy(appProviders), 46 | cmd.Launch(appProviders), 47 | cmd.Logs(appProviders), 48 | cmd.Status(adminProviders), 49 | cmd.Exec(appProviders, adminProviders), 50 | cmd.Ssh(adminProviders), 51 | cmd.Run(appProviders, adminProviders), 52 | cmd.Profile, 53 | } 54 | sort.Sort(cli.CommandsByName(cliApp.Commands)) 55 | 56 | return cliApp 57 | } 58 | -------------------------------------------------------------------------------- /modules/next_patch.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "golang.org/x/mod/semver" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/find" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | // NextPatch bumps the patch in major.minor.patch from the latest module version 15 | func NextPatch(ctx context.Context, cfg api.Config, manifest *types.ModuleManifest) (string, error) { 16 | latestVersion, err := find.ModuleVersion(ctx, cfg, fmt.Sprintf("%s/%s", manifest.OrgName, manifest.Name), "latest") 17 | if err != nil { 18 | return "", fmt.Errorf("error retrieving latest version: %w", err) 19 | } else if latestVersion == nil { 20 | return BumpPatch("v0.0.0"), nil 21 | } 22 | return BumpPatch(latestVersion.Version), nil 23 | } 24 | 25 | func BumpPatch(version string) string { 26 | if !strings.HasPrefix(version, "v") { 27 | version = "v" + version 28 | } 29 | curPatch := getPatch(version) 30 | return fmt.Sprintf("%s.%d", semver.MajorMinor(version), curPatch+1) 31 | } 32 | 33 | func getPatch(version string) int { 34 | tokens := strings.SplitN(semver.Canonical(version), ".", 3) 35 | if len(tokens) < 3 { 36 | return 0 37 | } 38 | rawPatch := strings.TrimSuffix(tokens[2], semver.Prerelease(version)) 39 | patch, err := strconv.Atoi(rawPatch) 40 | if err != nil { 41 | return 0 42 | } 43 | return patch 44 | } 45 | -------------------------------------------------------------------------------- /admin/remoter.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | "context" 5 | "github.com/nullstone-io/deployment-sdk/app" 6 | "gopkg.in/nullstone-io/nullstone.v0/config" 7 | ) 8 | 9 | type RemoteOptions struct { 10 | // Instance refers to the VM Instance for remote access 11 | Instance string 12 | // Task refers to the ECS task id for remote access if using ECS 13 | Task string 14 | // Pod refers to the k8s pod for remote access if using k8s 15 | Pod string 16 | // Container represents the specific container name for remote access in the k8s pod or ecs task 17 | Container string 18 | PortForwards []config.PortForward 19 | Username string 20 | LogStreamer app.LogStreamer 21 | LogEmitter app.LogEmitter 22 | } 23 | 24 | type RunOptions struct { 25 | // Container represents the specific container name to execute against in the k8s pod/ecs task 26 | Container string 27 | Username string 28 | LogStreamer app.LogStreamer 29 | LogEmitter app.LogEmitter 30 | } 31 | 32 | type Remoter interface { 33 | // Exec allows a user to execute a command (usually tunneling) into a running service 34 | // This only makes sense for container-based providers 35 | Exec(ctx context.Context, options RemoteOptions, cmd []string) error 36 | 37 | // Ssh allows a user to SSH into a running service 38 | Ssh(ctx context.Context, options RemoteOptions) error 39 | 40 | // Run starts a new job/task and executes a command 41 | Run(ctx context.Context, options RunOptions, cmd []string) error 42 | } 43 | -------------------------------------------------------------------------------- /modules/generate.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "gopkg.in/nullstone-io/go-api-client.v0/types" 5 | "io/ioutil" 6 | ) 7 | 8 | type generateFunc func(manifest *types.ModuleManifest) error 9 | 10 | var ( 11 | scaffoldTfFilename = "nullstone.tf" 12 | baseScaffoldTf = `terraform { 13 | required_providers { 14 | ns = { 15 | source = "nullstone-io/ns" 16 | } 17 | } 18 | } 19 | 20 | data "ns_workspace" "this" {} 21 | 22 | // Generate a random suffix to ensure uniqueness of resources 23 | resource "random_string" "resource_suffix" { 24 | length = 5 25 | lower = true 26 | upper = false 27 | numeric = false 28 | special = false 29 | } 30 | 31 | locals { 32 | tags = data.ns_workspace.this.tags 33 | block_name = data.ns_workspace.this.block_name 34 | resource_name = "${data.ns_workspace.this.block_ref}-${random_string.resource_suffix.result}" 35 | } 36 | ` 37 | 38 | generateFns = []generateFunc{ 39 | generateScaffold, 40 | generateApp, 41 | generateCapability, 42 | generateSubdomain, 43 | generateDomain, 44 | } 45 | ) 46 | 47 | func Generate(manifest *types.ModuleManifest) error { 48 | for _, gfn := range generateFns { 49 | if err := gfn(manifest); err != nil { 50 | return err 51 | } 52 | } 53 | return nil 54 | } 55 | 56 | func generateScaffold(manifest *types.ModuleManifest) error { 57 | return generateFile(scaffoldTfFilename, baseScaffoldTf) 58 | } 59 | 60 | func generateFile(filename string, content string) error { 61 | return ioutil.WriteFile(filename, []byte(content), 0644) 62 | } 63 | -------------------------------------------------------------------------------- /nullstone/README.md: -------------------------------------------------------------------------------- 1 | ## How to install CLI 2 | 3 | This repository contains a CLI to manage Nullstone. 4 | This CLI works on any platform and requires no dependencies (unless you are building manually). 5 | Nullstone currently provides easy installs for Mac and Windows (Linux coming soon). 6 | 7 | ### Homebrew (Mac) 8 | 9 | ```shell 10 | brew tap nullstone-io/nullstone https://github.com/nullstone-io/nullstone.git 11 | brew install nullstone 12 | ``` 13 | 14 | ### Scoop (Windows) 15 | 16 | ```shell 17 | scoop bucket add nullstone https://github.com/nullstone-io/nullstone.git 18 | scoop install nullstone 19 | ``` 20 | 21 | ### Build and install manually 22 | 23 | This requires Go 1.17+. 24 | 25 | ```shell 26 | go install gopkg.in/nullstone-io/nullstone.v0/nullstone 27 | ``` 28 | 29 | ## Configure CLI 30 | 31 | Visit your [Nullstone Profile](https://app.nullstone.io/profile). 32 | Click "New API Key". 33 | Name your API Key (usually the name of your computer or the purpose of the API Key). 34 | 35 | Copy and run the command that is displayed in the dialog. 36 | ```shell 37 | nullstone configure --api-key=... 38 | ``` 39 | 40 | When you initially log in, Nullstone sets up a personal organization matching your username. 41 | To scope your nullstone commands to this organization, use the following: 42 | ```shell 43 | nullstone set-org 44 | ``` 45 | 46 | If you create or join an organization, you will need to use the same command to switch to that organization. 47 | ```shell 48 | nullstone set-org 49 | ``` 50 | -------------------------------------------------------------------------------- /git/git_ignore.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/go-git/go-git/v5" 7 | "io" 8 | "os" 9 | ) 10 | 11 | // FindGitIgnores will search for patterns in `.gitignore` 12 | // All missing and found patterns are returned 13 | func FindGitIgnores(repo *git.Repository, patterns []string) (found []string, missing []string) { 14 | found = make([]string, 0) 15 | missing = make([]string, 0) 16 | 17 | wt, err := repo.Worktree() 18 | if err != nil { 19 | return 20 | } 21 | 22 | filename := wt.Filesystem.Join(".gitignore") 23 | file, err := os.Open(filename) 24 | if err != nil { 25 | return 26 | } 27 | defer file.Close() 28 | 29 | existing := map[string]struct{}{} 30 | for scanner := bufio.NewScanner(file); scanner.Scan(); { 31 | existing[scanner.Text()] = struct{}{} 32 | } 33 | 34 | for _, pattern := range patterns { 35 | if _, ok := existing[pattern]; ok { 36 | found = append(found, pattern) 37 | } else { 38 | missing = append(missing, pattern) 39 | } 40 | } 41 | return 42 | } 43 | 44 | // AddGitIgnores will add patterns to `.gitignore` 45 | // Any issues with this are silently ignored 46 | func AddGitIgnores(repo *git.Repository, patterns []string) { 47 | wt, err := repo.Worktree() 48 | if err != nil { 49 | return 50 | } 51 | 52 | filename := wt.Filesystem.Join(".gitignore") 53 | file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) 54 | if err != nil { 55 | return 56 | } 57 | defer file.Close() 58 | 59 | for _, pattern := range patterns { 60 | io.WriteString(file, fmt.Sprintln(pattern)) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /cmd/block_workspace_action.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/urfave/cli/v2" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/find" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | "log" 11 | ) 12 | 13 | type BlockWorkspaceActionFn func(ctx context.Context, cfg api.Config, stack types.Stack, block types.Block, env types.Environment, workspace types.Workspace) error 14 | 15 | func BlockWorkspaceAction(c *cli.Context, fn BlockWorkspaceActionFn) error { 16 | ctx := context.TODO() 17 | 18 | _, cfg, err := SetupProfileCmd(c) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | stackName := c.String(StackFlag.Name) 24 | blockName := c.String(BlockFlag.Name) 25 | envName := c.String(EnvFlag.Name) 26 | 27 | sbe, err := find.StackBlockEnvByName(ctx, cfg, stackName, blockName, envName) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | logger := log.New(c.App.ErrWriter, "", 0) 33 | logger.Printf("Performing workspace command (Org=%s, Block=%s, Stack=%s, Env=%s)", cfg.OrgName, sbe.Block.Name, sbe.Stack.Name, sbe.Env.Name) 34 | logger.Println() 35 | 36 | client := api.Client{Config: cfg} 37 | workspace, err := client.Workspaces().Get(ctx, sbe.Stack.Id, sbe.Block.Id, sbe.Env.Id) 38 | if err != nil { 39 | return fmt.Errorf("error looking for workspace: %w", err) 40 | } else if workspace == nil { 41 | return fmt.Errorf("workspace not found") 42 | } 43 | 44 | return CancellableAction(func(ctx context.Context) error { 45 | return fn(ctx, cfg, sbe.Stack, sbe.Block, sbe.Env, *workspace) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /artifacts/find_latest_version.go: -------------------------------------------------------------------------------- 1 | package artifacts 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // FindLatestVersionSequence takes the provided shortSha and finds any versions in the list of artifacts. 9 | // 10 | // a match is any artifact that starts with the shortSha 11 | // this will return the largest sequence number found 12 | // if no matches are found, this will return -1 13 | func FindLatestVersionSequence(shortSha string, artifacts []string) int { 14 | sequence := -1 15 | for _, artifact := range artifacts { 16 | // if we find an artifact with the same shortSha, we will increase the sequence if it is the largest 17 | if artifact == shortSha || strings.HasPrefix(artifact, shortSha) { 18 | // if it is an exact match, we have found the first deploy (which doesn't have a sequence appended) 19 | if artifact == shortSha { 20 | seq := 0 21 | if seq > sequence { 22 | sequence = seq 23 | } 24 | continue 25 | } 26 | 27 | // split the sha and sequence 28 | parts := strings.Split(artifact, "-") 29 | // if we don't get exactly 2 parts, this isn't the correct format so we will ignore 30 | if len(parts) != 2 { 31 | continue 32 | } 33 | sequenceStr := parts[1] 34 | seq, err := strconv.Atoi(sequenceStr) 35 | // if the second part isn't a number, this isn't the correct format so we will ignore 36 | if err != nil { 37 | continue 38 | } 39 | // if the sequence is larger than the current sequence, we will update the sequence 40 | if seq > sequence { 41 | sequence = seq 42 | } 43 | } 44 | } 45 | 46 | return sequence 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v5 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: "1.24" 21 | 22 | - name: Install Snapcraft 23 | uses: samuelmeuli/action-snapcraft@v2 24 | 25 | # This gives us more disk space to produce the release 26 | - name: Free disk space 27 | run: | 28 | sudo rm -rf /usr/share/dotnet 29 | sudo rm -rf /opt/ghc 30 | sudo rm -rf /usr/local/lib/android 31 | sudo apt-get clean 32 | df -h 33 | 34 | - name: Find version 35 | id: version 36 | run: echo "CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV 37 | 38 | - id: app-token 39 | uses: actions/create-github-app-token@v2 40 | with: 41 | app-id: ${{ vars.BOT_APP_ID }} 42 | private-key: ${{ secrets.BOT_PRIVATE_KEY }} 43 | 44 | - name: Download go modules 45 | run: go mod download 46 | 47 | - name: Run GoReleaser 48 | uses: goreleaser/goreleaser-action@v4 49 | with: 50 | version: latest 51 | args: release --clean 52 | env: 53 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 54 | CLI_VERSION: ${{ env.MODULE_VERSION }} 55 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }} 56 | -------------------------------------------------------------------------------- /aws/ssm/ec2.go: -------------------------------------------------------------------------------- 1 | package ssm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/service/ec2" 8 | "github.com/aws/aws-sdk-go-v2/service/ssm" 9 | ) 10 | 11 | // StartEc2Session initiates an interactive SSH session with an EC2 instance using SSM 12 | // See setup guide: https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started.html 13 | // In short, the following is necessary for this function to work 14 | // - ec2 instance has SSM agent installed and registered 15 | // - ec2 instance is configured with instance profile that has the AmazonSSMManagedInstanceCore policy attached (or an equivalent custom policy) 16 | // - config contains an AWS identity that has access to ssm:StartSession on the EC2 Instance 17 | func StartEc2Session(ctx context.Context, config aws.Config, region, instanceId string, parameters map[string][]string) error { 18 | docName := GetDocumentName(parameters) 19 | 20 | ssmClient := ssm.NewFromConfig(config) 21 | input := &ssm.StartSessionInput{ 22 | DocumentName: docName, 23 | Target: aws.String(instanceId), 24 | Reason: aws.String("nullstone exec"), 25 | } 26 | out, err := ssmClient.StartSession(ctx, input) 27 | if err != nil { 28 | return fmt.Errorf("error starting ssm session: %w", err) 29 | } 30 | 31 | target := ssm.StartSessionInput{ 32 | DocumentName: docName, 33 | Target: aws.String(instanceId), 34 | Parameters: parameters, 35 | } 36 | 37 | er := ec2.NewDefaultEndpointResolver() 38 | endpoint, _ := er.ResolveEndpoint(region, ec2.EndpointResolverOptions{}) 39 | 40 | return StartSession(ctx, out, target, region, endpoint.URL) 41 | } 42 | -------------------------------------------------------------------------------- /Formula/nullstone.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | # frozen_string_literal: true 3 | 4 | # This file was generated by GoReleaser. DO NOT EDIT. 5 | class Nullstone < Formula 6 | desc "An internal developer platform running on your cloud" 7 | homepage "https://nullstone.io" 8 | version "0.0.149" 9 | license "MIT" 10 | 11 | on_macos do 12 | if Hardware::CPU.intel? 13 | url "https://github.com/nullstone-io/nullstone/releases/download/v0.0.149/nullstone_0.0.149_darwin_amd64.tar.gz" 14 | sha256 "d0e2b324296bf7907142016fed22ba4253f3e0862ff41ef73ae9e0ef66ae4c77" 15 | 16 | def install 17 | bin.install "nullstone" 18 | end 19 | end 20 | if Hardware::CPU.arm? 21 | url "https://github.com/nullstone-io/nullstone/releases/download/v0.0.149/nullstone_0.0.149_darwin_arm64.tar.gz" 22 | sha256 "c26fc8a9faf1e307b3845c693bd5776403d238f6b2d7bc8240f21d7deac4a0d2" 23 | 24 | def install 25 | bin.install "nullstone" 26 | end 27 | end 28 | end 29 | 30 | on_linux do 31 | if Hardware::CPU.intel? && Hardware::CPU.is_64_bit? 32 | url "https://github.com/nullstone-io/nullstone/releases/download/v0.0.149/nullstone_0.0.149_linux_amd64.tar.gz" 33 | sha256 "7e33d1ec71d0be4580a97ece06ff239774f0c7c2d448188af3a28b96458c5711" 34 | def install 35 | bin.install "nullstone" 36 | end 37 | end 38 | if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? 39 | url "https://github.com/nullstone-io/nullstone/releases/download/v0.0.149/nullstone_0.0.149_linux_arm64.tar.gz" 40 | sha256 "78262f3c9bc652ba41ac040e1c65a0bc6e732c21b1671ac901c2474df5ddd5c8" 41 | def install 42 | bin.install "nullstone" 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /workspaces/manifest.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type Manifest struct { 10 | OrgName string `json:"orgName" yaml:"org_name"` 11 | 12 | StackId int64 `json:"stackId" yaml:"stack_id"` 13 | StackName string `json:"stackName" yaml:"stack_name"` 14 | 15 | BlockId int64 `json:"blockId" yaml:"block_id"` 16 | BlockName string `json:"blockName" yaml:"block_name"` 17 | BlockRef string `json:"blockRef" yaml:"block_ref"` 18 | 19 | EnvId int64 `json:"envId" yaml:"env_id"` 20 | EnvName string `json:"envName" yaml:"env_name"` 21 | 22 | WorkspaceUid string `json:"workspaceUid" yaml:"workspace_uid"` 23 | 24 | // CapabilityId 25 | // Deprecated 26 | CapabilityId int64 `json:"capabilityId,omitempty" yaml:"capability_id,omitempty"` 27 | 28 | CapabilityName string `json:"capabilityName,omitempty" yaml:"capability_name,omitempty"` 29 | 30 | Connections ManifestConnections `json:"connections" yaml:"connections"` 31 | } 32 | 33 | func (m Manifest) WriteToFile(filename string) error { 34 | if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { 35 | return err 36 | } 37 | file, err := os.Create(filename) 38 | if err != nil { 39 | return err 40 | } 41 | defer file.Close() 42 | encoder := yaml.NewEncoder(file) 43 | defer encoder.Close() 44 | return encoder.Encode(m) 45 | } 46 | 47 | type ManifestConnections map[string]ManifestConnectionTarget 48 | 49 | type ManifestConnectionTarget struct { 50 | StackId int64 `json:"stackId" yaml:"stack_id"` 51 | BlockId int64 `json:"blockId" yaml:"block_id"` 52 | BlockName string `json:"blockName" yaml:"block_name"` 53 | EnvId *int64 `json:"envId,omitempty" yaml:"env_id,omitempty"` 54 | } 55 | -------------------------------------------------------------------------------- /cmd/configure.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/urfave/cli/v2" 6 | "golang.org/x/crypto/ssh/terminal" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/nullstone.v0/config" 9 | "os" 10 | "syscall" 11 | ) 12 | 13 | var ( 14 | AddressFlag = &cli.StringFlag{ 15 | Name: "address", 16 | Value: api.DefaultAddress, 17 | Usage: "Specify the url for the Nullstone API.", 18 | } 19 | ApiKeyFlag = &cli.StringFlag{ 20 | Name: "api-key", 21 | Value: "", 22 | Usage: "Configure your personal API key that will be used to authenticate with the Nullstone API. You can generate an API key from your profile page.", 23 | } 24 | ) 25 | 26 | var Configure = &cli.Command{ 27 | Name: "configure", 28 | Description: "Establishes a profile and configures authentication for the CLI to use.", 29 | Usage: "Configure the nullstone CLI", 30 | UsageText: "nullstone configure --api-key=", 31 | Flags: []cli.Flag{ 32 | AddressFlag, 33 | ApiKeyFlag, 34 | }, 35 | Action: func(c *cli.Context) error { 36 | apiKey := c.String(ApiKeyFlag.Name) 37 | if apiKey == "" { 38 | fmt.Print("Enter API Key: ") 39 | rawApiKey, err := terminal.ReadPassword(int(syscall.Stdin)) 40 | if err != nil { 41 | return fmt.Errorf("error reading password: %w", err) 42 | } 43 | fmt.Println() 44 | apiKey = string(rawApiKey) 45 | } 46 | 47 | profile := config.Profile{ 48 | Name: GetProfile(c), 49 | Address: c.String(AddressFlag.Name), 50 | ApiKey: apiKey, 51 | } 52 | if err := profile.Save(); err != nil { 53 | return fmt.Errorf("error configuring profile: %w", err) 54 | } 55 | fmt.Fprintln(os.Stderr, "nullstone configured successfully!") 56 | return nil 57 | }, 58 | } 59 | -------------------------------------------------------------------------------- /aws/ec2/remoter.go: -------------------------------------------------------------------------------- 1 | package ec2 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "github.com/nullstone-io/deployment-sdk/logging" 8 | "github.com/nullstone-io/deployment-sdk/outputs" 9 | "gopkg.in/nullstone-io/nullstone.v0/admin" 10 | "gopkg.in/nullstone-io/nullstone.v0/aws/ssm" 11 | ) 12 | 13 | var ( 14 | _ admin.Remoter = Remoter{} 15 | ) 16 | 17 | func NewRemoter(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (admin.Remoter, error) { 18 | outs, err := outputs.Retrieve[Outputs](ctx, source, appDetails.Workspace, appDetails.WorkspaceConfig) 19 | if err != nil { 20 | return nil, err 21 | } 22 | outs.InitializeCreds(source, appDetails.Workspace) 23 | 24 | return Remoter{ 25 | OsWriters: osWriters, 26 | Details: appDetails, 27 | Infra: outs, 28 | }, nil 29 | } 30 | 31 | type Remoter struct { 32 | OsWriters logging.OsWriters 33 | Details app.Details 34 | Infra Outputs 35 | } 36 | 37 | func (r Remoter) Exec(ctx context.Context, options admin.RemoteOptions, cmd []string) error { 38 | // TODO: Add support for cmd 39 | return ssm.StartEc2Session(ctx, r.Infra.AdminerConfig(), r.Infra.Region, r.Infra.InstanceId, nil) 40 | } 41 | 42 | func (r Remoter) Ssh(ctx context.Context, options admin.RemoteOptions) error { 43 | parameters, err := ssm.SessionParametersFromPortForwards(options.PortForwards) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | return ssm.StartEc2Session(ctx, r.Infra.AdminerConfig(), r.Infra.Region, r.Infra.InstanceId, parameters) 49 | } 50 | 51 | func (r Remoter) Run(ctx context.Context, options admin.RunOptions, cmd []string) error { 52 | return fmt.Errorf("`run` is not supported for EC2 yet") 53 | } 54 | -------------------------------------------------------------------------------- /tfconfig/creds.go: -------------------------------------------------------------------------------- 1 | package tfconfig 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gopkg.in/nullstone-io/go-api-client.v0" 7 | "io/ioutil" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | credentialsFindTmpl = "credentials %q {" 14 | credentialsTmpl = `credentials %q { 15 | token = %q 16 | } 17 | ` 18 | ) 19 | 20 | func IsCredsConfigured(cfg api.Config) bool { 21 | credsFilename, err := GetCredentialsFilename() 22 | if err != nil { 23 | return false 24 | } 25 | 26 | hostname := getNullstoneHostname(cfg) 27 | raw, err := ioutil.ReadFile(credsFilename) 28 | if err != nil { 29 | return false 30 | } 31 | find := fmt.Sprintf(credentialsFindTmpl, hostname) 32 | return strings.Contains(string(raw), find) 33 | } 34 | 35 | // ConfigCreds configures Terraform with configuration to authenticate Terraform with Nullstone server 36 | // This configuration enables Terraform to: 37 | // - Configure `backend "remote"` to reach Nullstone state backend 38 | // - Download private modules from the Nullstone registry 39 | func ConfigCreds(ctx context.Context, cfg api.Config) error { 40 | credsFilename, err := GetCredentialsFilename() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | file, err := os.OpenFile(credsFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 46 | if err != nil { 47 | return err 48 | } 49 | defer file.Close() 50 | 51 | accessToken, err := cfg.AccessTokenSource.GetAccessToken(ctx, cfg.OrgName) 52 | if err != nil { 53 | return err 54 | } 55 | _, err = file.WriteString(fmt.Sprintf(credentialsTmpl, getNullstoneHostname(cfg), accessToken)) 56 | return err 57 | } 58 | 59 | func getNullstoneHostname(cfg api.Config) string { 60 | return strings.Replace(strings.Replace(cfg.BaseAddress, "https://", "", 1), "http://", "", 1) 61 | } 62 | -------------------------------------------------------------------------------- /cmd/table_buffer.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ryanuber/columnize" 6 | "strings" 7 | ) 8 | 9 | // TableBuffer builds a table of data to display on the terminal 10 | // The TableBuffer guarantees safe merging of rows with potentially different field names 11 | // Example: If a user is migrating an app from container to serverless, 12 | // 13 | // it's possible that the infrastructure has not fully propagated 14 | type TableBuffer struct { 15 | Fields []string 16 | HasField map[string]bool 17 | Data []map[string]interface{} 18 | } 19 | 20 | func (b *TableBuffer) AddFields(fields ...string) { 21 | if b.HasField == nil { 22 | b.HasField = map[string]bool{} 23 | } 24 | 25 | for _, field := range fields { 26 | if _, ok := b.HasField[field]; !ok { 27 | b.Fields = append(b.Fields, field) 28 | b.HasField[field] = true 29 | } 30 | } 31 | } 32 | 33 | func (b *TableBuffer) AddRow(data map[string]interface{}) { 34 | cur := map[string]interface{}{} 35 | for k, v := range data { 36 | b.AddFields(k) 37 | cur[k] = v 38 | } 39 | b.Data = append(b.Data, cur) 40 | } 41 | 42 | func (b *TableBuffer) Values() [][]string { 43 | all := make([][]string, 0) 44 | for _, arr := range b.Data { 45 | cur := make([]string, 0) 46 | for _, field := range b.Fields { 47 | if val, ok := arr[field]; ok { 48 | cur = append(cur, fmt.Sprintf("%s", val)) 49 | } else { 50 | cur = append(cur, "") 51 | } 52 | } 53 | all = append(all, cur) 54 | } 55 | return all 56 | } 57 | 58 | func (b *TableBuffer) String() string { 59 | colConfig := columnize.DefaultConfig() 60 | values := b.Values() 61 | lines := make([]string, len(values)+1) 62 | lines[0] = strings.Join(b.Fields, colConfig.Delim) 63 | for i, row := range values { 64 | lines[i+1] = strings.Join(row, colConfig.Delim) 65 | } 66 | return columnize.Format(lines, colConfig) 67 | } 68 | -------------------------------------------------------------------------------- /cmd/find_app_details.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/find" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | ) 11 | 12 | // FindAppDetails retrieves the app, env, and workspace 13 | // stackName is optional -- If multiple apps are found, this will return an error 14 | func FindAppDetails(cfg api.Config, appName, stackName, envName string) (app.Details, error) { 15 | ctx := context.TODO() 16 | appDetails := app.Details{} 17 | 18 | _, application, env, err := find.StackAppEnv(ctx, cfg, stackName, appName, envName) 19 | if err != nil { 20 | return appDetails, err 21 | } 22 | appDetails.App = application 23 | appDetails.Env = env 24 | 25 | if appDetails.Workspace, err = getAppWorkspace(cfg, appDetails.App, appDetails.Env); err != nil { 26 | return appDetails, err 27 | } 28 | 29 | if appDetails.Module, err = find.Module(ctx, cfg, appDetails.App.ModuleSource); err != nil { 30 | return appDetails, err 31 | } else if appDetails.Module == nil { 32 | return appDetails, fmt.Errorf("can't find app module %s", appDetails.App.ModuleSource) 33 | } 34 | 35 | return appDetails, nil 36 | } 37 | 38 | func getAppWorkspace(cfg api.Config, app *types.Application, env *types.Environment) (*types.Workspace, error) { 39 | ctx := context.TODO() 40 | client := api.Client{Config: cfg} 41 | 42 | workspace, err := client.Workspaces().Get(ctx, app.StackId, app.Id, env.Id) 43 | if err != nil { 44 | return nil, fmt.Errorf("error retrieving workspace: %w", err) 45 | } else if workspace == nil { 46 | return nil, fmt.Errorf("workspace %q does not exist", err) 47 | } 48 | if workspace.Status != types.WorkspaceStatusProvisioned { 49 | return nil, fmt.Errorf("app %q has not been provisioned in %q environment yet", app.Name, env.Name) 50 | } 51 | return workspace, nil 52 | } 53 | -------------------------------------------------------------------------------- /k8s/exec_command.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | corev1 "k8s.io/api/core/v1" 7 | "k8s.io/client-go/kubernetes/scheme" 8 | "k8s.io/client-go/rest" 9 | "k8s.io/client-go/tools/remotecommand" 10 | "net/http" 11 | ) 12 | 13 | func ExecCommand(ctx context.Context, cfg *rest.Config, podNamespace, podName, containerName string, cmd []string, opts *ExecOptions) error { 14 | tty, sizeQueue, err := opts.CreateTTY() 15 | if err != nil { 16 | return fmt.Errorf("unable to execute kubernetes command: %w", err) 17 | } 18 | 19 | portForwarder, err := NewPortForwarder(cfg, podNamespace, podName, opts.PortMappings) 20 | if err != nil { 21 | return fmt.Errorf("unable to create port forwarder: %w", err) 22 | } 23 | 24 | return tty.Safe(func() error { 25 | restClient, err := rest.RESTClientFor(cfg) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | req := restClient.Post(). 31 | Resource("pods"). 32 | Name(podName). 33 | Namespace(podNamespace). 34 | SubResource("exec") 35 | req.VersionedParams(&corev1.PodExecOptions{ 36 | Container: containerName, 37 | Command: cmd, 38 | Stdin: opts.In != nil, 39 | Stdout: opts.Out != nil, 40 | Stderr: opts.ErrOut != nil, 41 | TTY: tty.Raw, 42 | }, scheme.ParameterCodec) 43 | 44 | executor, err := remotecommand.NewSPDYExecutor(cfg, http.MethodPost, req.URL()) 45 | if err != nil { 46 | return fmt.Errorf("unable to create kubernetes remote executor: %w", err) 47 | } 48 | 49 | if portForwarder != nil { 50 | stop := make(chan struct{}, 1) 51 | defer close(stop) 52 | go portForwarder.ForwardPorts(stop, opts) 53 | } 54 | 55 | return executor.StreamWithContext(ctx, remotecommand.StreamOptions{ 56 | Stdin: opts.In, 57 | Stdout: opts.Out, 58 | Stderr: opts.ErrOut, 59 | Tty: opts.TTY, 60 | TerminalSizeQueue: sizeQueue, 61 | }) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /cmd/up.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/urfave/cli/v2" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/types" 9 | "gopkg.in/nullstone-io/nullstone.v0/runs" 10 | ) 11 | 12 | var Up = func() *cli.Command { 13 | return &cli.Command{ 14 | Name: "up", 15 | Description: "Launches the infrastructure for the given block/environment and its dependencies.", 16 | Usage: "Provisions the block and all of its dependencies", 17 | UsageText: "nullstone up [--stack=] --block= --env= [options]", 18 | Flags: []cli.Flag{ 19 | StackFlag, 20 | BlockFlag, 21 | EnvFlag, 22 | &cli.BoolFlag{ 23 | Name: "wait", 24 | Aliases: []string{"w"}, 25 | Usage: "Wait for the launch to complete and stream the Terraform logs to the console.", 26 | }, 27 | &cli.StringSliceFlag{ 28 | Name: "var", 29 | Usage: "Set variables values for the plan. This can be used to override variables defined in the module.", 30 | }, 31 | }, 32 | Action: func(c *cli.Context) error { 33 | varFlags := c.StringSlice("var") 34 | 35 | return BlockWorkspaceAction(c, func(ctx context.Context, cfg api.Config, stack types.Stack, block types.Block, env types.Environment, workspace types.Workspace) error { 36 | if workspace.Status == types.WorkspaceStatusProvisioned { 37 | fmt.Println("workspace is already provisioned") 38 | return nil 39 | } 40 | 41 | err := runs.SetConfigVars(ctx, cfg, workspace, varFlags) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | t := true 47 | input := PerformRunInput{ 48 | Workspace: workspace, 49 | CommitSha: "", 50 | IsApproved: &t, 51 | IsDestroy: false, 52 | BlockType: types.BlockType(block.Type), 53 | StreamLogs: c.IsSet("wait"), 54 | } 55 | return PerformRun(ctx, cfg, input) 56 | }) 57 | }, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /iac/discover.go: -------------------------------------------------------------------------------- 1 | package iac 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mitchellh/colorstring" 6 | "github.com/nullstone-io/iac" 7 | "gopkg.in/nullstone-io/nullstone.v0/git" 8 | "io" 9 | "net/url" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | var ( 15 | blankVcsUrl = url.URL{ 16 | Scheme: "https", 17 | Host: "localhost", 18 | Path: "local/repo", 19 | } 20 | ) 21 | 22 | func Discover(dir string, w io.Writer) (*iac.ConfigFiles, error) { 23 | pmr, err := parseIacFiles(dir) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | // Emit information about detected IaC files 29 | numFiles := len(pmr.Overrides) 30 | if pmr.Config != nil { 31 | numFiles++ 32 | } 33 | colorstring.Fprintf(w, "[bold]Found %d IaC files[reset]\n", numFiles) 34 | if cur := pmr.Config; cur != nil { 35 | relFilename, _ := filepath.Rel(dir, cur.IacContext.Filename) 36 | fmt.Fprintf(w, " 📂 %s\n", relFilename) 37 | } 38 | for _, cur := range pmr.Overrides { 39 | relFilename, _ := filepath.Rel(dir, cur.IacContext.Filename) 40 | fmt.Fprintf(w, " 📂 %s\n", relFilename) 41 | } 42 | fmt.Fprintln(w) 43 | return pmr, nil 44 | } 45 | 46 | func parseIacFiles(dir string) (*iac.ConfigFiles, error) { 47 | rootDir, repo, err := git.GetRootDir(dir) 48 | if err != nil { 49 | return nil, fmt.Errorf("error looking for repository root directory: %w", err) 50 | } else if rootDir == "" { 51 | rootDir = dir 52 | } 53 | 54 | repoUrl, err := git.GetVcsUrl(repo) 55 | if err != nil { 56 | return nil, fmt.Errorf("error trying to discover the repo url of the local repository: %w", err) 57 | } 58 | if repoUrl == nil { 59 | repoUrl = &blankVcsUrl 60 | } 61 | 62 | repoName := strings.TrimPrefix(repoUrl.Path, "/") 63 | pmr, err := iac.ParseConfigDir(repoUrl.String(), repoName, filepath.Join(rootDir, ".nullstone")) 64 | if err != nil { 65 | return nil, fmt.Errorf("error parsing nullstone IaC files: %w", err) 66 | } 67 | return pmr, nil 68 | } 69 | -------------------------------------------------------------------------------- /k8s/port_forwarder.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | corev1 "k8s.io/api/core/v1" 6 | "k8s.io/client-go/kubernetes/scheme" 7 | "k8s.io/client-go/rest" 8 | "k8s.io/client-go/tools/portforward" 9 | "k8s.io/client-go/transport/spdy" 10 | "net/http" 11 | "os" 12 | ) 13 | 14 | type PortForwarder struct { 15 | Transport http.RoundTripper 16 | Upgrader spdy.Upgrader 17 | Request *rest.Request 18 | } 19 | 20 | func NewPortForwarder(cfg *rest.Config, podNamespace, podName string, portMappings []string) (*PortForwarder, error) { 21 | if len(portMappings) < 1 { 22 | return nil, nil 23 | } 24 | 25 | restClient, err := rest.RESTClientFor(cfg) 26 | if err != nil { 27 | return nil, fmt.Errorf("cannot create rest client: %w", err) 28 | } 29 | req := restClient.Post(). 30 | Resource("pods"). 31 | Namespace(podNamespace). 32 | Name(podName). 33 | SubResource("portforward"). 34 | VersionedParams(&corev1.PodPortForwardOptions{}, scheme.ParameterCodec) 35 | 36 | transport, upgrader, err := spdy.RoundTripperFor(cfg) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to create SPDY transport: %w", err) 39 | } 40 | 41 | return &PortForwarder{ 42 | Transport: transport, 43 | Upgrader: upgrader, 44 | Request: req, 45 | }, nil 46 | } 47 | 48 | func (f *PortForwarder) ForwardPorts(stop <-chan struct{}, opts *ExecOptions) { 49 | if len(opts.PortMappings) < 1 { 50 | return 51 | } 52 | 53 | stderr := opts.ErrOut 54 | if stderr == nil { 55 | stderr = os.Stderr 56 | } 57 | 58 | ready := make(chan struct{}) 59 | dialer := spdy.NewDialer(f.Upgrader, &http.Client{Transport: f.Transport}, http.MethodPost, f.Request.URL()) 60 | fw, err := portforward.New(dialer, opts.PortMappings, stop, ready, opts.Out, opts.ErrOut) 61 | if err != nil { 62 | fmt.Fprintf(stderr, "error forwarding ports: %s\n", err) 63 | return 64 | } 65 | 66 | if err := fw.ForwardPorts(); err != nil { 67 | fmt.Fprintf(stderr, "port forwarding stopped: %s\n", err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /gcp/gke/outputs.go: -------------------------------------------------------------------------------- 1 | package gke 2 | 3 | import ( 4 | "github.com/nullstone-io/deployment-sdk/docker" 5 | "github.com/nullstone-io/deployment-sdk/gcp" 6 | "github.com/nullstone-io/deployment-sdk/gcp/creds" 7 | "github.com/nullstone-io/deployment-sdk/gcp/gke" 8 | "github.com/nullstone-io/deployment-sdk/outputs" 9 | nstypes "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api" 11 | ) 12 | 13 | type Outputs struct { 14 | ServiceNamespace string `ns:"service_namespace"` 15 | ServiceName string `ns:"service_name"` 16 | ImageRepoUrl docker.ImageUrl `ns:"image_repo_url,optional"` 17 | Deployer gcp.ServiceAccount `ns:"deployer"` 18 | MainContainerName string `ns:"main_container_name,optional"` 19 | // JobDefinitionName is only specified for a job/task 20 | // It refers to a Kubernetes ConfigMap containing the job definition in the "template" field 21 | JobDefinitionName string `ns:"job_definition_name,optional"` 22 | 23 | ClusterNamespace ClusterNamespaceOutputs `ns:",connectionContract:cluster-namespace/gcp/k8s:gke"` 24 | } 25 | 26 | func (o *Outputs) InitializeCreds(source outputs.RetrieverSource, ws *nstypes.Workspace) { 27 | o.Deployer.RemoteTokenSourcer = creds.NewTokenSourcer(source, ws.StackId, ws.Uid, "deployer") 28 | } 29 | 30 | type ClusterNamespaceOutputs struct { 31 | ClusterEndpoint string `ns:"cluster_endpoint"` 32 | ClusterCACertificate string `ns:"cluster_ca_certificate"` 33 | } 34 | 35 | func (o ClusterNamespaceOutputs) ClusterInfo() (clientcmdapi.Cluster, error) { 36 | return gke.GetClusterInfo(o.ClusterEndpoint, o.ClusterCACertificate) 37 | } 38 | 39 | type ClusterOutputs struct { 40 | ClusterEndpoint string `ns:"cluster_endpoint"` 41 | ClusterCACertificate string `ns:"cluster_ca_certificate"` 42 | } 43 | 44 | func (o ClusterOutputs) ClusterInfo() (clientcmdapi.Cluster, error) { 45 | return gke.GetClusterInfo(o.ClusterEndpoint, o.ClusterCACertificate) 46 | } 47 | -------------------------------------------------------------------------------- /artifacts/package_module.go: -------------------------------------------------------------------------------- 1 | package artifacts 2 | 3 | import ( 4 | "archive/tar" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // PackageModule creates a tar.gz containing the module files 11 | // 'filename' allows a developer to specify where to write the tar.gz 12 | // 'patterns' allows a developer to specify which file patterns are included in the tar.gz 13 | // This is more effective than the built-in tar command because it won't fail if a pattern doesn't match any files 14 | func PackageModule(dir, filename string, patterns []string, excludeFn func(entry GlobEntry) bool) error { 15 | targzFile, err := os.Create(filename) 16 | if err != nil { 17 | return fmt.Errorf("error creating module package: %w", err) 18 | } 19 | defer targzFile.Close() 20 | output := NewTargzer(targzFile, filename) 21 | defer output.Close() 22 | 23 | entries, err := GlobMany(dir, patterns) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | addEntry := func(entry GlobEntry) error { 29 | if entry.Path == dir { 30 | return nil 31 | } 32 | if excludeFn != nil && excludeFn(entry) { 33 | // Skip files that match exclude function 34 | fmt.Fprintf(os.Stderr, "excluding %q\n", entry.Path) 35 | return nil 36 | } else { 37 | fmt.Fprintf(os.Stderr, "packaging %q\n", entry.Path) 38 | } 39 | relPath, err := filepath.Rel(dir, entry.Path) 40 | if err != nil { 41 | return fmt.Errorf("error deciphering relative path of tar file: %w", err) 42 | } 43 | header, err := tar.FileInfoHeader(entry.Info, relPath) 44 | if err != nil { 45 | return fmt.Errorf("error creating file header %s: %w", relPath, err) 46 | } 47 | header.Name = relPath 48 | file, err := os.Open(entry.Path) 49 | if err != nil { 50 | return fmt.Errorf("error opening file to package into archive %s: %w", relPath, err) 51 | } 52 | defer file.Close() 53 | return output.AddFile(header, file) 54 | } 55 | 56 | for _, entry := range entries { 57 | if err := addEntry(entry); err != nil { 58 | return err 59 | } 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /workspaces/run_config.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/google/uuid" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/types" 9 | ) 10 | 11 | // GetRunConfig loads the effective run config for a workspace 12 | // This does the following: 13 | // 1. Pull the latest run config for the workspace 14 | // 2. Scan module in local file system for `ns_connection` that have not been added to run config 15 | func GetRunConfig(ctx context.Context, cfg api.Config, workspace Manifest) (types.RunConfig, error) { 16 | client := api.Client{Config: cfg} 17 | uid, _ := uuid.Parse(workspace.WorkspaceUid) 18 | runConfig, err := client.RunConfigs().GetLatest(ctx, workspace.StackId, uid) 19 | if err != nil { 20 | return types.RunConfig{}, err 21 | } else if runConfig == nil { 22 | runConfig = &types.RunConfig{ 23 | WorkspaceUid: uid, 24 | Targets: types.RunTargets{}, 25 | WorkspaceConfig: types.WorkspaceConfig{ 26 | Source: "", 27 | SourceVersion: "", 28 | Variables: types.Variables{}, 29 | Connections: types.Connections{}, 30 | Capabilities: types.CapabilityConfigs{}, 31 | Providers: types.Providers{}, 32 | Dependencies: types.Dependencies{}, 33 | }, 34 | } 35 | } 36 | 37 | // Scan module in local file system 38 | localManifest, err := ScanLocal(".") 39 | if err != nil { 40 | return *runConfig, fmt.Errorf("could not scan local module: %w", err) 41 | } 42 | 43 | // Look for new connections locally that aren't present in the workspace's run config 44 | for name, local := range localManifest.Connections { 45 | _, ok := runConfig.Connections[name] 46 | if !ok { 47 | // Connection exists in local scan, but not in run config 48 | // Let's add the definition with an empty target 49 | runConfig.Connections[name] = types.Connection{ 50 | Connection: local, 51 | DesiredTarget: nil, 52 | EffectiveTarget: nil, 53 | Unused: false, 54 | } 55 | } 56 | } 57 | return *runConfig, nil 58 | } 59 | -------------------------------------------------------------------------------- /cmd/perform_run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | api_runs "gopkg.in/nullstone-io/go-api-client.v0/runs" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | "gopkg.in/nullstone-io/nullstone.v0/app_urls" 11 | "gopkg.in/nullstone-io/nullstone.v0/runs" 12 | "os" 13 | ) 14 | 15 | type PerformRunInput struct { 16 | Workspace types.Workspace 17 | CommitSha string 18 | IsApproved *bool 19 | IsDestroy bool 20 | BlockType types.BlockType 21 | StreamLogs bool 22 | } 23 | 24 | func PerformRun(ctx context.Context, cfg api.Config, input PerformRunInput) error { 25 | result, err := api_runs.Create(ctx, cfg, input.Workspace, input.CommitSha, input.IsApproved, input.IsDestroy, "") 26 | if err != nil { 27 | return fmt.Errorf("error creating run: %w", err) 28 | } else if result == nil { 29 | return fmt.Errorf("unable to create run") 30 | } 31 | 32 | var newRun types.Run 33 | if result.IntentWorkflow != nil { 34 | // When creating runs, we should have a primary workflow already 35 | pw := result.IntentWorkflow.PrimaryWorkflow 36 | if pw == nil { 37 | return fmt.Errorf("no primary workflow found") 38 | } 39 | fmt.Fprintf(os.Stdout, "created workflow run (id = %d)\n", pw.Id) 40 | fmt.Fprintln(os.Stdout, app_urls.GetWorkspaceWorkflow(cfg, *pw, input.BlockType == types.BlockTypeApplication)) 41 | if newRun, err = waitForWorkspaceWorkflowRun(ctx, cfg, *pw); err != nil { 42 | return fmt.Errorf("error waiting for workflow run: %w", err) 43 | } 44 | } else if result.Run != nil { 45 | newRun = *result.Run 46 | fmt.Fprintf(os.Stdout, "created run %q\n", newRun.Uid) 47 | fmt.Fprintln(os.Stdout, app_urls.GetRun(cfg, input.Workspace, newRun)) 48 | } else { 49 | return fmt.Errorf("run was not created") 50 | } 51 | 52 | if input.StreamLogs { 53 | err := runs.StreamLogs(ctx, cfg, input.Workspace, &newRun) 54 | if errors.Is(err, runs.ErrRunDisapproved) { 55 | // Disapproved plans are expected, return no error 56 | return nil 57 | } 58 | return err 59 | } 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "github.com/nullstone-io/deployment-sdk/logging" 8 | "github.com/nullstone-io/deployment-sdk/outputs" 9 | "github.com/urfave/cli/v2" 10 | "gopkg.in/nullstone-io/go-api-client.v0" 11 | "gopkg.in/nullstone-io/nullstone.v0/admin" 12 | "os" 13 | ) 14 | 15 | var Run = func(appProviders app.Providers, providers admin.Providers) *cli.Command { 16 | return &cli.Command{ 17 | Name: "run", 18 | Description: "Starts a new container/serverless for the given Nullstone job/task. ", 19 | Usage: "Starts a new job/task", 20 | UsageText: "nullstone run [--stack=] --app= --env= [options] [command]", 21 | Flags: []cli.Flag{ 22 | StackFlag, 23 | AppFlag, 24 | EnvFlag, 25 | ContainerFlag, 26 | }, 27 | Action: func(c *cli.Context) error { 28 | var cmd []string 29 | if c.Args().Present() { 30 | cmd = c.Args().Slice() 31 | } 32 | 33 | return AppWorkspaceAction(c, func(ctx context.Context, cfg api.Config, appDetails app.Details) error { 34 | client := api.Client{Config: cfg} 35 | user, err := client.CurrentUser().Get(ctx) 36 | if err != nil { 37 | return fmt.Errorf("unable to fetch the current user") 38 | } 39 | if user == nil { 40 | return fmt.Errorf("unable to load the current user info") 41 | } 42 | 43 | source := outputs.ApiRetrieverSource{Config: cfg} 44 | 45 | logStreamer, err := appProviders.FindLogStreamer(ctx, logging.StandardOsWriters{}, source, appDetails) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | remoter, err := providers.FindRemoter(ctx, logging.StandardOsWriters{}, source, appDetails) 51 | if err != nil { 52 | return err 53 | } 54 | options := admin.RunOptions{ 55 | Container: c.String("container"), 56 | Username: user.Name, 57 | LogStreamer: logStreamer, 58 | LogEmitter: app.NewWriterLogEmitter(os.Stdout), 59 | } 60 | return remoter.Run(ctx, options, cmd) 61 | }) 62 | }, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /cmd/create_deploy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/nullstone-io/deployment-sdk/app" 9 | "gopkg.in/nullstone-io/go-api-client.v0" 10 | "gopkg.in/nullstone-io/nullstone.v0/artifacts" 11 | ) 12 | 13 | func CreateDeploy(nsConfig api.Config, appDetails app.Details, info artifacts.VersionInfo) (*api.DeployCreateResult, error) { 14 | ctx := context.TODO() 15 | client := api.Client{Config: nsConfig} 16 | payload := api.DeployCreatePayload{ 17 | FromSource: false, 18 | Version: info.EffectiveVersion, 19 | CommitSha: info.CommitInfo.CommitSha, 20 | AutomationTool: detectAutomationTool(), 21 | } 22 | result, err := client.Deploys().Create(ctx, appDetails.App.StackId, appDetails.App.Id, appDetails.Env.Id, payload) 23 | if err != nil { 24 | return nil, fmt.Errorf("error creating deploy: %w", err) 25 | } else if result == nil { 26 | return nil, fmt.Errorf("unable to create deploy") 27 | } 28 | return result, nil 29 | } 30 | 31 | func detectAutomationTool() string { 32 | if os.Getenv("CIRCLECI") != "" { 33 | return api.AutomationToolCircleCI 34 | } 35 | if os.Getenv("GITHUB_ACTIONS") != "" { 36 | return api.AutomationToolGithubActions 37 | } 38 | if os.Getenv("GITLAB_CI") != "" { 39 | return api.AutomationToolGitlab 40 | } 41 | if os.Getenv("BITBUCKET_PIPELINES") != "" { 42 | return api.AutomationToolBitbucket 43 | } 44 | if os.Getenv("JENKINS_URL") != "" { 45 | return api.AutomationToolJenkins 46 | } 47 | if os.Getenv("TRAVIS") != "" { 48 | return api.AutomationToolTravis 49 | } 50 | if os.Getenv("TF_BUILD") != "" { 51 | // TF_BUILD is not referring to Terraform, it's legacy from the original system called "Team Foundation" 52 | return api.AutomationToolAzurePipelines 53 | } 54 | if os.Getenv("APPVEYOR") != "" { 55 | return api.AutomationToolAppveyor 56 | } 57 | if os.Getenv("TEAMCITY_VERSION") != "" { 58 | return api.AutomationToolTeamCity 59 | } 60 | if os.Getenv("CI_NAME") != "codeship" { 61 | return api.AutomationToolCodeship 62 | } 63 | if os.Getenv("SEMAPHORE") != "" { 64 | return api.AutomationToolSemaphore 65 | } 66 | return "" 67 | } 68 | -------------------------------------------------------------------------------- /gcp/gke/get_pod_name.go: -------------------------------------------------------------------------------- 1 | package gke 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | v1 "k8s.io/api/core/v1" 7 | meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/client-go/kubernetes" 9 | "k8s.io/client-go/rest" 10 | ) 11 | 12 | // GetPodName finds a pod based on the current infrastructure and an optional pod name 13 | // If pod name is left blank, this will find either the only active pod or first active pod in a replica set 14 | func GetPodName(ctx context.Context, cfg *rest.Config, infra Outputs, pod string) (string, error) { 15 | name := infra.ServiceName 16 | kubeClient, err := kubernetes.NewForConfig(cfg) 17 | 18 | // If pod is specified, let's verify it exists and is running 19 | if pod != "" { 20 | pod, err := kubeClient.CoreV1().Pods(infra.ServiceNamespace).Get(ctx, pod, meta_v1.GetOptions{}) 21 | if err != nil { 22 | return "", err 23 | } 24 | if pod == nil { 25 | return "", fmt.Errorf("could not find pod (%s) in kubernetes cluster", pod) 26 | } 27 | if pod.Status.Phase != v1.PodRunning { 28 | return "", fmt.Errorf("pod (%s) is not running (current pod status = %s)", pod, pod.Status.Phase) 29 | } 30 | return pod.Name, nil 31 | } 32 | 33 | // If replicas>1, the pods have unique names, but the replicaset has name= 34 | // Let's look for pods by replicaset first 35 | listOptions := meta_v1.ListOptions{LabelSelector: fmt.Sprintf("replicaset=%s", name)} 36 | podsOutput, err := kubeClient.CoreV1().Pods(infra.ServiceNamespace).List(ctx, listOptions) 37 | if err != nil { 38 | return "", err 39 | } 40 | for _, pod := range podsOutput.Items { 41 | if pod.Status.Phase == v1.PodRunning { 42 | return pod.Name, nil 43 | } 44 | } 45 | 46 | // If we don't find a replica, look for the pod by name directly 47 | listOptions = meta_v1.ListOptions{LabelSelector: fmt.Sprintf("nullstone.io/app=%s", name)} 48 | podsOutput, err = kubeClient.CoreV1().Pods(infra.ServiceNamespace).List(ctx, listOptions) 49 | if err != nil { 50 | return "", err 51 | } 52 | for _, pod := range podsOutput.Items { 53 | if pod.Status.Phase == v1.PodRunning { 54 | return pod.Name, nil 55 | } 56 | } 57 | 58 | return name, nil 59 | } 60 | -------------------------------------------------------------------------------- /artifacts/version_info.go: -------------------------------------------------------------------------------- 1 | package artifacts 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | 8 | "github.com/nullstone-io/deployment-sdk/app" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | "gopkg.in/nullstone-io/nullstone.v0/vcs" 11 | ) 12 | 13 | type VersionInfo struct { 14 | DesiredVersion string 15 | EffectiveVersion string 16 | CommitInfo types.CommitInfo 17 | } 18 | 19 | func GetVersionInfoFromWorkingDir(desiredVersion string) (VersionInfo, error) { 20 | info := VersionInfo{DesiredVersion: desiredVersion} 21 | 22 | var err error 23 | if info.CommitInfo, err = vcs.GetCommitInfo(); err != nil { 24 | return VersionInfo{}, fmt.Errorf("error retrieving commit info from .git/: %w", err) 25 | } 26 | if info.DesiredVersion == "" { 27 | info.DesiredVersion = shortCommitSha(info.CommitInfo.CommitSha) 28 | } 29 | 30 | return info, nil 31 | } 32 | 33 | type VersionDeconflictor struct { 34 | versions []string 35 | } 36 | 37 | func NewVersionDeconflictor(ctx context.Context, pusher app.Pusher) (*VersionDeconflictor, error) { 38 | versions, err := pusher.ListArtifactVersions(ctx) 39 | if err != nil { 40 | return nil, fmt.Errorf("error retrieving list of artifact versions: %w", err) 41 | } 42 | return &VersionDeconflictor{versions: versions}, nil 43 | } 44 | 45 | // CreateUnique calculates a new version from the input version if the version already exists 46 | // If the version already exists, the version is returned unchanged 47 | func (d *VersionDeconflictor) CreateUnique(version string) string { 48 | seq := FindLatestVersionSequence(version, d.versions) 49 | 50 | // -1 means we didn't find any existing versions matching the input version 51 | // use the input version 52 | if seq == -1 { 53 | return version 54 | } 55 | 56 | // Otherwise, we need to calculate a new version 57 | return fmt.Sprintf("%s-%d", version, seq+1) 58 | } 59 | 60 | func (d *VersionDeconflictor) DoesVersionExist(version string) bool { 61 | return slices.Contains(d.versions, version) 62 | } 63 | 64 | func shortCommitSha(commitSha string) string { 65 | maxLength := 7 66 | if len(commitSha) < maxLength { 67 | maxLength = len(commitSha) 68 | } 69 | return commitSha[0:maxLength] 70 | } 71 | -------------------------------------------------------------------------------- /cmd/outputs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/urfave/cli/v2" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/types" 9 | "os" 10 | ) 11 | 12 | // Outputs command retrieves outputs from a workspace (block+env) 13 | var Outputs = func() *cli.Command { 14 | return &cli.Command{ 15 | Name: "outputs", 16 | Description: "Print all the module outputs for a given block and environment. Provide the `--sensitive` flag to include sensitive outputs in the results. You must have proper permissions in order to use the `--sensitive` flag. For less information in an easier to read format, use the `--plain` flag.", 17 | Usage: "Retrieve outputs", 18 | UsageText: "nullstone outputs [--stack=] --block= --env= [options]", 19 | Flags: []cli.Flag{ 20 | StackFlag, 21 | BlockFlag, 22 | EnvFlag, 23 | &cli.BoolFlag{ 24 | Name: "sensitive", 25 | Usage: "Include sensitive outputs in the results", 26 | }, 27 | &cli.BoolFlag{ 28 | Name: "plain", 29 | Usage: "Print less information about the outputs in a more readable format", 30 | }, 31 | }, 32 | Action: func(c *cli.Context) error { 33 | return BlockWorkspaceAction(c, func(ctx context.Context, cfg api.Config, stack types.Stack, block types.Block, env types.Environment, workspace types.Workspace) error { 34 | client := api.Client{Config: cfg} 35 | showSensitive := c.IsSet("sensitive") 36 | outputs, err := client.WorkspaceOutputs().GetCurrent(ctx, stack.Id, workspace.Uid, showSensitive) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | for key, output := range outputs { 42 | if output.Redacted { 43 | output.Value = "(hidden)" 44 | outputs[key] = output 45 | } 46 | } 47 | 48 | encoder := json.NewEncoder(os.Stdout) 49 | encoder.SetIndent("", " ") 50 | if c.IsSet("plain") { 51 | stripped := map[string]any{} 52 | for key, output := range outputs { 53 | stripped[key] = output.Value 54 | } 55 | encoder.Encode(stripped) 56 | } else { 57 | encoder.Encode(outputs) 58 | } 59 | 60 | return nil 61 | }) 62 | }, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /aws/ssm/plugin.go: -------------------------------------------------------------------------------- 1 | package ssm 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/aws/aws-sdk-go-v2/service/ssm" 8 | "os" 9 | "os/exec" 10 | "os/signal" 11 | ) 12 | 13 | const ( 14 | sessionManagerBinary = "session-manager-plugin" 15 | sessionManagerUrl = "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html" 16 | ) 17 | 18 | func StartSession(ctx context.Context, session interface{}, target ssm.StartSessionInput, region, endpointUrl string) error { 19 | process, err := getSessionManagerPluginPath() 20 | if err != nil { 21 | return fmt.Errorf("could not find AWS session-manager-plugin: %w", err) 22 | } 23 | 24 | sessionJsonRaw, _ := json.Marshal(session) 25 | targetRaw, _ := json.Marshal(target) 26 | args := []string{ 27 | string(sessionJsonRaw), 28 | region, 29 | "StartSession", 30 | "", // empty profile name 31 | string(targetRaw), 32 | endpointUrl, 33 | } 34 | ctx = context.Background() // Ignore signal cancellations on the context 35 | 36 | cmd := exec.CommandContext(ctx, process, args...) 37 | cmd.Stderr = os.Stderr 38 | cmd.Stdout = os.Stdout 39 | cmd.Stdin = os.Stdin 40 | if err := cmd.Start(); err != nil { 41 | return err 42 | } 43 | 44 | done := make(chan any) 45 | defer close(done) 46 | forwardSignals(done, cmd.Process) 47 | return cmd.Wait() 48 | } 49 | 50 | func forwardSignals(done chan any, process *os.Process) { 51 | ch := make(chan os.Signal, 1) 52 | signal.Notify(ch) 53 | go func() { 54 | select { 55 | case <-done: 56 | return 57 | case sig := <-ch: 58 | process.Signal(sig) 59 | } 60 | }() 61 | } 62 | 63 | // getSessionManagerPluginPath attempts to find "session-manager-plugin" 64 | // If it's in PATH, will simply return binary name 65 | // If not, will attempt OS-specific locations 66 | func getSessionManagerPluginPath() (string, error) { 67 | if _, err := exec.LookPath(sessionManagerBinary); err == nil { 68 | return sessionManagerBinary, nil 69 | } 70 | if _, err := os.Stat(osSessionManagerPluginPath); err != nil { 71 | return "", fmt.Errorf("Could not find session-manager-plugin. Visit %q to install.", sessionManagerUrl) 72 | } 73 | return osSessionManagerPluginPath, nil 74 | } 75 | -------------------------------------------------------------------------------- /vcs/get_commit_info.go: -------------------------------------------------------------------------------- 1 | package vcs 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-git/go-git/v5/config" 6 | "gopkg.in/nullstone-io/go-api-client.v0/types" 7 | "net/url" 8 | "strings" 9 | ) 10 | 11 | func GetCommitInfo() (types.CommitInfo, error) { 12 | ci := types.CommitInfo{} 13 | 14 | repo, err := GetGitRepo() 15 | if err != nil { 16 | return ci, err 17 | } else if repo == nil { 18 | return ci, nil 19 | } 20 | 21 | ref, err := repo.Head() 22 | if err != nil { 23 | return ci, err 24 | } else if ref == nil { 25 | return ci, nil 26 | } 27 | ci.BranchName = ref.Name().Short() 28 | ci.CommitSha = ref.Hash().String() 29 | 30 | commit, err := repo.CommitObject(ref.Hash()) 31 | if err != nil { 32 | return ci, err 33 | } else if commit == nil { 34 | return ci, nil 35 | } 36 | ci.AuthorEmail = commit.Author.Email 37 | ci.AuthorUsername = commit.Author.Name 38 | ci.CommitMessage = commit.Message 39 | 40 | remotes, err := repo.Remotes() 41 | if err != nil { 42 | return ci, err 43 | } 44 | for _, remote := range remotes { 45 | rcfg := remote.Config() 46 | if rcfg.Name == "origin" { 47 | ci.Repository = extractApiRepository(rcfg) 48 | break 49 | } 50 | } 51 | ci.InferCommitUrl() 52 | 53 | return ci, nil 54 | } 55 | 56 | func extractApiRepository(cfg *config.RemoteConfig) types.Repo { 57 | repo := types.Repo{} 58 | if len(cfg.URLs) == 0 { 59 | return repo 60 | } 61 | 62 | if strings.HasPrefix(cfg.URLs[0], "git@") { 63 | // SSH format: git@github.com:org/repo.git 64 | rest := strings.TrimSuffix(strings.TrimPrefix(cfg.URLs[0], "git@"), ".git") 65 | parts := strings.SplitN(rest, ":", 2) 66 | repo.Host = parts[0] 67 | repoName := strings.SplitN(parts[1], "/", 2) 68 | repo.Owner = repoName[0] 69 | repo.Name = repoName[1] 70 | } else if strings.HasPrefix(cfg.URLs[0], "https://") { 71 | // HTTPS format: https://github.com/org/repo.git 72 | u, err := url.Parse(strings.TrimSuffix(cfg.URLs[0], ".git")) 73 | if err != nil { 74 | return repo 75 | } 76 | repo.Host = u.Host 77 | repoName := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 2) 78 | repo.Owner = repoName[0] 79 | repo.Name = repoName[1] 80 | } 81 | repo.Url = fmt.Sprintf("https://%s/%s/%s", repo.Host, repo.Owner, repo.Name) 82 | repo.InferVcsProvider() 83 | 84 | return repo 85 | } 86 | -------------------------------------------------------------------------------- /workspaces/select.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "path" 8 | "strings" 9 | 10 | "gopkg.in/nullstone-io/go-api-client.v0" 11 | "gopkg.in/nullstone-io/go-api-client.v0/types" 12 | "gopkg.in/nullstone-io/nullstone.v0/git" 13 | ) 14 | 15 | var ( 16 | backendFilename = "__backend__.tf" 17 | activeWorkspaceFilename = path.Join(".nullstone", "active-workspace.yml") 18 | ) 19 | 20 | func Select(ctx context.Context, cfg api.Config, workspace Manifest, runConfig types.RunConfig) error { 21 | repo := git.RepoFromDir(".") 22 | if repo != nil { 23 | // Add gitignores for __backend__.tf and .nullstone/active-workspace.yml 24 | _, missing := git.FindGitIgnores(repo, []string{ 25 | backendFilename, 26 | activeWorkspaceFilename, 27 | }) 28 | if len(missing) > 0 { 29 | fmt.Printf("Adding %s to .gitignore\n", strings.Join(missing, ", ")) 30 | git.AddGitIgnores(repo, missing) 31 | } 32 | } 33 | 34 | if err := WriteBackendTf(cfg, workspace.WorkspaceUid, backendFilename); err != nil { 35 | return fmt.Errorf("error writing terraform backend file: %w", err) 36 | } 37 | if err := workspace.WriteToFile(activeWorkspaceFilename); err != nil { 38 | return fmt.Errorf("error writing active workspace file: %w", err) 39 | } 40 | 41 | fmt.Printf(`Selected workspace: 42 | Stack: %s 43 | Block: %s 44 | Env: %s 45 | Workspace: %s 46 | `, workspace.StackName, workspace.BlockName, workspace.EnvName, workspace.WorkspaceUid) 47 | 48 | capGenerator := CapabilitiesGenerator{ 49 | RegistryAddress: cfg.BaseAddress, 50 | Manifest: workspace, 51 | TemplateFilename: "capabilities.tf.tmpl", 52 | TargetFilename: "capabilities.tf", 53 | ApiConfig: cfg, 54 | } 55 | if capGenerator.ShouldGenerate() { 56 | fmt.Printf("Generating %q from %q\n", capGenerator.TargetFilename, capGenerator.TemplateFilename) 57 | if err := capGenerator.Generate(runConfig); err != nil { 58 | return fmt.Errorf("Could not generate %q: %w", capGenerator.TargetFilename, err) 59 | } 60 | } 61 | 62 | if err := Init(ctx); err != nil { 63 | fallbackMessage := `Unable to initialize terraform. 64 | Reset .terraform/ directory and run 'terraform init'.` 65 | fmt.Println(fallbackMessage) 66 | log.Println(err) 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/plan.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "github.com/urfave/cli/v2" 6 | "gopkg.in/nullstone-io/go-api-client.v0" 7 | "gopkg.in/nullstone-io/go-api-client.v0/types" 8 | "gopkg.in/nullstone-io/nullstone.v0/runs" 9 | ) 10 | 11 | var Plan = func() *cli.Command { 12 | return &cli.Command{ 13 | Name: "plan", 14 | Description: "Run a plan for a given block and environment. This will automatically disapprove the plan and is useful for testing what a plan will do.", 15 | Usage: "Runs a plan with a disapproval", 16 | UsageText: "nullstone plan [--stack=] --block= --env= [options]", 17 | Flags: []cli.Flag{ 18 | StackFlag, 19 | BlockFlag, 20 | EnvFlag, 21 | &cli.BoolFlag{ 22 | Name: "wait", 23 | Aliases: []string{"w"}, 24 | Usage: "Wait for the plan to complete and stream the Terraform logs to the console.", 25 | }, 26 | &cli.StringSliceFlag{ 27 | Name: "var", 28 | Usage: "Set variables values for the plan. This can be used to override variables defined in the module.", 29 | }, 30 | &cli.StringFlag{ 31 | Name: "module-version", 32 | Usage: "Run a plan with a specific version of the module.", 33 | }, 34 | }, 35 | Action: func(c *cli.Context) error { 36 | varFlags := c.StringSlice("var") 37 | moduleVersion := c.String("module-version") 38 | 39 | return BlockWorkspaceAction(c, func(ctx context.Context, cfg api.Config, stack types.Stack, block types.Block, env types.Environment, workspace types.Workspace) error { 40 | if moduleVersion != "" { 41 | module := types.WorkspaceModuleInput{ 42 | Module: block.ModuleSource, 43 | ModuleVersion: moduleVersion, 44 | } 45 | err := runs.SetModuleVersion(ctx, cfg, workspace, module) 46 | if err != nil { 47 | return err 48 | } 49 | } 50 | 51 | err := runs.SetConfigVars(ctx, cfg, workspace, varFlags) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | f := false 57 | input := PerformRunInput{ 58 | Workspace: workspace, 59 | CommitSha: "", 60 | IsApproved: &f, 61 | IsDestroy: false, 62 | BlockType: types.BlockType(block.Type), 63 | StreamLogs: c.IsSet("wait"), 64 | } 65 | return PerformRun(ctx, cfg, input) 66 | }) 67 | }, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /cmd/apps.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ryanuber/columnize" 7 | "github.com/urfave/cli/v2" 8 | "gopkg.in/nullstone-io/go-api-client.v0" 9 | "gopkg.in/nullstone-io/go-api-client.v0/find" 10 | "gopkg.in/nullstone-io/go-api-client.v0/types" 11 | ) 12 | 13 | var Apps = &cli.Command{ 14 | Name: "apps", 15 | Usage: "View and modify applications", 16 | UsageText: "nullstone apps [subcommand]", 17 | Subcommands: []*cli.Command{ 18 | AppsList, 19 | }, 20 | } 21 | 22 | var AppsList = &cli.Command{ 23 | Name: "list", 24 | Description: "Shows a list of the applications that you have access to. Set the `--detail` flag to show more details about each application.", 25 | Usage: "List applications", 26 | UsageText: "nullstone apps list", 27 | Flags: []cli.Flag{ 28 | &cli.BoolFlag{ 29 | Name: "detail", 30 | Aliases: []string{"d"}, 31 | Usage: "Use this flag to show the details for each application", 32 | }, 33 | }, 34 | Action: func(c *cli.Context) error { 35 | ctx := context.TODO() 36 | return ProfileAction(c, func(cfg api.Config) error { 37 | client := api.Client{Config: cfg} 38 | allApps, err := client.Apps().GlobalList(ctx) 39 | if err != nil { 40 | return fmt.Errorf("error listing applications: %w", err) 41 | } 42 | 43 | if c.IsSet("detail") { 44 | appDetails := make([]string, len(allApps)+1) 45 | appDetails[0] = "ID|Name|Reference|Category|Type|Module|Stack|Framework" 46 | for i, app := range allApps { 47 | var appCategory types.CategoryName 48 | var appType string 49 | if appModule, err := find.Module(ctx, cfg, app.ModuleSource); err == nil { 50 | appCategory = appModule.Category 51 | appType = appModule.Type 52 | } 53 | stack, err := client.Stacks().Get(ctx, app.StackId, false) 54 | if err != nil { 55 | return fmt.Errorf("error looking for stack %q: %w", app.StackId, err) 56 | } 57 | appDetails[i+1] = fmt.Sprintf("%d|%s|%s|%s|%s|%s|%s|%s", app.Id, app.Name, app.Reference, appCategory, appType, app.ModuleSource, stack.Name, app.Framework) 58 | } 59 | fmt.Println(columnize.Format(appDetails, columnize.DefaultConfig())) 60 | } else { 61 | for _, app := range allApps { 62 | fmt.Println(app.Name) 63 | } 64 | } 65 | 66 | return nil 67 | }) 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /aws/beanstalk/remoter.go: -------------------------------------------------------------------------------- 1 | package beanstalk 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "github.com/nullstone-io/deployment-sdk/logging" 8 | "github.com/nullstone-io/deployment-sdk/outputs" 9 | "gopkg.in/nullstone-io/nullstone.v0/admin" 10 | "gopkg.in/nullstone-io/nullstone.v0/aws/ssm" 11 | ) 12 | 13 | var ( 14 | _ admin.Remoter = Remoter{} 15 | ) 16 | 17 | func NewRemoter(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (admin.Remoter, error) { 18 | outs, err := outputs.Retrieve[Outputs](ctx, source, appDetails.Workspace, appDetails.WorkspaceConfig) 19 | if err != nil { 20 | return nil, err 21 | } 22 | outs.InitializeCreds(source, appDetails.Workspace) 23 | 24 | return Remoter{ 25 | OsWriters: osWriters, 26 | Details: appDetails, 27 | Infra: outs, 28 | }, nil 29 | } 30 | 31 | type Remoter struct { 32 | OsWriters logging.OsWriters 33 | Details app.Details 34 | Infra Outputs 35 | } 36 | 37 | func (r Remoter) Exec(ctx context.Context, options admin.RemoteOptions, cmd []string) error { 38 | // TODO: Add support for cmd 39 | instanceId, err := r.getInstanceId(ctx, options) 40 | if err != nil { 41 | return err 42 | } 43 | return ssm.StartEc2Session(ctx, r.Infra.AdminerConfig(), r.Infra.Region, instanceId, nil) 44 | } 45 | 46 | func (r Remoter) Ssh(ctx context.Context, options admin.RemoteOptions) error { 47 | parameters, err := ssm.SessionParametersFromPortForwards(options.PortForwards) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | instanceId, err := r.getInstanceId(ctx, options) 53 | if err != nil { 54 | return err 55 | } 56 | return ssm.StartEc2Session(ctx, r.Infra.AdminerConfig(), r.Infra.Region, instanceId, parameters) 57 | } 58 | 59 | func (r Remoter) Run(ctx context.Context, options admin.RunOptions, cmd []string) error { 60 | return fmt.Errorf("`run` is not supported for Beanstalk yet") 61 | } 62 | 63 | func (r Remoter) getInstanceId(ctx context.Context, options admin.RemoteOptions) (string, error) { 64 | if options.Instance == "" { 65 | if instanceId, err := GetRandomInstance(ctx, r.Infra); err != nil { 66 | return "", err 67 | } else if instanceId == "" { 68 | return "", fmt.Errorf("cannot exec command with no running instances") 69 | } else { 70 | return instanceId, nil 71 | } 72 | } 73 | return options.Instance, nil 74 | } 75 | -------------------------------------------------------------------------------- /aws/ecs/outputs.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go-v2/service/ecs/types" 5 | nsaws "github.com/nullstone-io/deployment-sdk/aws" 6 | "github.com/nullstone-io/deployment-sdk/aws/creds" 7 | "github.com/nullstone-io/deployment-sdk/outputs" 8 | nstypes "gopkg.in/nullstone-io/go-api-client.v0/types" 9 | ) 10 | 11 | type Outputs struct { 12 | Region string `ns:"region"` 13 | ServiceName string `ns:"service_name"` 14 | TaskArn string `ns:"task_arn"` 15 | MainContainerName string `ns:"main_container_name,optional"` 16 | Deployer nsaws.User `ns:"deployer,optional"` 17 | AppSecurityGroupId string `ns:"app_security_group_id"` 18 | LaunchType string `ns:"launch_type,optional"` 19 | 20 | Cluster ClusterOutputs `ns:",connectionContract:cluster/aws/ecs:*,optional"` 21 | ClusterNamespace ClusterNamespaceOutputs `ns:",connectionContract:cluster-namespace/aws/ecs:*,optional"` 22 | } 23 | 24 | func (o *Outputs) InitializeCreds(source outputs.RetrieverSource, ws *nstypes.Workspace) { 25 | credsFactory := creds.NewProviderFactory(source, ws.StackId, ws.Uid) 26 | o.Deployer.RemoteProvider = credsFactory("adminer", "deployer") 27 | } 28 | 29 | func (o *Outputs) ClusterArn() string { 30 | if o.ClusterNamespace.ClusterArn != "" { 31 | return o.ClusterNamespace.ClusterArn 32 | } 33 | return o.Cluster.ClusterArn 34 | } 35 | 36 | func (o *Outputs) PrivateSubnetIds() []string { 37 | if o.ClusterNamespace.Cluster.Network.PrivateSubnetIds != nil { 38 | return o.ClusterNamespace.Cluster.Network.PrivateSubnetIds 39 | } 40 | return o.Cluster.Network.PrivateSubnetIds 41 | } 42 | 43 | func (o *Outputs) GetLaunchType() types.LaunchType { 44 | switch o.LaunchType { 45 | case "EC2": 46 | return types.LaunchTypeEc2 47 | case "EXTERNAL": 48 | return types.LaunchTypeExternal 49 | default: 50 | return types.LaunchTypeFargate 51 | } 52 | } 53 | 54 | type ClusterNamespaceOutputs struct { 55 | ClusterArn string `ns:"cluster_arn"` 56 | Cluster ClusterOutputs `ns:"connectionContract:cluster/aws/ecs:*,optional"` 57 | } 58 | 59 | type ClusterOutputs struct { 60 | ClusterArn string `ns:"cluster_arn"` 61 | Network NetworkOutputs `ns:"connectionContract:network/aws/*,optional"` 62 | } 63 | 64 | type NetworkOutputs struct { 65 | PrivateSubnetIds []string `ns:"private_subnet_ids"` 66 | } 67 | -------------------------------------------------------------------------------- /cmd/launch.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/nullstone-io/deployment-sdk/app" 8 | "github.com/urfave/cli/v2" 9 | "gopkg.in/nullstone-io/go-api-client.v0" 10 | ) 11 | 12 | // Launch command performs push, deploy, and logs 13 | var Launch = func(providers app.Providers) *cli.Command { 14 | return &cli.Command{ 15 | Name: "launch", 16 | Description: "This command will first upload (push) an artifact containing the source for your application. Then it will deploy it to the given environment and tail the logs for the deployment." + 17 | "This command is the same as running `nullstone push` followed by `nullstone deploy -w`.", 18 | Usage: "Launch application (push + deploy + wait-healthy)", 19 | UsageText: "nullstone launch [--stack=] --app= --env= [options]", 20 | Flags: []cli.Flag{ 21 | StackFlag, 22 | AppFlag, 23 | OldEnvFlag, 24 | AppSourceFlag, 25 | AppVersionFlag, 26 | }, 27 | Action: func(c *cli.Context) error { 28 | return AppWorkspaceAction(c, func(ctx context.Context, cfg api.Config, appDetails app.Details) error { 29 | osWriters := CliOsWriters{Context: c} 30 | stderr := osWriters.Stderr() 31 | source := c.String(AppSourceFlag.Name) 32 | 33 | pusher, err := getPusher(providers, cfg, appDetails) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | info, skipPush, err := calcPushInfo(ctx, c, pusher) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | if skipPush { 44 | fmt.Fprintln(osWriters.Stderr(), "App artifact already exists. Skipped push.") 45 | fmt.Fprintln(osWriters.Stderr(), "") 46 | return nil 47 | } else { 48 | if err := recordArtifact(ctx, osWriters, cfg, appDetails, info); err != nil { 49 | return err 50 | } 51 | if err := push(ctx, osWriters, pusher, source, info); err != nil { 52 | return err 53 | } 54 | } 55 | 56 | fmt.Fprintln(stderr, "Creating deploy...") 57 | result, err := CreateDeploy(cfg, appDetails, info) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | fmt.Fprintln(stderr) 63 | if result.Deploy != nil { 64 | return streamDeployLogs(ctx, osWriters, cfg, *result.Deploy, true) 65 | } else if result.IntentWorkflow != nil { 66 | return streamDeployIntentLogs(ctx, osWriters, cfg, appDetails, *result.IntentWorkflow, true) 67 | } 68 | fmt.Fprintln(stderr, "Unable to stream deployment logs") 69 | return nil 70 | }) 71 | }, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /admin/all/providers.go: -------------------------------------------------------------------------------- 1 | package all 2 | 3 | import ( 4 | "gopkg.in/nullstone-io/go-api-client.v0/types" 5 | "gopkg.in/nullstone-io/nullstone.v0/admin" 6 | "gopkg.in/nullstone-io/nullstone.v0/aws/beanstalk" 7 | "gopkg.in/nullstone-io/nullstone.v0/aws/ec2" 8 | "gopkg.in/nullstone-io/nullstone.v0/aws/ecs" 9 | "gopkg.in/nullstone-io/nullstone.v0/gcp/gke" 10 | ) 11 | 12 | var ( 13 | ecsContract = types.ModuleContractName{ 14 | Category: string(types.CategoryApp), 15 | Subcategory: string(types.SubcategoryAppContainer), 16 | Provider: "aws", 17 | Platform: "ecs", 18 | Subplatform: "*", 19 | } 20 | beanstalkContract = types.ModuleContractName{ 21 | Category: string(types.CategoryApp), 22 | Subcategory: string(types.SubcategoryAppServer), 23 | Provider: "aws", 24 | Platform: "ec2", 25 | Subplatform: "beanstalk", 26 | } 27 | ec2Contract = types.ModuleContractName{ 28 | Category: string(types.CategoryApp), 29 | Subcategory: string(types.SubcategoryAppServer), 30 | Provider: "aws", 31 | Platform: "ec2", 32 | Subplatform: "", 33 | } 34 | lambdaContract = types.ModuleContractName{ 35 | Category: string(types.CategoryApp), 36 | Subcategory: string(types.SubcategoryAppServerless), 37 | Provider: "aws", 38 | Platform: "lambda", 39 | Subplatform: "*", 40 | } 41 | s3SiteContract = types.ModuleContractName{ 42 | Category: string(types.CategoryApp), 43 | Subcategory: string(types.SubcategoryAppStaticSite), 44 | Provider: "aws", 45 | Platform: "s3", 46 | Subplatform: "", 47 | } 48 | gkeContract = types.ModuleContractName{ 49 | Category: string(types.CategoryApp), 50 | Subcategory: string(types.SubcategoryAppContainer), 51 | Provider: "gcp", 52 | Platform: "k8s", 53 | Subplatform: "gke", 54 | } 55 | 56 | Providers = admin.Providers{ 57 | ecsContract: admin.Provider{ 58 | NewStatuser: ecs.NewStatuser, 59 | NewRemoter: ecs.NewRemoter, 60 | }, 61 | beanstalkContract: admin.Provider{ 62 | NewStatuser: nil, // TODO: beanstalk.NewStatuser 63 | NewRemoter: beanstalk.NewRemoter, 64 | }, 65 | ec2Contract: admin.Provider{ 66 | NewStatuser: nil, 67 | NewRemoter: ec2.NewRemoter, 68 | }, 69 | lambdaContract: admin.Provider{ 70 | NewStatuser: nil, 71 | NewRemoter: nil, // TODO: lambda.NewRemoter, 72 | }, 73 | s3SiteContract: admin.Provider{ 74 | NewStatuser: nil, 75 | NewRemoter: nil, 76 | }, 77 | gkeContract: admin.Provider{ 78 | NewStatuser: nil, 79 | NewRemoter: gke.NewRemoter, 80 | }, 81 | } 82 | ) 83 | -------------------------------------------------------------------------------- /cmd/ns_status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/find" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | ) 11 | 12 | type AppWorkspaceInfo struct { 13 | AppDetails app.Details 14 | Status string 15 | Version string 16 | } 17 | 18 | type NsStatus struct { 19 | Config api.Config 20 | } 21 | 22 | func (s NsStatus) GetAppWorkspaceInfo(application *types.Application, env *types.Environment) (AppWorkspaceInfo, error) { 23 | ctx := context.TODO() 24 | awi := AppWorkspaceInfo{ 25 | AppDetails: app.Details{ 26 | App: application, 27 | Env: env, 28 | }, 29 | Status: types.WorkspaceStatusNotProvisioned, 30 | Version: "not-deployed", 31 | } 32 | 33 | module, err := find.Module(ctx, s.Config, awi.AppDetails.App.ModuleSource) 34 | if err != nil { 35 | return awi, err 36 | } else if module == nil { 37 | return awi, fmt.Errorf("can't find app module %s", awi.AppDetails.App.ModuleSource) 38 | } 39 | awi.AppDetails.Module = module 40 | 41 | client := api.Client{Config: s.Config} 42 | workspace, err := client.Workspaces().Get(ctx, application.StackId, application.Id, env.Id) 43 | if err != nil { 44 | return awi, err 45 | } else if workspace == nil { 46 | return awi, nil 47 | } 48 | awi.AppDetails.Workspace = workspace 49 | awi.Status = s.calcInfraStatus(workspace) 50 | 51 | appEnv, err := client.AppEnvs().Get(ctx, application.StackId, application.Id, env.Name) 52 | if err != nil { 53 | return awi, err 54 | } else if appEnv != nil { 55 | awi.Version = appEnv.Version 56 | } 57 | if awi.Version == "" || awi.Status == types.WorkspaceStatusNotProvisioned || awi.Status == "creating" { 58 | awi.Version = "not-deployed" 59 | } 60 | 61 | return awi, nil 62 | } 63 | 64 | func (s NsStatus) calcInfraStatus(workspace *types.Workspace) string { 65 | if workspace == nil { 66 | return types.WorkspaceStatusNotProvisioned 67 | } 68 | if workspace.ActiveRun == nil { 69 | return workspace.Status 70 | } 71 | switch workspace.ActiveRun.Status { 72 | default: 73 | return workspace.Status 74 | case types.RunStatusNeedsApproval: 75 | return "needs-approval" 76 | case types.RunStatusResolving: 77 | case types.RunStatusInitializing: 78 | case types.RunStatusAwaiting: 79 | case types.RunStatusRunning: 80 | } 81 | if workspace.ActiveRun.IsDestroy { 82 | return "destroying" 83 | } 84 | if workspace.Status == types.WorkspaceStatusNotProvisioned { 85 | return "creating" 86 | } 87 | return "updating" 88 | } 89 | -------------------------------------------------------------------------------- /cmd/ssh.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "github.com/nullstone-io/deployment-sdk/logging" 8 | "github.com/nullstone-io/deployment-sdk/outputs" 9 | "github.com/urfave/cli/v2" 10 | "gopkg.in/nullstone-io/go-api-client.v0" 11 | "gopkg.in/nullstone-io/nullstone.v0/admin" 12 | "gopkg.in/nullstone-io/nullstone.v0/config" 13 | "strings" 14 | ) 15 | 16 | var Ssh = func(providers admin.Providers) *cli.Command { 17 | return &cli.Command{ 18 | Name: "ssh", 19 | Description: "SSH into a running app container or virtual machine. Use the `--forward, L` option to forward ports from remote service or hosts.", 20 | Usage: "SSH into a running application.", 21 | UsageText: "nullstone ssh [--stack=] --app= --env= [options]", 22 | Flags: []cli.Flag{ 23 | StackFlag, 24 | AppFlag, 25 | EnvFlag, 26 | InstanceFlag, 27 | TaskFlag, 28 | PodFlag, 29 | ContainerFlag, 30 | &cli.StringSliceFlag{ 31 | Name: "forward", 32 | Aliases: []string{"L"}, 33 | Usage: "Use this to forward ports from host to local machine. Format: :[]:", 34 | }, 35 | }, 36 | Action: func(c *cli.Context) error { 37 | forwards := make([]config.PortForward, 0) 38 | for _, arg := range c.StringSlice("forward") { 39 | pf, err := config.ParsePortForward(arg) 40 | if err != nil { 41 | return fmt.Errorf("invalid format for --forward/-L: %w", err) 42 | } 43 | forwards = append(forwards, pf) 44 | } 45 | 46 | return AppWorkspaceAction(c, func(ctx context.Context, cfg api.Config, appDetails app.Details) error { 47 | source := outputs.ApiRetrieverSource{Config: cfg} 48 | remoter, err := providers.FindRemoter(ctx, logging.StandardOsWriters{}, source, appDetails) 49 | if err != nil { 50 | return err 51 | } else if remoter == nil { 52 | module := appDetails.Module 53 | platform := strings.TrimSuffix(fmt.Sprintf("%s:%s", module.Platform, module.Subplatform), ":") 54 | return fmt.Errorf("The Nullstone CLI does not currently support the ssh command for the %q application. (Module = %s/%s, App Category = app/%s, Platform = %s)", 55 | appDetails.App.Name, module.OrgName, module.Name, module.Subcategory, platform) 56 | } 57 | options := admin.RemoteOptions{ 58 | Instance: c.String("instance"), 59 | Task: c.String("task"), 60 | Pod: c.String("pod"), 61 | Container: c.String("container"), 62 | PortForwards: forwards, 63 | } 64 | return remoter.Ssh(ctx, options) 65 | }) 66 | }, 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /runs/wait_for_terminal_run.go: -------------------------------------------------------------------------------- 1 | package runs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/logging" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/types" 9 | "gopkg.in/nullstone-io/nullstone.v0/app_urls" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | func WaitForTerminalRun(ctx context.Context, osWriters logging.OsWriters, cfg api.Config, ws types.Workspace, track types.Run, 15 | timeout time.Duration, approvalTimeout time.Duration) (types.Run, error) { 16 | stderr := osWriters.Stderr() 17 | // ctx already contains cancellation for Ctrl+C, innerCtx allows us to cancel pollRun when timeout occurs 18 | innerCtx, cancelFn := context.WithCancel(ctx) 19 | defer cancelFn() 20 | 21 | // updatedRun is returned to allow the caller to decision off the run status upon completion 22 | var updatedRun types.Run 23 | 24 | // This timer provides a hard timeout for entire wait operation 25 | // If it hits first, we print an error message and cancel innerCtx to stop pollRun 26 | timeoutTimer := time.NewTimer(timeout) 27 | defer timeoutTimer.Stop() 28 | 29 | // This timer provides a timeout when we reach "needs-approval" 30 | // This timer starts with an extremely large timeout value, so it doesn't trigger; 31 | // When we reach "needs-approval", the timer is reset to the user-specified "approvalTimeout" 32 | approvalTimer := time.NewTimer(7 * 24 * time.Hour) 33 | defer approvalTimer.Stop() 34 | var printApprovalMsg sync.Once 35 | onNeedsApproval := func() { 36 | printApprovalMsg.Do(func() { 37 | fmt.Fprintln(stderr, "Nullstone requires approval before applying infrastructure changes.") 38 | fmt.Fprintln(stderr, "Visit the infrastructure logs in a browser to approve/reject.") 39 | fmt.Fprintln(stderr, app_urls.GetRun(cfg, ws, updatedRun)) 40 | }) 41 | approvalTimer.Reset(approvalTimeout) 42 | } 43 | 44 | runCh := pollRun(innerCtx, cfg, ws.StackId, track.Uid, time.Second) 45 | for { 46 | select { 47 | case updatedRun = <-runCh: 48 | if types.IsTerminalRunStatus(updatedRun.Status) { 49 | return updatedRun, nil 50 | } 51 | if updatedRun.Status == types.RunStatusNeedsApproval { 52 | onNeedsApproval() 53 | } 54 | case <-timeoutTimer.C: 55 | fmt.Fprintln(stderr, "Timed out waiting for workspace to provision.") 56 | return updatedRun, fmt.Errorf("Operation cancelled waiting for provision") 57 | case <-approvalTimer.C: 58 | fmt.Fprintln(stderr, "Timed out waiting for workspace to be approved.") 59 | return updatedRun, fmt.Errorf("Operation cancelled waiting for approval") 60 | case <-ctx.Done(): 61 | return updatedRun, fmt.Errorf("User cancelled operation") 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /config/profile.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | ) 10 | 11 | type Profile struct { 12 | Name string `json:"name"` 13 | Address string `json:"address"` 14 | ApiKey string `json:"-"` 15 | } 16 | 17 | func (p Profile) Save() error { 18 | if err := p.ensureDir(); err != nil { 19 | return err 20 | } 21 | raw, err := json.MarshalIndent(p, "", " ") 22 | if err != nil { 23 | return fmt.Errorf("error generating profile file: %w", err) 24 | } 25 | if err := ioutil.WriteFile(p.ConfigFilename(), raw, 0644); err != nil { 26 | return fmt.Errorf("error saving profile configuration: %w", err) 27 | } 28 | if err := ioutil.WriteFile(p.ApiKeyFilename(), []byte(p.ApiKey), 0644); err != nil { 29 | return fmt.Errorf("error saving api key: %w", err) 30 | } 31 | return nil 32 | } 33 | 34 | func LoadProfile(name string) (*Profile, error) { 35 | p := &Profile{ 36 | Name: name, 37 | } 38 | 39 | if err := p.ensureDir(); err != nil { 40 | return nil, err 41 | } 42 | raw, err := ioutil.ReadFile(p.ConfigFilename()) 43 | if err != nil { 44 | // If profile configuration file does not exist, just return our defaults 45 | if os.IsNotExist(err) { 46 | return p, nil 47 | } 48 | return nil, fmt.Errorf("error reading profile configuration: %w", err) 49 | } 50 | if err := json.Unmarshal(raw, p); err != nil { 51 | return nil, fmt.Errorf("invalid profile configuration: %w", err) 52 | } 53 | // The name in the configuration file should not override the requested profile 54 | p.Name = name 55 | 56 | if raw, err := ioutil.ReadFile(p.ApiKeyFilename()); err != nil { 57 | return nil, fmt.Errorf("error reading api key: %w", err) 58 | } else { 59 | p.ApiKey = CleanseApiKey(string(raw)) 60 | } 61 | return p, nil 62 | } 63 | 64 | func (p Profile) LoadOrg() (string, error) { 65 | raw, err := ioutil.ReadFile(path.Join(p.Directory(), "org")) 66 | if os.IsNotExist(err) { 67 | return "", nil 68 | } else if err != nil { 69 | return "", err 70 | } 71 | return string(raw), nil 72 | } 73 | 74 | func (p Profile) SaveOrg(org string) error { 75 | if err := p.ensureDir(); err != nil { 76 | return err 77 | } 78 | return ioutil.WriteFile(path.Join(p.Directory(), "org"), []byte(org), 0644) 79 | } 80 | 81 | func (p Profile) Directory() string { 82 | return path.Join(NullstoneDir, p.Name) 83 | } 84 | 85 | func (p Profile) ConfigFilename() string { 86 | return path.Join(p.Directory(), "config") 87 | } 88 | 89 | func (p Profile) ApiKeyFilename() string { 90 | return path.Join(p.Directory(), "key") 91 | } 92 | 93 | func (p Profile) ensureDir() error { 94 | if err := os.MkdirAll(p.Directory(), 0755); !os.IsExist(err) { 95 | return err 96 | } 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nullstone 2 | 3 | Nullstone is a Heroku-like developer platform launched on your cloud accounts. 4 | We offer a simple developer experience for teams that want to use Infrastructure-as-code tools like Terraform. 5 | 6 | This repository contains code for the Nullstone CLI which is used to manage Nullstone from the command line. 7 | This includes creating and deploying app, domains, and datastore as well as creating and managing Terraform workspaces. 8 | 9 | ## How to Install 10 | 11 | ### Homebrew (Mac) 12 | 13 | ```shell 14 | brew tap nullstone-io/nullstone https://github.com/nullstone-io/nullstone.git 15 | brew install nullstone 16 | ``` 17 | 18 | ### Snap (Linux) 19 | 20 | [![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/nullstone) 21 | 22 | ### Scoop (Windows) 23 | 24 | ```shell 25 | scoop bucket add nullstone https://github.com/nullstone-io/nullstone.git 26 | scoop install nullstone 27 | ``` 28 | 29 | ## Documentation 30 | 31 | For a complete set of Nullstone documentation, visit [docs.nullstone.io](https://docs.nullstone.io). Check out the links below for specific topics within the docs. 32 | 33 | - [CLI Docs](https://docs.nullstone.io/getting-started/cli/docs.html) - 34 | Learn more about the Nullstone CLI; including how to install and use it. 35 | 36 | ## Community & Support 37 | 38 | - [Public Roadmap](https://github.com/orgs/nullstone-io/projects/1/views/1) - View upcoming features. 39 | - [Discussions](https://github.com/nullstone-io/nullstone/discussions) - View product updates. 40 | - [GitHub Issues](https://github.com/nullstone-io/nullstone/issues) - Request new features, report bugs and errors you encounter. 41 | - [Slack](https://join.slack.com/t/nullstone-community/signup) - Ask questions, get support, and hang out. 42 | - Email Support - support@nullstone.io 43 | 44 | ## Resources 45 | 46 | - [CircleCI Orb](https://github.com/nullstone-io/nullstone-orb) 47 | - GitHub Action (In Development) 48 | - [Go API Client](https://github.com/nullstone-io/go-api-client) 49 | - [Nullstone Modules](https://github.com/nullstone-modules) 50 | 51 | ## How it works 52 | 53 | The Nullstone platform operates similar to a developer platform that is internally built and used at large tech companies. 54 | It consists of a UI, API, and CLI to serve both software engineers and platform engineers. 55 | Software engineers use official Nullstone modules combined with modules built by their own platform engineers to launch and configure their application. 56 | Platform engineers utilize the Nullstone platform to codify and administer infrastructure architectures for their teams without worrying about building user interfaces. 57 | 58 | ![How it works](doc/images/how-it-works.svg) 59 | -------------------------------------------------------------------------------- /cmd/apply.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "github.com/urfave/cli/v2" 6 | "gopkg.in/nullstone-io/go-api-client.v0" 7 | "gopkg.in/nullstone-io/go-api-client.v0/types" 8 | "gopkg.in/nullstone-io/nullstone.v0/runs" 9 | ) 10 | 11 | var Apply = func() *cli.Command { 12 | return &cli.Command{ 13 | Name: "apply", 14 | Description: "Runs a Terraform apply on the given block and environment. This is useful for making ad-hoc changes to your infrastructure.\n" + 15 | "This plan will be executed by the Nullstone system. In order to run a plan locally, check out the `nullstone workspaces select` command.\n" + 16 | "Be sure to run `nullstone plan` first to see what changes will be made.", 17 | Usage: "Runs an apply with optional auto-approval", 18 | UsageText: "nullstone apply [--stack=] --block= --env= [options]", 19 | Flags: []cli.Flag{ 20 | StackFlag, 21 | BlockFlag, 22 | EnvFlag, 23 | &cli.BoolFlag{ 24 | Name: "wait", 25 | Aliases: []string{"w"}, 26 | Usage: "Wait for the apply to complete and stream the Terraform logs to the console.", 27 | }, 28 | &cli.BoolFlag{ 29 | Name: "auto-approve", 30 | Usage: "Skip any approvals and apply the changes immediately. This requires proper permissions in the stack.", 31 | }, 32 | &cli.StringSliceFlag{ 33 | Name: "var", 34 | Usage: "Set variables values for the apply. This can be used to override variables defined in the module.", 35 | }, 36 | &cli.StringFlag{ 37 | Name: "module-version", 38 | Usage: "The version of the module to apply.", 39 | }, 40 | }, 41 | Action: func(c *cli.Context) error { 42 | varFlags := c.StringSlice("var") 43 | moduleVersion := c.String("module-version") 44 | var autoApprove *bool 45 | if c.IsSet("auto-approve") { 46 | val := c.Bool("auto-approve") 47 | autoApprove = &val 48 | } 49 | 50 | return BlockWorkspaceAction(c, func(ctx context.Context, cfg api.Config, stack types.Stack, block types.Block, env types.Environment, workspace types.Workspace) error { 51 | if moduleVersion != "" { 52 | module := types.WorkspaceModuleInput{ 53 | Module: block.ModuleSource, 54 | ModuleVersion: moduleVersion, 55 | } 56 | err := runs.SetModuleVersion(ctx, cfg, workspace, module) 57 | if err != nil { 58 | return err 59 | } 60 | } 61 | 62 | err := runs.SetConfigVars(ctx, cfg, workspace, varFlags) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | input := PerformRunInput{ 68 | Workspace: workspace, 69 | CommitSha: "", 70 | IsApproved: autoApprove, 71 | IsDestroy: false, 72 | BlockType: types.BlockType(block.Type), 73 | StreamLogs: c.IsSet("wait"), 74 | } 75 | return PerformRun(ctx, cfg, input) 76 | }) 77 | }, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /cmd/perform_env_run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gopkg.in/nullstone-io/go-api-client.v0" 7 | "gopkg.in/nullstone-io/go-api-client.v0/types" 8 | "gopkg.in/nullstone-io/nullstone.v0/app_urls" 9 | "os" 10 | ) 11 | 12 | type PerformEnvRunInput struct { 13 | CommitSha string 14 | Stack types.Stack 15 | Env types.Environment 16 | IsDestroy bool 17 | } 18 | 19 | func PerformEnvRun(ctx context.Context, cfg api.Config, input PerformEnvRunInput) error { 20 | stdout := os.Stdout 21 | action := "launch" 22 | if input.IsDestroy { 23 | action = "destroy" 24 | } 25 | 26 | client := api.Client{Config: cfg} 27 | body := types.CreateEnvRunInput{IsDestroy: input.IsDestroy} 28 | result, err := client.EnvRuns().Create(ctx, input.Stack.Id, input.Env.Id, body) 29 | if err != nil { 30 | return fmt.Errorf("error creating run: %w", err) 31 | } else if result == nil { 32 | fmt.Fprintf(stdout, "no runs created to %s the %q environment\n", action, input.Env.Name) 33 | return nil 34 | } 35 | 36 | if result.IntentWorkflow.Intent != "" { 37 | fmt.Fprintf(stdout, "created workflow to %s %q environment.\n", action, input.Env.Name) 38 | fmt.Fprintln(stdout, app_urls.GetIntentWorkflow(cfg, result.IntentWorkflow)) 39 | return nil 40 | } else if result.Runs == nil { 41 | return fmt.Errorf("workflow to %q environment was not created", action) 42 | } 43 | 44 | if len(result.Runs) < 1 { 45 | fmt.Fprintf(stdout, "no runs created to %s the %q environment\n", action, input.Env.Name) 46 | return nil 47 | } 48 | 49 | workspaces, err := client.Workspaces().List(ctx, input.Env.Id) 50 | if err != nil { 51 | return fmt.Errorf("error retrieving list of workspaces: %w", err) 52 | } 53 | blocks, err := client.Blocks().List(ctx, input.Stack.Id, false) 54 | if err != nil { 55 | return fmt.Errorf("error retrieving list of blocks: %w", err) 56 | } 57 | 58 | findWorkspace := func(run types.Run) *types.Workspace { 59 | for _, workspace := range workspaces { 60 | if workspace.Uid == run.WorkspaceUid { 61 | return &workspace 62 | } 63 | } 64 | return nil 65 | } 66 | findBlock := func(workspace *types.Workspace) *types.Block { 67 | if workspace == nil { 68 | return nil 69 | } 70 | for _, block := range blocks { 71 | if workspace.BlockId == block.Id { 72 | return &block 73 | } 74 | } 75 | return nil 76 | } 77 | for _, run := range result.Runs { 78 | blockName := "(unknown)" 79 | workspace := findWorkspace(run) 80 | if block := findBlock(workspace); block != nil { 81 | blockName = block.Name 82 | } 83 | browserUrl := "" 84 | if workspace != nil { 85 | browserUrl = fmt.Sprintf(" Logs: %s", app_urls.GetRun(cfg, *workspace, run)) 86 | } 87 | fmt.Fprintf(os.Stdout, "created run to %s %s and dependencies in %q environment. %s\n", action, blockName, input.Env.Name, browserUrl) 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /gcp/gke/remoter.go: -------------------------------------------------------------------------------- 1 | package gke 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "github.com/nullstone-io/deployment-sdk/gcp/gke" 8 | "github.com/nullstone-io/deployment-sdk/logging" 9 | "github.com/nullstone-io/deployment-sdk/outputs" 10 | "gopkg.in/nullstone-io/nullstone.v0/admin" 11 | "gopkg.in/nullstone-io/nullstone.v0/k8s" 12 | "k8s.io/client-go/rest" 13 | "os" 14 | ) 15 | 16 | var ( 17 | _ admin.Remoter = Remoter{} 18 | ) 19 | 20 | func NewRemoter(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (admin.Remoter, error) { 21 | outs, err := outputs.Retrieve[Outputs](ctx, source, appDetails.Workspace, appDetails.WorkspaceConfig) 22 | if err != nil { 23 | return nil, err 24 | } 25 | outs.InitializeCreds(source, appDetails.Workspace) 26 | 27 | return Remoter{ 28 | OsWriters: osWriters, 29 | Details: appDetails, 30 | Infra: outs, 31 | }, nil 32 | } 33 | 34 | type Remoter struct { 35 | OsWriters logging.OsWriters 36 | Details app.Details 37 | Infra Outputs 38 | } 39 | 40 | func (r Remoter) Exec(ctx context.Context, options admin.RemoteOptions, cmd []string) error { 41 | if r.Infra.ServiceName == "" { 42 | return fmt.Errorf("cannot `exec` unless you have a long-running service, use `run` for a job/task") 43 | } 44 | 45 | opts := &k8s.ExecOptions{ 46 | In: os.Stdin, 47 | Out: r.OsWriters.Stdout(), 48 | ErrOut: r.OsWriters.Stderr(), 49 | TTY: false, 50 | } 51 | for _, pf := range options.PortForwards { 52 | opts.PortMappings = append(opts.PortMappings, fmt.Sprintf("%s:%s", pf.LocalPort, pf.RemotePort)) 53 | } 54 | 55 | return ExecCommand(ctx, r.Infra, options.Pod, options.Container, cmd, opts) 56 | } 57 | 58 | func (r Remoter) Ssh(ctx context.Context, options admin.RemoteOptions) error { 59 | opts := &k8s.ExecOptions{ 60 | In: os.Stdin, 61 | Out: r.OsWriters.Stdout(), 62 | ErrOut: r.OsWriters.Stderr(), 63 | TTY: true, 64 | } 65 | for _, pf := range options.PortForwards { 66 | opts.PortMappings = append(opts.PortMappings, fmt.Sprintf("%s:%s", pf.LocalPort, pf.RemotePort)) 67 | } 68 | 69 | return ExecCommand(ctx, r.Infra, options.Pod, options.Container, []string{"/bin/sh"}, opts) 70 | } 71 | 72 | func (r Remoter) Run(ctx context.Context, options admin.RunOptions, cmd []string) error { 73 | if r.Infra.ServiceName != "" { 74 | return fmt.Errorf("cannot use `run` for a long-running service, use `exec` instead") 75 | } 76 | 77 | runner := k8s.JobRunner{ 78 | Namespace: r.Infra.ServiceNamespace, 79 | AppName: r.Details.App.Name, 80 | MainContainerName: r.Infra.MainContainerName, 81 | JobDefinitionName: r.Infra.JobDefinitionName, 82 | NewConfigFn: func(ctx context.Context) (*rest.Config, error) { 83 | return gke.CreateKubeConfig(ctx, r.Infra.ClusterNamespace, r.Infra.Deployer) 84 | }, 85 | } 86 | return runner.Run(ctx, options, cmd) 87 | } 88 | -------------------------------------------------------------------------------- /aws/ecs/remoter.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "github.com/nullstone-io/deployment-sdk/logging" 8 | "github.com/nullstone-io/deployment-sdk/outputs" 9 | "gopkg.in/nullstone-io/nullstone.v0/admin" 10 | ) 11 | 12 | var ( 13 | _ admin.Remoter = Remoter{} 14 | ) 15 | 16 | func NewRemoter(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (admin.Remoter, error) { 17 | outs, err := outputs.Retrieve[Outputs](ctx, source, appDetails.Workspace, appDetails.WorkspaceConfig) 18 | if err != nil { 19 | return nil, err 20 | } 21 | outs.InitializeCreds(source, appDetails.Workspace) 22 | 23 | return Remoter{ 24 | OsWriters: osWriters, 25 | Details: appDetails, 26 | Infra: outs, 27 | }, nil 28 | } 29 | 30 | type Remoter struct { 31 | OsWriters logging.OsWriters 32 | Details app.Details 33 | Infra Outputs 34 | } 35 | 36 | func (r Remoter) Exec(ctx context.Context, options admin.RemoteOptions, cmd []string) error { 37 | if len(options.PortForwards) > 0 { 38 | return fmt.Errorf("ecs provider does not support port forwarding") 39 | } 40 | if r.Infra.ServiceName == "" { 41 | if options.Task != "" { 42 | return fmt.Errorf("fargate and ecs tasks do not support selecting a task, this exec command starts a new task") 43 | } 44 | return RunTask(ctx, r.Infra, options.Container, options.Username, cmd, options.LogStreamer, options.LogEmitter) 45 | } 46 | taskId, err := r.getTaskId(ctx, options) 47 | if err != nil { 48 | return err 49 | } 50 | return ExecCommand(ctx, r.Infra, taskId, options.Container, cmd, nil) 51 | } 52 | 53 | func (r Remoter) Ssh(ctx context.Context, options admin.RemoteOptions) error { 54 | if r.Infra.ServiceName == "" { 55 | return fmt.Errorf("fargate and ecs tasks do not support ssh") 56 | } 57 | if len(options.PortForwards) > 0 { 58 | return fmt.Errorf("ecs provider does not support port forwarding") 59 | } 60 | taskId, err := r.getTaskId(ctx, options) 61 | if err != nil { 62 | return err 63 | } 64 | return ExecCommand(ctx, r.Infra, taskId, options.Container, []string{"/bin/sh"}, nil) 65 | } 66 | 67 | func (r Remoter) Run(ctx context.Context, options admin.RunOptions, cmd []string) error { 68 | if r.Infra.ServiceName != "" { 69 | return fmt.Errorf("cannot use `run` with a long-running application, use `exec` instead") 70 | } 71 | return RunTask(ctx, r.Infra, options.Container, options.Username, cmd, options.LogStreamer, options.LogEmitter) 72 | } 73 | 74 | func (r Remoter) getTaskId(ctx context.Context, options admin.RemoteOptions) (string, error) { 75 | if options.Task == "" { 76 | if taskId, err := GetRandomTask(ctx, r.Infra); err != nil { 77 | return "", err 78 | } else if taskId == "" { 79 | return "", fmt.Errorf("cannot exec command with no running tasks") 80 | } else { 81 | return taskId, nil 82 | } 83 | } 84 | return options.Task, nil 85 | } 86 | -------------------------------------------------------------------------------- /cmd/stacks.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/ryanuber/columnize" 7 | "github.com/urfave/cli/v2" 8 | "gopkg.in/nullstone-io/go-api-client.v0" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | ) 11 | 12 | var Stacks = &cli.Command{ 13 | Name: "stacks", 14 | Usage: "View and modify stacks", 15 | UsageText: "nullstone stacks [subcommand]", 16 | Subcommands: []*cli.Command{ 17 | StacksList, 18 | StacksNew, 19 | }, 20 | } 21 | 22 | var StacksList = &cli.Command{ 23 | Name: "list", 24 | Description: "Shows a list of the stacks that you have access to. Set the `--detail` flag to show more details about each stack.", 25 | Usage: "List stacks", 26 | UsageText: "nullstone stacks list", 27 | Flags: []cli.Flag{ 28 | &cli.BoolFlag{ 29 | Name: "detail", 30 | Aliases: []string{"d"}, 31 | Usage: "Use this flag to show more details about each stack", 32 | }, 33 | }, 34 | Action: func(c *cli.Context) error { 35 | ctx := context.TODO() 36 | return ProfileAction(c, func(cfg api.Config) error { 37 | client := api.Client{Config: cfg} 38 | allStacks, err := client.Stacks().List(ctx) 39 | if err != nil { 40 | return fmt.Errorf("error listing stacks: %w", err) 41 | } 42 | 43 | if c.IsSet("detail") { 44 | stackDetails := make([]string, len(allStacks)+1) 45 | stackDetails[0] = "ID|Name|Description" 46 | for i, stack := range allStacks { 47 | stackDetails[i+1] = fmt.Sprintf("%d|%s|%s", stack.Id, stack.Name, stack.Description) 48 | } 49 | fmt.Println(columnize.Format(stackDetails, columnize.DefaultConfig())) 50 | } else { 51 | for _, stack := range allStacks { 52 | fmt.Println(stack.Name) 53 | } 54 | } 55 | 56 | return nil 57 | }) 58 | }, 59 | } 60 | 61 | var StacksNew = &cli.Command{ 62 | Name: "new", 63 | Description: "Creates a new stack with the given name and in the organization configured for the CLI.", 64 | Usage: "Create new stack", 65 | UsageText: "nullstone stacks new --name= --description=", 66 | Flags: []cli.Flag{ 67 | &cli.StringFlag{ 68 | Name: "name", 69 | Usage: "The name of the stack to create. This name must be unique within the organization.", 70 | Required: true, 71 | }, 72 | &cli.StringFlag{ 73 | Name: "description", 74 | Usage: "The description of the stack to create.", 75 | Required: true, 76 | }, 77 | }, 78 | Action: func(c *cli.Context) error { 79 | ctx := context.TODO() 80 | return ProfileAction(c, func(cfg api.Config) error { 81 | client := api.Client{Config: cfg} 82 | name := c.String("name") 83 | description := c.String("description") 84 | stack, err := client.Stacks().Create(ctx, &types.Stack{ 85 | Name: name, 86 | Description: description, 87 | }) 88 | if err != nil { 89 | return fmt.Errorf("error creating stack: %w", err) 90 | } 91 | fmt.Printf("created %q stack\n", stack.Name) 92 | return nil 93 | }) 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /cmd/exec.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/cristalhq/jwt/v3" 8 | "github.com/nullstone-io/deployment-sdk/app" 9 | "github.com/nullstone-io/deployment-sdk/logging" 10 | "github.com/nullstone-io/deployment-sdk/outputs" 11 | "github.com/urfave/cli/v2" 12 | "gopkg.in/nullstone-io/go-api-client.v0" 13 | "gopkg.in/nullstone-io/nullstone.v0/admin" 14 | "os" 15 | ) 16 | 17 | var Exec = func(appProviders app.Providers, providers admin.Providers) *cli.Command { 18 | return &cli.Command{ 19 | Name: "exec", 20 | Description: "Executes a command on a container or the virtual machine for the given application. Defaults command to '/bin/sh' which acts as opening a shell to the running container or virtual machine.", 21 | Usage: "Execute a command on running service", 22 | UsageText: "nullstone exec [--stack=] --app= --env= [options] [command]", 23 | Flags: []cli.Flag{ 24 | StackFlag, 25 | AppFlag, 26 | EnvFlag, 27 | InstanceFlag, 28 | TaskFlag, 29 | PodFlag, 30 | ContainerFlag, 31 | }, 32 | Action: func(c *cli.Context) error { 33 | var cmd []string 34 | if c.Args().Present() { 35 | cmd = c.Args().Slice() 36 | } 37 | 38 | return AppWorkspaceAction(c, func(ctx context.Context, cfg api.Config, appDetails app.Details) error { 39 | client := api.Client{Config: cfg} 40 | user, err := client.CurrentUser().Get(ctx) 41 | if err != nil { 42 | return fmt.Errorf("unable to fetch the current user") 43 | } 44 | if user == nil { 45 | return fmt.Errorf("unable to load the current user info") 46 | } 47 | 48 | source := outputs.ApiRetrieverSource{Config: cfg} 49 | 50 | logStreamer, err := appProviders.FindLogStreamer(ctx, logging.StandardOsWriters{}, source, appDetails) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | remoter, err := providers.FindRemoter(ctx, logging.StandardOsWriters{}, source, appDetails) 56 | if err != nil { 57 | return err 58 | } 59 | options := admin.RemoteOptions{ 60 | Instance: c.String("instance"), 61 | Task: c.String("task"), 62 | Pod: c.String("pod"), 63 | Container: c.String("container"), 64 | Username: user.Name, 65 | LogStreamer: logStreamer, 66 | LogEmitter: app.NewWriterLogEmitter(os.Stdout), 67 | } 68 | return remoter.Exec(ctx, options, cmd) 69 | }) 70 | }, 71 | } 72 | } 73 | 74 | type Claims struct { 75 | jwt.StandardClaims 76 | Email string `json:"email"` 77 | Picture string `json:"picture"` 78 | Username string `json:"https://nullstone.io/username"` 79 | Roles map[string]string `json:"https://nullstone.io/roles"` 80 | } 81 | 82 | func getClaims(rawToken string) (*Claims, error) { 83 | token, err := jwt.ParseString(rawToken) 84 | if err != nil { 85 | return nil, err 86 | } 87 | var claims Claims 88 | if err := json.Unmarshal(token.RawClaims(), &claims); err != nil { 89 | return nil, err 90 | } 91 | return &claims, nil 92 | } 93 | -------------------------------------------------------------------------------- /runs/stream_logs.go: -------------------------------------------------------------------------------- 1 | package runs 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/google/uuid" 8 | "gopkg.in/nullstone-io/go-api-client.v0" 9 | "gopkg.in/nullstone-io/go-api-client.v0/types" 10 | "gopkg.in/nullstone-io/go-api-client.v0/ws" 11 | "gopkg.in/nullstone-io/nullstone.v0/app_urls" 12 | "os" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | var ( 18 | ErrRunDisapproved = errors.New("run was disapproved") 19 | ) 20 | 21 | // StreamLogs streams the logs from the server over a websocket 22 | // The logs are emitted to stdout 23 | func StreamLogs(ctx context.Context, cfg api.Config, workspace types.Workspace, newRun *types.Run) error { 24 | // ctx already contains cancellation for Ctrl+C 25 | // innerCtx will allow us to cancel when the run reaches a terminal status 26 | innerCtx, cancelFn := context.WithCancel(ctx) 27 | defer cancelFn() 28 | 29 | fmt.Fprintln(os.Stdout, "Waiting for run logs...") 30 | client := api.Client{Config: cfg} 31 | msgs, err := client.RunLogs().Watch(innerCtx, workspace.StackId, newRun.Uid, ws.RetryInfinite(2*time.Second)) 32 | if err != nil { 33 | return err 34 | } 35 | // NOTE: pollRun is needed to know when the live logs are complete 36 | // TODO: Replace pollRun with an EOF message received through the live logs 37 | runCh := pollRun(innerCtx, cfg, workspace.StackId, newRun.Uid, time.Second) 38 | var printApprovalMsg sync.Once 39 | for { 40 | select { 41 | case msg := <-msgs: 42 | if msg.Type != "error" { 43 | fmt.Fprint(os.Stdout, msg.Content) 44 | } 45 | case run := <-runCh: 46 | if types.IsTerminalRunStatus(run.Status) { 47 | // A completed run finishes successfully 48 | // Any other terminal status returns an error (causing a non-zero exit code for failed runs) 49 | if run.Status == types.RunStatusDisapproved { 50 | return ErrRunDisapproved 51 | } 52 | if run.Status != types.RunStatusCompleted { 53 | return fmt.Errorf("Run failed to complete (%s): %s", run.Status, run.StatusMessage) 54 | } 55 | return nil 56 | } 57 | if run.Status == types.RunStatusNeedsApproval { 58 | printApprovalMsg.Do(func() { 59 | fmt.Fprintln(os.Stdout, "Nullstone requires approval before applying infrastructure changes.") 60 | fmt.Fprintln(os.Stdout, "Visit the infrastructure logs in a browser to approve/reject.") 61 | fmt.Fprintln(os.Stdout, app_urls.GetRun(cfg, workspace, run)) 62 | }) 63 | } 64 | case <-ctx.Done(): 65 | return nil 66 | } 67 | } 68 | } 69 | 70 | // pollRun returns a channel that repeatedly delivers details about the run separated by pollDelay 71 | // If input ctx is cancelled, the returned channel is closed 72 | func pollRun(ctx context.Context, cfg api.Config, stackId int64, runUid uuid.UUID, pollDelay time.Duration) <-chan types.Run { 73 | ch := make(chan types.Run) 74 | client := api.Client{Config: cfg} 75 | go func() { 76 | defer close(ch) 77 | for { 78 | run, _ := client.Runs().Get(ctx, stackId, runUid) 79 | if run != nil { 80 | ch <- *run 81 | } 82 | select { 83 | case <-ctx.Done(): 84 | return 85 | case <-time.After(pollDelay): 86 | } 87 | } 88 | }() 89 | return ch 90 | } 91 | -------------------------------------------------------------------------------- /cmd/iac.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/urfave/cli/v2" 10 | "gopkg.in/nullstone-io/go-api-client.v0" 11 | "gopkg.in/nullstone-io/go-api-client.v0/find" 12 | "gopkg.in/nullstone-io/go-api-client.v0/types" 13 | iac2 "gopkg.in/nullstone-io/nullstone.v0/iac" 14 | ) 15 | 16 | var Iac = &cli.Command{ 17 | Name: "iac", 18 | Usage: "Utility functions to interact with Nullstone IaC", 19 | UsageText: "nullstone iac [subcommand]", 20 | Subcommands: []*cli.Command{ 21 | IacTest, 22 | IacGenerate, 23 | }, 24 | } 25 | 26 | var IacTest = &cli.Command{ 27 | Name: "test", 28 | Description: "Test the current repository's IaC files against a Nullstone stack.", 29 | Usage: "Test Nullstone IaC", 30 | UsageText: "nullstone iac test --stack= --env=", 31 | Flags: []cli.Flag{ 32 | StackFlag, 33 | EnvFlag, 34 | }, 35 | Action: func(c *cli.Context) error { 36 | return CancellableAction(func(ctx context.Context) error { 37 | return ProfileAction(c, func(cfg api.Config) error { 38 | curDir, err := os.Getwd() 39 | if err != nil { 40 | return fmt.Errorf("cannot retrieve Nullstone IaC files: %w", err) 41 | } 42 | 43 | stackName := c.String("stack") 44 | stack, err := find.Stack(ctx, cfg, stackName) 45 | if err != nil { 46 | return err 47 | } else if stack == nil { 48 | return find.StackDoesNotExistError{StackName: stackName} 49 | } 50 | 51 | envName := c.String("env") 52 | env, err := find.Env(ctx, cfg, stack.Id, envName) 53 | if err != nil { 54 | return err 55 | } else if env == nil { 56 | return find.EnvDoesNotExistError{StackName: stackName, EnvName: envName} 57 | } 58 | 59 | stdout := os.Stdout 60 | pmr, err := iac2.Discover(curDir, stdout) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | if err := iac2.Process(ctx, cfg, curDir, stdout, *stack, *env, *pmr); err != nil { 66 | return err 67 | } 68 | 69 | return iac2.Test(ctx, cfg, stdout, *stack, *env, *pmr) 70 | }) 71 | }) 72 | }, 73 | } 74 | 75 | var IacGenerate = &cli.Command{ 76 | Name: "generate", 77 | Description: "Generate IaC from a Nullstone stack for apps", 78 | Usage: "Generate IaC from an application workspace", 79 | UsageText: "nullstone iac --stack= --env= --app=", 80 | Flags: []cli.Flag{ 81 | StackFlag, 82 | EnvFlag, 83 | &cli.StringFlag{ 84 | Name: "block", 85 | Usage: "Name of the block to use for this operation", 86 | EnvVars: []string{"NULLSTONE_BLOCK"}, 87 | }, 88 | }, 89 | Action: func(c *cli.Context) error { 90 | return BlockWorkspaceAction(c, func(ctx context.Context, cfg api.Config, stack types.Stack, block types.Block, env types.Environment, workspace types.Workspace) error { 91 | apiClient := api.Client{Config: cfg} 92 | buf := bytes.NewBufferString("") 93 | err := apiClient.WorkspaceConfigFiles().GetConfigFile(ctx, stack.Id, block.Id, env.Id, buf) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | fmt.Fprintln(os.Stdout, buf.String()) 99 | return nil 100 | }) 101 | }, 102 | } 103 | -------------------------------------------------------------------------------- /cmd/wait_for.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gopkg.in/nullstone-io/go-api-client.v0" 7 | "gopkg.in/nullstone-io/go-api-client.v0/types" 8 | "gopkg.in/nullstone-io/go-api-client.v0/ws" 9 | "time" 10 | ) 11 | 12 | func waitForRunningIntentWorkflow(ctx context.Context, cfg api.Config, iw types.IntentWorkflow) (types.IntentWorkflow, error) { 13 | client := api.Client{Config: cfg} 14 | intentWorkflow, ch, err := client.IntentWorkflows().WatchGet(ctx, iw.StackId, iw.Id, ws.RetryInfinite(time.Second)) 15 | if err != nil { 16 | return iw, fmt.Errorf("error waiting for deployment: %w", err) 17 | } else if intentWorkflow == nil { 18 | return iw, context.Canceled 19 | } 20 | 21 | cur := *intentWorkflow 22 | for { 23 | switch cur.Status { 24 | case types.IntentWorkflowStatusRunning: 25 | updated, err := client.IntentWorkflows().Get(ctx, iw.StackId, iw.Id) 26 | if err != nil { 27 | return cur, fmt.Errorf("error waiting for deployment: %w", err) 28 | } else if updated != nil { 29 | cur = *updated 30 | } 31 | return cur, nil 32 | case types.IntentWorkflowStatusCompleted: 33 | return cur, nil 34 | case types.IntentWorkflowStatusFailed: 35 | return cur, fmt.Errorf("Deployment failed: %s", cur.StatusMessage) 36 | case types.IntentWorkflowStatusCancelled: 37 | return cur, fmt.Errorf("Deployment was cancelled.") 38 | } 39 | so := <-ch 40 | if so.Err != nil { 41 | return cur, fmt.Errorf("error waiting for deployment: %w", so.Err) 42 | } 43 | cur = so.Object.ApplyTo(cur) 44 | } 45 | } 46 | 47 | func waitForCompletedIntentWorkflow(ctx context.Context, cfg api.Config, iw types.IntentWorkflow) (types.IntentWorkflow, error) { 48 | client := api.Client{Config: cfg} 49 | intentWorkflow, ch, err := client.IntentWorkflows().WatchGet(ctx, iw.StackId, iw.Id, ws.RetryInfinite(time.Second)) 50 | if err != nil { 51 | return iw, fmt.Errorf("error waiting for deployment: %w", err) 52 | } else if intentWorkflow == nil { 53 | return iw, context.Canceled 54 | } 55 | 56 | cur := *intentWorkflow 57 | for { 58 | switch cur.Status { 59 | case types.IntentWorkflowStatusCompleted: 60 | return cur, nil 61 | case types.IntentWorkflowStatusFailed: 62 | return cur, fmt.Errorf("Deployment failed: %s", cur.StatusMessage) 63 | case types.IntentWorkflowStatusCancelled: 64 | return cur, fmt.Errorf("Deployment was cancelled.") 65 | } 66 | so := <-ch 67 | if so.Err != nil { 68 | return cur, fmt.Errorf("error waiting for deployment: %w", so.Err) 69 | } 70 | cur = so.Object.ApplyTo(cur) 71 | } 72 | } 73 | 74 | func waitForWorkspaceWorkflowRun(ctx context.Context, cfg api.Config, ww types.WorkspaceWorkflow) (types.Run, error) { 75 | client := api.Client{Config: cfg} 76 | workspaceWorkflow, ch, err := client.WorkspaceWorkflows().WatchGet(ctx, ww.StackId, ww.BlockId, ww.EnvId, ww.Id, ws.RetryInfinite(time.Second)) 77 | if err != nil { 78 | return types.Run{}, fmt.Errorf("error waiting for run: %w", err) 79 | } else if workspaceWorkflow == nil { 80 | return types.Run{}, context.Canceled 81 | } 82 | 83 | cur := *workspaceWorkflow 84 | for { 85 | if cur.Run != nil { 86 | return *cur.Run, nil 87 | } 88 | if types.IsTerminalWorkspaceWorkflow(cur.Status) { 89 | return types.Run{}, fmt.Errorf("workflow reached %s status before a run could be found", cur.Status) 90 | } 91 | so := <-ch 92 | if so.Err != nil { 93 | return types.Run{}, fmt.Errorf("error waiting for run: %w", so.Err) 94 | } 95 | cur = so.Object.ApplyTo(cur) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /aws/ecs/statuser.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "github.com/nullstone-io/deployment-sdk/display" 8 | "github.com/nullstone-io/deployment-sdk/logging" 9 | "github.com/nullstone-io/deployment-sdk/outputs" 10 | "gopkg.in/nullstone-io/nullstone.v0/admin" 11 | ) 12 | 13 | func NewStatuser(ctx context.Context, osWriters logging.OsWriters, source outputs.RetrieverSource, appDetails app.Details) (admin.Statuser, error) { 14 | outs, err := outputs.Retrieve[Outputs](ctx, source, appDetails.Workspace, appDetails.WorkspaceConfig) 15 | if err != nil { 16 | return nil, err 17 | } 18 | outs.InitializeCreds(source, appDetails.Workspace) 19 | 20 | return Statuser{ 21 | OsWriters: osWriters, 22 | Details: appDetails, 23 | Infra: outs, 24 | }, nil 25 | } 26 | 27 | type Statuser struct { 28 | OsWriters logging.OsWriters 29 | Details app.Details 30 | Infra Outputs 31 | } 32 | 33 | func (s Statuser) Status(ctx context.Context) (admin.StatusReport, error) { 34 | svc, err := GetService(ctx, s.Infra) 35 | if err != nil { 36 | return admin.StatusReport{}, fmt.Errorf("error retrieving ecs service: %w", err) 37 | } 38 | 39 | return admin.StatusReport{ 40 | Fields: []string{"Running", "Desired", "Pending"}, 41 | Data: map[string]interface{}{ 42 | "Running": fmt.Sprintf("%d", svc.RunningCount), 43 | "Desired": fmt.Sprintf("%d", svc.DesiredCount), 44 | "Pending": fmt.Sprintf("%d", svc.PendingCount), 45 | }, 46 | }, nil 47 | } 48 | 49 | func (s Statuser) StatusDetail(ctx context.Context) (admin.StatusDetailReports, error) { 50 | reports := admin.StatusDetailReports{} 51 | 52 | svc, err := GetService(ctx, s.Infra) 53 | if err != nil { 54 | return reports, fmt.Errorf("error retrieving ecs service: %w", err) 55 | } 56 | 57 | deploymentReport := admin.StatusDetailReport{ 58 | Name: "Deployments", 59 | Records: admin.StatusRecords{}, 60 | } 61 | for _, deployment := range svc.Deployments { 62 | record := admin.StatusRecord{ 63 | Fields: []string{"Created", "Status", "Running", "Desired", "Pending"}, 64 | Data: map[string]interface{}{ 65 | "Created": display.FormatTimePtr(deployment.CreatedAt), 66 | "Status": *deployment.Status, 67 | "Running": fmt.Sprintf("%d", deployment.RunningCount), 68 | "Desired": fmt.Sprintf("%d", deployment.DesiredCount), 69 | "Pending": fmt.Sprintf("%d", deployment.PendingCount), 70 | }, 71 | } 72 | deploymentReport.Records = append(deploymentReport.Records, record) 73 | } 74 | reports = append(reports, deploymentReport) 75 | 76 | lbReport := admin.StatusDetailReport{ 77 | Name: "Load Balancers", 78 | Records: admin.StatusRecords{}, 79 | } 80 | for _, lb := range svc.LoadBalancers { 81 | targets, err := GetTargetGroupHealth(ctx, s.Infra, *lb.TargetGroupArn) 82 | if err != nil { 83 | return reports, fmt.Errorf("error retrieving load balancer target health: %w", err) 84 | } 85 | 86 | for _, target := range targets { 87 | record := admin.StatusRecord{ 88 | Fields: []string{"Port", "Target", "Status"}, 89 | Data: map[string]interface{}{"Port": fmt.Sprintf("%d", *lb.ContainerPort)}, 90 | } 91 | record.Data["Target"] = *target.Target.Id 92 | record.Data["Status"] = target.TargetHealth.State 93 | if target.TargetHealth.Reason != "" { 94 | record.Fields = append(record.Fields, "Reason") 95 | record.Data["Reason"] = target.TargetHealth.Reason 96 | } 97 | 98 | lbReport.Records = append(lbReport.Records, record) 99 | } 100 | } 101 | reports = append(reports, lbReport) 102 | 103 | return reports, nil 104 | } 105 | -------------------------------------------------------------------------------- /cmd/logs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "github.com/mitchellh/colorstring" 6 | "github.com/nullstone-io/deployment-sdk/app" 7 | "github.com/nullstone-io/deployment-sdk/outputs" 8 | "github.com/urfave/cli/v2" 9 | "gopkg.in/nullstone-io/go-api-client.v0" 10 | "os" 11 | "time" 12 | ) 13 | 14 | var Logs = func(providers app.Providers) *cli.Command { 15 | return &cli.Command{ 16 | Name: "logs", 17 | Description: "Streams an application's logs to the console for the given environment. Use the start-time `-s` and end-time `-e` flags to only show logs for a given time period. Use the tail flag `-t` to stream the logs in real time.", 18 | Usage: "Emit application logs", 19 | UsageText: "nullstone logs [--stack=] --app= --env= [options]", 20 | Flags: []cli.Flag{ 21 | StackFlag, 22 | AppFlag, 23 | OldEnvFlag, 24 | &cli.DurationFlag{ 25 | Name: "start-time", 26 | Aliases: []string{"s"}, 27 | DefaultText: "0s", 28 | Usage: ` 29 | Emit log events that occur after the specified start-time. 30 | This is a golang duration relative to the time the command is issued. 31 | Examples: '5s' (5 seconds ago), '1m' (1 minute ago), '24h' (24 hours ago) 32 | `, 33 | }, 34 | &cli.DurationFlag{ 35 | Name: "end-time", 36 | Aliases: []string{"e"}, 37 | Usage: ` 38 | Emit log events that occur before the specified end-time. 39 | This is a golang duration relative to the time the command is issued. 40 | Examples: '5s' (5 seconds ago), '1m' (1 minute ago), '24h' (24 hours ago) 41 | `, 42 | }, 43 | &cli.DurationFlag{ 44 | Name: "interval", 45 | DefaultText: "1s", 46 | Usage: `Set --interval to a golang duration to control how often to pull new log events. 47 | This will do nothing unless --tail is set. The default is '1s' (1 second). 48 | `, 49 | }, 50 | &cli.BoolFlag{ 51 | Name: "tail", 52 | Aliases: []string{"t"}, 53 | Usage: `Set tail to watch log events and emit as they are reported. 54 | Use --interval to control how often to query log events. 55 | This is off by default. Unless this option is provided, this command will exit as soon as current log events are emitted.`, 56 | }, 57 | }, 58 | Action: func(c *cli.Context) error { 59 | logStreamOptions := app.LogStreamOptions{ 60 | WatchInterval: -1 * time.Second, // Disabled by default 61 | Emitter: app.NewWriterLogEmitter(os.Stdout), 62 | } 63 | if c.IsSet("start-time") { 64 | absoluteTime := time.Now().Add(-c.Duration("start-time")) 65 | logStreamOptions.StartTime = &absoluteTime 66 | } else { 67 | absoluteTime := time.Now() 68 | logStreamOptions.StartTime = &absoluteTime 69 | } 70 | if c.IsSet("end-time") { 71 | absoluteTime := time.Now().Add(-c.Duration("end-time")) 72 | logStreamOptions.EndTime = &absoluteTime 73 | } 74 | if c.IsSet("tail") { 75 | logStreamOptions.WatchInterval = time.Duration(0) 76 | if c.IsSet("interval") { 77 | logStreamOptions.WatchInterval = c.Duration("interval") 78 | } 79 | } 80 | 81 | return AppWorkspaceAction(c, func(ctx context.Context, cfg api.Config, appDetails app.Details) error { 82 | osWriters := CliOsWriters{Context: c} 83 | source := outputs.ApiRetrieverSource{Config: cfg} 84 | logStreamer, err := providers.FindLogStreamer(ctx, osWriters, source, appDetails) 85 | if err != nil { 86 | return err 87 | } 88 | if logStreamer == nil { 89 | colorstring.Fprintln(osWriters.Stderr(), "[yellow]log streaming is not supported for this provider[reset]") 90 | } 91 | return logStreamer.Stream(ctx, logStreamOptions) 92 | }) 93 | }, 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /docs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/urfave/cli/v2" 6 | "gopkg.in/nullstone-io/nullstone.v0/app" 7 | "log" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | func main() { 13 | log.Println("Generating CLI docs...") 14 | cliApp := app.Build() 15 | 16 | filename := "docs/CLI.md" 17 | f, err := os.Create(filename) 18 | if err != nil { 19 | log.Fatalf("unable to open %s for writing: %v", filename, err) 20 | } 21 | defer f.Close() 22 | 23 | f.WriteString("# CLI Docs\n") 24 | f.WriteString("Cheat sheet and reference for the Nullstone CLI.\n\n") 25 | f.WriteString("This document contains a list of all the commands available in the Nullstone CLI along with:\n") 26 | f.WriteString("- descriptions\n") 27 | f.WriteString("- when to use them\n") 28 | f.WriteString("- examples\n") 29 | f.WriteString("- options\n\n") 30 | 31 | for _, command := range cliApp.Commands { 32 | if len(command.Subcommands) > 0 { 33 | for _, subcommand := range command.Subcommands { 34 | outputCommandDocs(f, &command.Name, subcommand) 35 | } 36 | } else { 37 | outputCommandDocs(f, nil, command) 38 | } 39 | } 40 | } 41 | 42 | func formatUsageText(usageText string) string { 43 | result := strings.Replace(usageText, "\n", "", -1) 44 | result = strings.Replace(result, "<", "`", -1) 45 | result = strings.Replace(result, ">", "`", -1) 46 | return result 47 | } 48 | 49 | func formatFlagName(name string, aliases []string) string { 50 | if len(aliases) > 0 { 51 | return fmt.Sprintf("`--%s, -%s`", name, strings.Join(aliases, ", ")) 52 | } 53 | return fmt.Sprintf("`--%s`", name) 54 | } 55 | 56 | func formatRequired(required bool) string { 57 | if required { 58 | return "required" 59 | } 60 | return "" 61 | } 62 | 63 | func outputCommandDescription(f *os.File, name string, description string) { 64 | if name == "version" { 65 | f.WriteString("Prints the version of the CLI.\n\n") 66 | } else { 67 | f.WriteString(description + "\n\n") 68 | } 69 | } 70 | 71 | func outputCommandUsage(f *os.File, name, usage string) { 72 | f.WriteString("#### Usage\n") 73 | f.WriteString("```shell\n") 74 | if name == "version" { 75 | f.WriteString("nullstone -v\n") 76 | } else { 77 | f.WriteString(fmt.Sprintf("$ %s\n", usage)) 78 | } 79 | f.WriteString("```\n\n") 80 | } 81 | 82 | func outputCommandOptions(f *os.File, flags []cli.Flag) { 83 | if len(flags) > 0 { 84 | f.WriteString("#### Options\n") 85 | f.WriteString("| Option | Description | |\n") 86 | f.WriteString("| --- | --- | --- |\n") 87 | for _, flag := range flags { 88 | if sf, ok := flag.(*cli.StringFlag); ok { 89 | f.WriteString(fmt.Sprintf("| %s | %s | %s |\n", formatFlagName(sf.Name, sf.Aliases), formatUsageText(sf.Usage), formatRequired(sf.Required))) 90 | } else if bf, ok := flag.(*cli.BoolFlag); ok { 91 | f.WriteString(fmt.Sprintf("| %s | %s | %s |\n", formatFlagName(bf.Name, bf.Aliases), formatUsageText(bf.Usage), formatRequired(bf.Required))) 92 | } else if df, ok := flag.(*cli.DurationFlag); ok { 93 | f.WriteString(fmt.Sprintf("| %s | %s | %s |\n", formatFlagName(df.Name, df.Aliases), formatUsageText(df.Usage), formatRequired(df.Required))) 94 | } else if ssf, ok := flag.(*cli.StringSliceFlag); ok { 95 | f.WriteString(fmt.Sprintf("| %s | %s | %s |\n", formatFlagName(ssf.Name, ssf.Aliases), formatUsageText(ssf.Usage), formatRequired(ssf.Required))) 96 | } else { 97 | log.Printf("Skipping flag: %+v\n", flag) 98 | } 99 | } 100 | f.WriteString("\n\n") 101 | } 102 | } 103 | 104 | func outputCommandDocs(f *os.File, prefix *string, command *cli.Command) { 105 | name := command.Name 106 | if prefix != nil { 107 | name = fmt.Sprintf("%s %s", *prefix, name) 108 | } 109 | log.Printf("Generating docs for %s", name) 110 | f.WriteString(fmt.Sprintf("## %s\n", name)) 111 | outputCommandDescription(f, name, command.Description) 112 | outputCommandUsage(f, name, command.UsageText) 113 | outputCommandOptions(f, command.Flags) 114 | } 115 | -------------------------------------------------------------------------------- /iac/process.go: -------------------------------------------------------------------------------- 1 | package iac 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "path/filepath" 8 | 9 | "github.com/mitchellh/colorstring" 10 | "github.com/nullstone-io/iac" 11 | "github.com/nullstone-io/iac/config" 12 | "github.com/nullstone-io/iac/core" 13 | "gopkg.in/nullstone-io/go-api-client.v0" 14 | "gopkg.in/nullstone-io/go-api-client.v0/types" 15 | ) 16 | 17 | func Process(ctx context.Context, cfg api.Config, curDir string, w io.Writer, stack types.Stack, env types.Environment, pmr iac.ConfigFiles) error { 18 | apiClient := &api.Client{Config: cfg} 19 | colorstring.Fprintf(w, "[bold]Testing Nullstone IaC files against %s/%s environment...[reset]\n", stack.Name, env.Name) 20 | resolver := core.NewApiResolver(apiClient, stack.Id, env.Id) 21 | 22 | if pmr.Config != nil { 23 | blocks := pmr.Config.ToBlocks(stack.OrgName, stack.Id) 24 | blocksToCreate := map[string]types.Block{} 25 | for _, cur := range blocks { 26 | blocksToCreate[cur.Name] = cur 27 | } 28 | existing, err := apiClient.Blocks().List(ctx, stack.Id, false) 29 | if err != nil { 30 | fmt.Fprintln(w) 31 | return fmt.Errorf("error checking for existing blocks: %w", err) 32 | } 33 | for _, cur := range existing { 34 | delete(blocksToCreate, cur.Name) 35 | } 36 | 37 | if len(blocksToCreate) > 0 { 38 | colorstring.Fprintf(w, " [bold]Nullstone will create the following %d blocks...[reset]\n", len(blocksToCreate)) 39 | for name, _ := range blocksToCreate { 40 | colorstring.Fprintf(w, " [green]+[reset] %s\n", name) 41 | } 42 | if _, err := resolver.ResourceResolver.BackfillMissingBlocks(ctx, blocks); err != nil { 43 | fmt.Fprintln(w) 44 | return fmt.Errorf("error initializing normalization: %w", err) 45 | } 46 | } else { 47 | colorstring.Fprintln(w, " [green]✔[reset] Nullstone does not need to create any blocks.") 48 | } 49 | } 50 | 51 | finder := config.NewIacFinder(pmr.Config, pmr.GetOverrides(env), stack.Id, env.Id) 52 | if errs := iac.Initialize(ctx, pmr, resolver); len(errs) > 0 { 53 | colorstring.Fprintf(w, "[bold]Detected errors when initializing Nullstone IaC files[reset]\n") 54 | for _, err := range errs { 55 | relFilename, _ := filepath.Rel(curDir, err.IacContext.Filename) 56 | colorstring.Fprintf(w, " [red]✖[reset] (%s) %s => %s\n", relFilename, err.ObjectPathContext.Context(), err.ErrorMessage) 57 | } 58 | fmt.Fprintln(w) 59 | return fmt.Errorf("IaC files are invalid.") 60 | } else { 61 | colorstring.Fprintln(w, " [green]✔[reset] Initialization completed successfully.") 62 | } 63 | if errs := iac.Normalize(ctx, pmr, resolver); len(errs) > 0 { 64 | colorstring.Fprintf(w, " [bold]Detected errors when validating connections in Nullstone IaC files[reset]\n") 65 | for _, err := range errs { 66 | relFilename, _ := filepath.Rel(curDir, err.IacContext.Filename) 67 | colorstring.Fprintf(w, " [red]✖[reset] (%s) %s => %s\n", relFilename, err.ObjectPathContext.Context(), err.ErrorMessage) 68 | } 69 | fmt.Fprintln(w) 70 | return fmt.Errorf("IaC files are invalid.") 71 | } else { 72 | colorstring.Fprintln(w, " [green]✔[reset] Connection validation completed successfully.") 73 | } 74 | if errs := iac.Resolve(ctx, pmr, resolver, finder); len(errs) > 0 { 75 | colorstring.Fprintf(w, "[bold]Detected errors when resolving Nullstone IaC files[reset]\n") 76 | for _, err := range errs { 77 | relFilename, _ := filepath.Rel(curDir, err.IacContext.Filename) 78 | colorstring.Fprintf(w, " [red]✖[reset] (%s) %s => %s\n", relFilename, err.ObjectPathContext.Context(), err.ErrorMessage) 79 | } 80 | fmt.Fprintln(w) 81 | return fmt.Errorf("IaC files are invalid.") 82 | } else { 83 | colorstring.Fprintln(w, " [green]✔[reset] Resolution completed successfully.") 84 | } 85 | if errs := iac.Validate(pmr); len(errs) > 0 { 86 | colorstring.Fprintf(w, " [bold]Detected errors when validating Nullstone IaC files[reset]\n") 87 | for _, err := range errs { 88 | relFilename, _ := filepath.Rel(curDir, err.IacContext.Filename) 89 | colorstring.Fprintf(w, " [red]✖[reset] (%s) %s => %s\n", relFilename, err.ObjectPathContext.Context(), err.ErrorMessage) 90 | } 91 | fmt.Fprintln(w) 92 | return fmt.Errorf("IaC files are invalid.") 93 | } else { 94 | colorstring.Fprintln(w, " [green]✔[reset] Validation completed successfully.") 95 | } 96 | fmt.Fprintln(w) 97 | 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /workspaces/capabilities.go: -------------------------------------------------------------------------------- 1 | package workspaces 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "maps" 10 | "os" 11 | "slices" 12 | "strings" 13 | "text/template" 14 | 15 | "gopkg.in/nullstone-io/go-api-client.v0" 16 | "gopkg.in/nullstone-io/go-api-client.v0/artifacts" 17 | "gopkg.in/nullstone-io/go-api-client.v0/find" 18 | "gopkg.in/nullstone-io/go-api-client.v0/types" 19 | ) 20 | 21 | var ( 22 | capabilitiesTemplateFuncs template.FuncMap 23 | ) 24 | 25 | func init() { 26 | toJson := func(v interface{}) string { 27 | if v == nil { 28 | return "null" 29 | } 30 | rawJson, _ := json.Marshal(v) 31 | return string(rawJson) 32 | } 33 | // to_json_string is intended to emit the json as a string 34 | // This is helpful when wrapping in terraform with `jsondecode(...)` 35 | toJsonString := func(v interface{}) string { 36 | return toJson(toJson(v)) 37 | } 38 | 39 | capabilitiesTemplateFuncs = template.FuncMap{ 40 | "to_json": toJson, 41 | "to_json_string": toJsonString, 42 | } 43 | } 44 | 45 | type CapabilitiesGenerator struct { 46 | RegistryAddress string 47 | Manifest Manifest 48 | TemplateFilename string 49 | TargetFilename string 50 | ApiConfig api.Config 51 | } 52 | 53 | func (g CapabilitiesGenerator) ShouldGenerate() bool { 54 | _, err := os.Lstat(g.TemplateFilename) 55 | return err == nil || !os.IsNotExist(err) 56 | } 57 | 58 | func (g CapabilitiesGenerator) Generate(runConfig types.RunConfig) error { 59 | capabilities := runConfig.Capabilities 60 | var err error 61 | if capabilities, err = g.backfillMeta(capabilities); err != nil { 62 | return fmt.Errorf("error filling capability meta: %w", err) 63 | } 64 | if capabilities, err = g.transformCapabilities(capabilities); err != nil { 65 | return fmt.Errorf("error retrieving current configuration of capabilities: %w", err) 66 | } 67 | 68 | rawTemplateContent, err := os.ReadFile(g.TemplateFilename) 69 | if err != nil { 70 | return fmt.Errorf("error reading capabilities template file: %w", err) 71 | } 72 | 73 | content := bytes.NewBufferString("") 74 | tmpl, err := template.New("capabilities").Funcs(capabilitiesTemplateFuncs).Parse(string(rawTemplateContent)) 75 | if err != nil { 76 | return fmt.Errorf("error parsing capabilities template: %w", err) 77 | } 78 | 79 | if err := tmpl.Execute(content, capabilities); err != nil { 80 | return fmt.Errorf("error executing capabilities template: %w", err) 81 | } 82 | 83 | if err := os.WriteFile(g.TargetFilename, content.Bytes(), 0644); err != nil { 84 | return fmt.Errorf("error writing %q: %s", g.TargetFilename, err) 85 | } 86 | return nil 87 | } 88 | 89 | func (g CapabilitiesGenerator) transformCapabilities(capabilities types.CapabilityConfigs) (types.CapabilityConfigs, error) { 90 | // Terraform assumes that module source has a host of `registry.terraform.io` if not specified 91 | // We are going to override that behavior to presume `api.nullstone.io` instead 92 | result := make(types.CapabilityConfigs, 0) 93 | for _, capability := range capabilities { 94 | if ms, err := artifacts.ParseSource(capability.Source); err == nil { 95 | if ms.Host == "" { 96 | // Set the module source host to api.nullstone.io without the URI scheme 97 | ms.Host = strings.TrimPrefix(strings.TrimPrefix(g.RegistryAddress, "https://"), "http://") 98 | capability.Source = ms.String() 99 | } 100 | } 101 | result = append(result, capability) 102 | } 103 | return result, nil 104 | } 105 | 106 | func (g CapabilitiesGenerator) backfillMeta(capabilities types.CapabilityConfigs) (types.CapabilityConfigs, error) { 107 | errs := make([]error, 0) 108 | result := make(types.CapabilityConfigs, 0) 109 | for _, cur := range capabilities { 110 | meta, err := g.resolveCapabilityMeta(cur) 111 | if err != nil { 112 | errs = append(errs, err) 113 | } 114 | cur.Meta = meta 115 | result = append(result, cur) 116 | } 117 | return result, errors.Join(errs...) 118 | } 119 | 120 | func (g CapabilitiesGenerator) resolveCapabilityMeta(cur types.CapabilityConfig) (*types.CapabilityConfigMeta, error) { 121 | ctx := context.Background() 122 | mod, err := find.Module(ctx, g.ApiConfig, cur.Source) 123 | if err != nil { 124 | return nil, err 125 | } 126 | meta := &types.CapabilityConfigMeta{ 127 | Subcategory: mod.Subcategory, 128 | Platform: mod.Platform, 129 | Subplatform: mod.Subplatform, 130 | } 131 | 132 | mv, err := find.ModuleVersion(ctx, g.ApiConfig, cur.Source, cur.SourceVersion) 133 | if err != nil { 134 | return nil, err 135 | } 136 | meta.OutputNames = slices.Collect(maps.Keys(mv.Manifest.Outputs)) 137 | if meta.OutputNames == nil { 138 | meta.OutputNames = make([]string, 0) 139 | } 140 | 141 | return meta, nil 142 | } 143 | -------------------------------------------------------------------------------- /iac/test.go: -------------------------------------------------------------------------------- 1 | package iac 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/mitchellh/colorstring" 9 | "github.com/nullstone-io/iac" 10 | iacEvents "github.com/nullstone-io/iac/events" 11 | "github.com/nullstone-io/iac/workspace" 12 | "gopkg.in/nullstone-io/go-api-client.v0" 13 | "gopkg.in/nullstone-io/go-api-client.v0/types" 14 | ) 15 | 16 | const ( 17 | indentStep = " " 18 | ) 19 | 20 | func Test(ctx context.Context, cfg api.Config, w io.Writer, stack types.Stack, env types.Environment, pmr iac.ConfigFiles) error { 21 | if err := testWorkspaces(ctx, cfg, w, stack, env, pmr); err != nil { 22 | return err 23 | } 24 | if err := testEvents(ctx, cfg, w, stack, env, pmr); err != nil { 25 | return err 26 | } 27 | return nil 28 | } 29 | 30 | func testWorkspaces(ctx context.Context, cfg api.Config, w io.Writer, stack types.Stack, env types.Environment, pmr iac.ConfigFiles) error { 31 | blockNames := pmr.BlockNames(env) 32 | apiClient := &api.Client{Config: cfg} 33 | allBlocks, err := apiClient.Blocks().List(ctx, stack.Id, false) 34 | if err != nil { 35 | return fmt.Errorf("error retrieving blocks: %w", err) 36 | } 37 | 38 | blocks := make(types.Blocks, 0) 39 | for _, block := range allBlocks { 40 | if _, ok := blockNames[block.Name]; ok { 41 | blocks = append(blocks, block) 42 | } 43 | } 44 | 45 | hasError := false 46 | plural := "s" 47 | if len(blocks) == 1 { 48 | plural = "" 49 | } 50 | colorstring.Fprintf(w, "[bold]Detecting changes for %d block%s in %s/%s...[reset]\n", len(blocks), plural, stack.Name, env.Name) 51 | for _, block := range blocks { 52 | if err := testWorkspace(ctx, apiClient, w, stack, block, env, pmr); err != nil { 53 | colorstring.Fprintf(w, "[red]An error occurred: %s[reset]\n", err) 54 | hasError = true 55 | } 56 | } 57 | 58 | if hasError { 59 | return fmt.Errorf("An error occurred diffing block%s.", plural) 60 | } 61 | return nil 62 | } 63 | 64 | func testWorkspace(ctx context.Context, apiClient *api.Client, w io.Writer, stack types.Stack, block types.Block, env types.Environment, pmr iac.ConfigFiles) error { 65 | effective, err := apiClient.WorkspaceConfigs().GetEffective(ctx, stack.Id, block.Id, env.Id) 66 | if err != nil { 67 | return fmt.Errorf("error retrieving workspace: %w", err) 68 | } else if effective == nil { 69 | return nil 70 | } 71 | 72 | fillWorkspaceConfigMissingEnv(effective, env) 73 | 74 | updated, err := effective.Clone() 75 | if err != nil { 76 | return fmt.Errorf("error cloning workspace: %w", err) 77 | } 78 | 79 | updater := workspace.ConfigUpdater{ 80 | Config: &updated, 81 | TemplateVars: workspace.TemplateVars{ 82 | OrgName: stack.OrgName, 83 | StackName: stack.Name, 84 | EnvName: env.Name, 85 | EnvIsProd: env.IsProd, 86 | }, 87 | } 88 | if err := iac.ApplyChangesTo(pmr, block, env, updater); err != nil { 89 | return fmt.Errorf("error applying changes: %w", err) 90 | } 91 | 92 | changes := workspace.DiffWorkspaceConfig(*effective, updated) 93 | emitWorkspaceChanges(w, block, changes) 94 | return nil 95 | } 96 | 97 | func testEvents(ctx context.Context, cfg api.Config, w io.Writer, stack types.Stack, env types.Environment, pmr iac.ConfigFiles) error { 98 | apiClient := api.Client{Config: cfg} 99 | existingEvents, err := apiClient.EnvEvents().List(ctx, stack.Id, env.Id) 100 | if err != nil { 101 | return fmt.Errorf("error looking up existing events: %w", err) 102 | } 103 | 104 | current := map[string]types.EnvEvent{} 105 | for _, cur := range existingEvents { 106 | if cur.OwningRepoUrl == pmr.RepoUrl { 107 | current[cur.Name] = cur 108 | } 109 | } 110 | 111 | colorstring.Fprintf(w, "[bold]Detecting changes for events in %s/%s...[reset]\n", stack.Name, env.Name) 112 | desired := iacEvents.Get(pmr, env) 113 | changes := iacEvents.Diff(current, desired, pmr.RepoUrl) 114 | emitEventChanges(w, changes) 115 | return nil 116 | } 117 | 118 | func fillWorkspaceConfigMissingEnv(c *types.WorkspaceConfig, env types.Environment) { 119 | envId := env.Id 120 | fillRef := func(conn types.Connection) bool { 121 | if conn.EffectiveTarget == nil { 122 | return false 123 | } 124 | filled := false 125 | if conn.EffectiveTarget.StackId == env.StackId { 126 | if conn.EffectiveTarget.EnvId == nil { 127 | conn.EffectiveTarget.EnvId = &envId 128 | filled = true 129 | } 130 | if conn.EffectiveTarget.EnvName == "" { 131 | conn.EffectiveTarget.EnvName = env.Name 132 | filled = true 133 | } 134 | } 135 | return filled 136 | } 137 | fillConns := func(conns types.Connections) bool { 138 | filled := false 139 | for name, conn := range conns { 140 | if fillRef(conn) { 141 | conns[name] = conn 142 | filled = true 143 | } 144 | } 145 | return filled 146 | } 147 | 148 | fillConns(c.Connections) 149 | for i, capability := range c.Capabilities { 150 | if fillConns(capability.Connections) { 151 | c.Capabilities[i] = capability 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /cmd/wait.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/nullstone-io/deployment-sdk/logging" 7 | "github.com/nullstone-io/deployment-sdk/workspace" 8 | "github.com/urfave/cli/v2" 9 | "gopkg.in/nullstone-io/go-api-client.v0" 10 | "gopkg.in/nullstone-io/go-api-client.v0/find" 11 | "gopkg.in/nullstone-io/go-api-client.v0/types" 12 | "gopkg.in/nullstone-io/nullstone.v0/app_urls" 13 | "gopkg.in/nullstone-io/nullstone.v0/runs" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | var ( 19 | WaitForFlag = &cli.StringFlag{ 20 | Name: "for", 21 | Usage: `Configure the wait command to reach a specific status. 22 | Currently this supports --for=launched. 23 | In the future, we will support --for=destroyed and --for=deployed`, 24 | } 25 | WaitTimeoutFlag = &cli.DurationFlag{ 26 | Name: "timeout", 27 | Value: time.Hour, 28 | Usage: `Set --timeout to a golang duration to control how long to wait for a status before cancelling. 29 | The default is '1h' (1 hour). 30 | `, 31 | } 32 | WaitApprovalTimeoutFlag = &cli.DurationFlag{ 33 | Name: "approval-timeout", 34 | Value: 15 * time.Minute, 35 | Usage: `Set --approval-timeout to a golang duration to control how long to wait for approval before cancelling. 36 | If the workspace run never reaches "needs-approval", this has no effect. 37 | The default is '15m' (15 minutes). 38 | `, 39 | } 40 | ) 41 | 42 | var Wait = func() *cli.Command { 43 | return &cli.Command{ 44 | Name: "wait", 45 | Description: `Waits for a workspace to reach a specific status. 46 | This is helpful to wait for infrastructure to provision or an app to deploy. 47 | Currently, this supports --for=launched to wait for a workspace to provision. 48 | In the future, we will add --for=destroyed and --for=deployed.`, 49 | Usage: "Wait for a block to launch, destroy, or deploy in an environment.", 50 | UsageText: "nullstone wait [--stack=] --block= --env= [options]", 51 | Flags: []cli.Flag{ 52 | StackFlag, 53 | BlockFlag, 54 | EnvFlag, 55 | WaitForFlag, 56 | WaitTimeoutFlag, 57 | WaitApprovalTimeoutFlag, 58 | }, 59 | Action: func(c *cli.Context) error { 60 | waitFor := c.String(WaitForFlag.Name) 61 | timeout := c.Duration(WaitTimeoutFlag.Name) 62 | approvalTimeout := c.Duration(WaitApprovalTimeoutFlag.Name) 63 | 64 | return BlockWorkspaceAction(c, func(ctx context.Context, cfg api.Config, stack types.Stack, block types.Block, env types.Environment, ws types.Workspace) error { 65 | details := workspace.Details{ 66 | Block: &block, 67 | Env: &env, 68 | Workspace: &ws, 69 | Module: nil, 70 | } 71 | osWriters := CliOsWriters{Context: c} 72 | switch strings.ToLower(waitFor) { 73 | case "launched": 74 | return WaitForLaunch(ctx, osWriters, cfg, details, timeout, approvalTimeout) 75 | default: 76 | return fmt.Errorf("The wait command does not support --for=%s", waitFor) 77 | } 78 | }) 79 | }, 80 | } 81 | } 82 | 83 | func WaitForLaunch(ctx context.Context, osWriters logging.OsWriters, cfg api.Config, details workspace.Details, 84 | timeout time.Duration, approvalTimeout time.Duration) error { 85 | stderr := osWriters.Stderr() 86 | if details.Workspace.Status == types.WorkspaceStatusProvisioned { 87 | fmt.Fprintln(stderr, "Workspace has launched already.") 88 | return nil 89 | } 90 | 91 | // If we made it here, we want to wait for a launch and the workspace has not been provisioned yet 92 | // Let's look for all the runs on this workspace to see if there are any pending 93 | // If we retrieve non-terminal runs, we use the oldest (next in line) to determine whether we can proceed 94 | launchRun, err := findLaunchRun(ctx, cfg, details) 95 | if err != nil { 96 | return err 97 | } else if launchRun == nil { 98 | return fmt.Errorf("app %q has not been provisioned in %q environment yet", details.Block.Name, details.Env.Name) 99 | } 100 | 101 | fmt.Fprintf(stderr, "Waiting for %q to launch in %q environment...\n", details.Block.Name, details.Env.Name) 102 | fmt.Fprintf(stderr, "Watching run for launch: %s\n", app_urls.GetRun(cfg, *details.Workspace, *launchRun)) 103 | fmt.Fprintf(stderr, "Timeout = %s, Approval Timeout = %s\n", timeout, approvalTimeout) 104 | 105 | result, err := runs.WaitForTerminalRun(ctx, osWriters, cfg, *details.Workspace, *launchRun, timeout, approvalTimeout) 106 | if err != nil { 107 | return err 108 | } else if result.Status == types.RunStatusCompleted { 109 | fmt.Fprintln(stderr, "Workspace launched successfully.") 110 | fmt.Fprintln(stderr, "") 111 | return nil 112 | } 113 | fmt.Fprintf(stderr, "Workspace failed to launch because run finished with %q status.\n", result.Status) 114 | return fmt.Errorf("Could not run command because app failed to launch") 115 | } 116 | 117 | func findLaunchRun(ctx context.Context, cfg api.Config, details workspace.Details) (*types.Run, error) { 118 | ntRuns, err := find.NonTerminalRuns(ctx, cfg, details.Block.StackId, details.Workspace.Uid) 119 | if err != nil { 120 | return nil, err 121 | } 122 | 123 | // It's possible that the queue of runs contains a "destroy" run 124 | // Let's find the first that is a launch/apply 125 | for _, run := range ntRuns { 126 | if run.IsDestroy { 127 | continue 128 | } 129 | return &run, nil 130 | } 131 | return nil, nil 132 | } 133 | -------------------------------------------------------------------------------- /cmd/push.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/nullstone-io/deployment-sdk/app" 8 | "github.com/nullstone-io/deployment-sdk/logging" 9 | "github.com/nullstone-io/deployment-sdk/outputs" 10 | "github.com/urfave/cli/v2" 11 | "gopkg.in/nullstone-io/go-api-client.v0" 12 | "gopkg.in/nullstone-io/nullstone.v0/artifacts" 13 | ) 14 | 15 | var ( 16 | UniquePushFlag = &cli.BoolFlag{ 17 | Name: "unique", 18 | Usage: "Use this to *always* push the artifact with a unique version. If the input version already exists, an incrementing `-` suffix is added.", 19 | } 20 | ) 21 | 22 | // Push command performs a docker push to an authenticated image registry configured against an app/container 23 | var Push = func(providers app.Providers) *cli.Command { 24 | return &cli.Command{ 25 | Name: "push", 26 | Description: "Upload (push) an artifact containing the source for your application. Specify a semver version to associate with the artifact. The version specified can be used in the deploy command to select this artifact. By default, this command does nothing if an artifact with the same version already exists. Use --unique to force push with a unique version.", 27 | Usage: "Push artifact", 28 | UsageText: "nullstone push [--stack=] --app= --env= [options]", 29 | Flags: []cli.Flag{ 30 | StackFlag, 31 | AppFlag, 32 | OldEnvFlag, 33 | AppSourceFlag, 34 | AppVersionFlag, 35 | UniquePushFlag, 36 | }, 37 | Action: func(c *cli.Context) error { 38 | return AppWorkspaceAction(c, func(ctx context.Context, cfg api.Config, appDetails app.Details) error { 39 | osWriters := CliOsWriters{Context: c} 40 | source := c.String(AppSourceFlag.Name) 41 | 42 | pusher, err := getPusher(providers, cfg, appDetails) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | info, skip, err := calcPushInfo(ctx, c, pusher) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if skip { 53 | fmt.Fprintln(osWriters.Stderr(), "App artifact already exists. Skipped push.") 54 | fmt.Fprintln(osWriters.Stderr(), "") 55 | return nil 56 | } 57 | 58 | if err := recordArtifact(ctx, osWriters, cfg, appDetails, info); err != nil { 59 | return err 60 | } 61 | 62 | return push(ctx, osWriters, pusher, source, info) 63 | }) 64 | }, 65 | } 66 | } 67 | 68 | func getPusher(providers app.Providers, cfg api.Config, appDetails app.Details) (app.Pusher, error) { 69 | ctx := context.TODO() 70 | pusher, err := providers.FindPusher(ctx, logging.StandardOsWriters{}, outputs.ApiRetrieverSource{Config: cfg}, appDetails) 71 | if err != nil { 72 | return nil, fmt.Errorf("error creating app pusher: %w", err) 73 | } else if pusher == nil { 74 | return nil, fmt.Errorf("this application category=%s, type=%s does not support push", appDetails.Module.Category, appDetails.Module.Type) 75 | } 76 | return pusher, nil 77 | } 78 | 79 | // calcPushInfo calculates version and commit info to push an artifact 80 | // This also returns whether we should skip pushing the artifact 81 | func calcPushInfo(ctx context.Context, c *cli.Context, pusher app.Pusher) (artifacts.VersionInfo, bool, error) { 82 | osWriters := CliOsWriters{Context: c} 83 | stderr := osWriters.Stderr() 84 | version, unique := c.String(AppVersionFlag.Name), c.IsSet(UniquePushFlag.Name) 85 | 86 | if version == "" { 87 | fmt.Fprintf(stderr, "No version specified. Defaulting version based on current git commit sha...\n") 88 | } 89 | info, err := artifacts.GetVersionInfoFromWorkingDir(version) 90 | if err != nil { 91 | return info, false, err 92 | } 93 | if version == "" { 94 | fmt.Fprintf(stderr, "Version defaulted to %q.\n", info.DesiredVersion) 95 | } 96 | 97 | deconflictor, err := artifacts.NewVersionDeconflictor(ctx, pusher) 98 | if err != nil { 99 | return info, false, fmt.Errorf("error reading artifact registry: %w", err) 100 | } 101 | 102 | if unique { 103 | info.EffectiveVersion = deconflictor.CreateUnique(info.DesiredVersion) 104 | fmt.Fprintf(stderr, "Artifacts matching %q exist in artifact registry. Changing version to %q.\n", info.DesiredVersion, info.EffectiveVersion) 105 | return info, false, nil 106 | } 107 | info.EffectiveVersion = info.DesiredVersion 108 | return info, deconflictor.DoesVersionExist(info.DesiredVersion), nil 109 | } 110 | 111 | func recordArtifact(ctx context.Context, osWriters logging.OsWriters, cfg api.Config, appDetails app.Details, info artifacts.VersionInfo) error { 112 | apiClient := api.Client{Config: cfg} 113 | if _, err := apiClient.CodeArtifacts().Upsert(ctx, appDetails.App.StackId, appDetails.App.Id, appDetails.Env.Id, info.EffectiveVersion, info.CommitInfo); err != nil { 114 | fmt.Fprintf(osWriters.Stderr(), "Unable to record artifact in Nullstone: %s\n", err) 115 | } 116 | fmt.Fprintf(osWriters.Stderr(), "Recorded artifact (%s) in Nullstone (commit SHA = %s).\n", info.EffectiveVersion, info.CommitInfo.CommitSha) 117 | return nil 118 | } 119 | 120 | func push(ctx context.Context, osWriters logging.OsWriters, pusher app.Pusher, source string, info artifacts.VersionInfo) error { 121 | fmt.Fprintln(osWriters.Stderr(), "Pushing app artifact...") 122 | if err := pusher.Push(ctx, source, info.EffectiveVersion); err != nil { 123 | return fmt.Errorf("error pushing artifact: %w", err) 124 | } 125 | fmt.Fprintln(osWriters.Stderr(), "App artifact pushed.") 126 | fmt.Fprintln(osWriters.Stderr(), "") 127 | return nil 128 | } 129 | -------------------------------------------------------------------------------- /cmd/module_survey.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/AlecAivazis/survey/v2" 7 | "gopkg.in/nullstone-io/go-api-client.v0" 8 | "gopkg.in/nullstone-io/go-api-client.v0/types" 9 | "strings" 10 | ) 11 | 12 | type moduleSurvey struct{} 13 | 14 | func (m *moduleSurvey) Ask(cfg api.Config, defaults *types.ModuleManifest) (*types.ModuleManifest, error) { 15 | manifest := types.ModuleManifest{} 16 | if defaults != nil { 17 | manifest = *defaults 18 | } 19 | 20 | initialQuestions := []*survey.Question{ 21 | m.questionOrgName(cfg), 22 | { 23 | Name: "Name", 24 | Validate: survey.Required, 25 | Transform: survey.ToLower, 26 | Prompt: &survey.Input{ 27 | Message: "Module Name:", 28 | Help: "A name that is used to uniquely identify the module in Nullstone. (Example: aws-rds-postgres)", 29 | Default: manifest.Name, 30 | }, 31 | }, 32 | { 33 | Name: "FriendlyName", 34 | Validate: survey.Required, 35 | Prompt: &survey.Input{ 36 | Message: "Friendly Name:", 37 | Help: "A friendly name is what appears to users in the Nullstone UI. (Example: RDS Postgres)", 38 | Default: manifest.FriendlyName, 39 | }, 40 | }, 41 | { 42 | Name: "Description", 43 | Validate: survey.Required, 44 | Prompt: &survey.Input{ 45 | Message: "Description:", 46 | Help: "A description helps users understand what the module does.", 47 | Default: manifest.Description, 48 | }, 49 | }, 50 | } 51 | if err := survey.Ask(initialQuestions, &manifest); err != nil { 52 | return nil, err 53 | } 54 | 55 | // IsPublic 56 | isPublicPrompt := &survey.Confirm{ 57 | Message: "Make this module available to everybody?", 58 | Default: manifest.IsPublic, 59 | } 60 | if err := survey.AskOne(isPublicPrompt, &manifest.IsPublic); err != nil { 61 | return nil, err 62 | } 63 | 64 | // Category 65 | categoryPrompt := &survey.Select{ 66 | Message: "Category:", 67 | Options: types.AllCategoryNames, 68 | } 69 | if err := survey.AskOne(categoryPrompt, &manifest.Category); err != nil { 70 | return nil, err 71 | } 72 | 73 | // [Optional] Subcategory 74 | subcategories, _ := types.AllSubcategoryNames[types.CategoryName(manifest.Category)] 75 | if len(subcategories) > 0 { 76 | subcategoryPrompt := &survey.Select{ 77 | Message: "Subcategory:", 78 | Options: subcategories, 79 | } 80 | if err := survey.AskOne(subcategoryPrompt, &manifest.Subcategory); err != nil { 81 | return nil, err 82 | } 83 | } 84 | manifest.Subcategory = strings.ToLower(manifest.Subcategory) 85 | 86 | // App Categories 87 | if strings.HasPrefix(manifest.Category, "capability") { 88 | // We are splitting category and subcategory 89 | // We need to map existing app categories (e.g. app/container) to new format (e.g. container) 90 | curAppCategories := make([]string, 0) 91 | for _, ac := range manifest.AppCategories { 92 | curAppCategories = append(curAppCategories, strings.TrimPrefix(ac, "app/")) 93 | } 94 | 95 | appSubcategories := types.AllSubcategoryNames[types.CategoryApp] 96 | // Only capabilities are able to limit their targets to a set of app categories 97 | appCategoriesPrompt := &survey.MultiSelect{ 98 | Message: "Supported App Category: (select none if all apps are supported)", 99 | Options: appSubcategories, 100 | Help: "This allows you to limit which types of apps are allowed to use this capability module", 101 | Default: curAppCategories, 102 | } 103 | manifest.AppCategories = make([]string, 0) 104 | if err := survey.AskOne(appCategoriesPrompt, &manifest.AppCategories); err != nil { 105 | return nil, err 106 | } 107 | } 108 | 109 | allProviderTypes := []string{ 110 | "aws", 111 | "gcp", 112 | } 113 | providerTypesPrompt := &survey.MultiSelect{ 114 | Message: "Provider Types:", 115 | Options: allProviderTypes, 116 | Default: manifest.ProviderTypes, 117 | } 118 | providerTypes := make([]string, 0) 119 | if err := survey.AskOne(providerTypesPrompt, &providerTypes); err != nil { 120 | return nil, err 121 | } 122 | manifest.ProviderTypes = providerTypes 123 | 124 | var fullPlatform struct { 125 | Platform string 126 | } 127 | curPlatform := manifest.Platform 128 | if manifest.Subplatform != "" { 129 | curPlatform = fmt.Sprintf("%s:%s", curPlatform, manifest.Subplatform) 130 | } 131 | finalQuestions := []*survey.Question{ 132 | { 133 | Name: "Platform", 134 | Validate: survey.Required, 135 | Prompt: &survey.Input{ 136 | Message: "Platform:", 137 | Help: "The platform that the module targets. (e.g. ecs:fargate, eks, lambda:container, postgres:rds)", 138 | Default: curPlatform, 139 | }, 140 | }, 141 | } 142 | if err := survey.Ask(finalQuestions, &fullPlatform); err != nil { 143 | return nil, err 144 | } 145 | manifest.Platform = fullPlatform.Platform 146 | tokens := strings.SplitN(manifest.Platform, ":", 2) 147 | if len(tokens) > 1 { 148 | manifest.Platform, manifest.Subplatform = tokens[0], tokens[1] 149 | } 150 | 151 | return &manifest, nil 152 | } 153 | 154 | func (m *moduleSurvey) questionOrgName(cfg api.Config) *survey.Question { 155 | ctx := context.TODO() 156 | client := api.Client{Config: cfg} 157 | orgs, _ := client.Organizations().List(ctx) 158 | 159 | return &survey.Question{ 160 | Name: "OrgName", 161 | Validate: survey.Required, 162 | Prompt: &survey.Input{ 163 | Message: "Which organizations owns this module?", 164 | Default: cfg.OrgName, 165 | Suggest: func(toComplete string) []string { 166 | matched := make([]string, 0) 167 | for _, org := range orgs { 168 | if strings.HasPrefix(org.Name, toComplete) { 169 | matched = append(matched, org.Name) 170 | } 171 | } 172 | return matched 173 | }, 174 | }, 175 | } 176 | } 177 | --------------------------------------------------------------------------------