Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ cmd/
register.go Interactive team registration wizard
status.go Project status (costs, ECS services)
whoami.go Show AWS + GitHub identity
secret.go List/set app.yaml secrets in SSM
internal/
aws/aws.go AWS SDK helpers (STS, Cost Explorer, ECS)
config/config.go Config directory (~/.javabin/)
Expand All @@ -37,6 +38,7 @@ internal/
| `javabin register-team` | Interactive wizard — prompts for team name, description, members (google + github), and budget; creates a team registration PR against `javaBin/registry` (`teams/{name}.yaml`) |
| `javabin init` | Interactive wizard — scaffolds a new app repo from `javaBin/app-template`, writes `app.yaml`, `Dockerfile`, and deploy workflow, then prints next steps for adding the repo to a GitHub team |
| `javabin status` | Shows month-to-date cost (Cost Explorer) and ECS service status. Infers project from git remote or accepts `--project` flag |
| `javabin secret list` / `javabin secret set <name> [--stdin]` | Run in an app repo: shows which `resources.secrets` from `app.yaml` still hold the platform's `PLACEHOLDER` (never prints values) and overwrites an existing value as an SSM SecureString under `/javabin/apps/{team}/{app}/` (never creates parameters — deploy first). The platform restarts the service automatically when a secret changes. Needs the `javabin-developer` SSO role |
| `javabin whoami` | Shows AWS identity (STS GetCallerIdentity) and GitHub user (gh API) |

## Build and Test
Expand All @@ -46,15 +48,17 @@ go build -o javabin .
./javabin --help
```

No tests yet. When adding tests, use standard `go test ./...`.
```bash
go test ./...
```

## Release

Releases are built with GoReleaser on semver tags. Binaries go to GitHub Releases and the Homebrew tap (`javaBin/tap/javabin`).

## Design Decisions

- **No deploy/plan/apply commands** — those run exclusively in CI. The CLI is for registration and status only.
- **No deploy/plan/apply commands** — those run exclusively in CI. The CLI covers registration, scaffolding, status and setting app secret values.
- **No infrastructure management** — use `app.yaml` and let the platform handle it.
- **GitHub API directly** (not `go-github` library) — keeps dependencies minimal for a simple REST client.

Expand Down
251 changes: 251 additions & 0 deletions cmd/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package cmd

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ssm"
ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
"github.com/spf13/cobra"
"golang.org/x/term"
"gopkg.in/yaml.v3"

jbaws "github.com/javaBin/javabin-cli/internal/aws"
)

const (
project = "javabin"
placeholder = "PLACEHOLDER"
)

// appSecrets is the part of app.yaml the secret commands need.
type appSecrets struct {
Name string `yaml:"name"`
Team string `yaml:"team"`
Resources struct {
Secrets []struct {
Name string `yaml:"name"`
Env string `yaml:"env"`
} `yaml:"secrets"`
} `yaml:"resources"`
}

func loadAppYaml(path string) (*appSecrets, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s (run from the app repo root): %w", path, err)
}
var app appSecrets
if err := yaml.Unmarshal(data, &app); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if app.Name == "" || app.Team == "" {
return nil, fmt.Errorf("%s must set name and team", path)
}
return &app, nil
}

// secretPath is where app.yaml `resources.secrets` entries live in SSM
// (service-secret module).
func secretPath(app *appSecrets, name string) string {
return fmt.Sprintf("/%s/apps/%s/%s/%s", project, app.Team, app.Name, name)
}

func (a *appSecrets) declares(name string) bool {
for _, s := range a.Resources.Secrets {
if s.Name == name {
return true
}
}
return false
}

var (
secretAppYaml string
secretStdin bool
)

// ssmAPI is the subset of the SSM client the secret commands use (fakeable in tests).
type ssmAPI interface {
GetParameter(ctx context.Context, in *ssm.GetParameterInput, opts ...func(*ssm.Options)) (*ssm.GetParameterOutput, error)
PutParameter(ctx context.Context, in *ssm.PutParameterInput, opts ...func(*ssm.Options)) (*ssm.PutParameterOutput, error)
}

var secretCmd = &cobra.Command{
Use: "secret",
Short: "List and set the app's secrets (app.yaml resources.secrets)",
Long: `Secrets declared in app.yaml under resources.secrets are created by the
platform with a PLACEHOLDER value. Set the real values here; they are stored as
SSM SecureStrings under /javabin/apps/<team>/<app>/ and injected into the
container at task start. Needs AWS SSO credentials (javabin-developer).`,
}

var secretListCmd = &cobra.Command{
Use: "list",
Short: "Show which declared secrets still have the PLACEHOLDER value",
RunE: func(cmd *cobra.Command, args []string) error {
app, err := loadAppYaml(secretAppYaml)
if err != nil {
return err
}
ctx := context.Background()
cfg, err := jbaws.LoadConfig(ctx)
if err != nil {
return err
}
client := ssm.NewFromConfig(cfg)
if len(app.Resources.Secrets) == 0 {
fmt.Println("app.yaml declares no resources.secrets")
return nil
}
for _, s := range app.Resources.Secrets {
fmt.Printf("%-24s %-28s %s\n", s.Name, s.Env, secretStatus(ctx, client, secretPath(app, s.Name)))
}
return nil
},
}

// secretStatus never prints the value, only whether it is set. Decrypting is
// deliberate: it is the only way to compare against PLACEHOLDER. The value is
// never printed or stored.
func secretStatus(ctx context.Context, client ssmAPI, path string) string {
out, err := client.GetParameter(ctx, &ssm.GetParameterInput{Name: aws.String(path), WithDecryption: aws.Bool(true)})
var notFound *ssmtypes.ParameterNotFound
switch {
case errors.As(err, &notFound):
return "not created yet (deploy first)"
case err != nil:
return "error: " + err.Error()
case aws.ToString(out.Parameter.Value) == placeholder:
return "PLACEHOLDER — not set"
default:
return fmt.Sprintf("set (version %d)", out.Parameter.Version)
}
}

var secretSetCmd = &cobra.Command{
Use: "set <name>",
Short: "Set a secret's value (prompted, or from stdin with --stdin)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
app, err := loadAppYaml(secretAppYaml)
if err != nil {
return err
}
if !app.declares(name) {
return fmt.Errorf("%q is not in app.yaml resources.secrets — add it and deploy first", name)
}

value, err := readSecretValue(os.Stdin, secretStdin, name)
if err != nil {
return err
}

ctx := context.Background()
cfg, err := jbaws.LoadConfig(ctx)
if err != nil {
return err
}
path := secretPath(app, name)
if err := setSecret(ctx, ssm.NewFromConfig(cfg), path, value); err != nil {
return err
}
fmt.Printf("Set %s\n", path)
fmt.Printf("The platform will restart the service %s-%s within a minute so new tasks get the new value.\n", app.Team, app.Name)
return nil
},
}

// setSecret overwrites an existing secret parameter. It never creates one: the
// platform creates declared secrets on deploy (and IAM denies writes to
// parameters not tagged with the team).
func setSecret(ctx context.Context, client ssmAPI, path, value string) error {
_, err := client.GetParameter(ctx, &ssm.GetParameterInput{Name: aws.String(path)})
var notFound *ssmtypes.ParameterNotFound
if errors.As(err, &notFound) {
return fmt.Errorf("%s does not exist yet — deploy the app first (the platform creates declared secrets)", path)
}
if err != nil {
return fmt.Errorf("check %s: %w", path, err)
}
if _, err := client.PutParameter(ctx, &ssm.PutParameterInput{
Name: aws.String(path),
Value: aws.String(value),
Type: ssmtypes.ParameterTypeSecureString,
Overwrite: aws.Bool(true),
}); err != nil {
return fmt.Errorf("set %s: %w", path, err)
}
return nil
}

// readSecretValue reads one value without echoing it on a terminal.
func readSecretValue(in io.Reader, fromStdin bool, name string) (string, error) {
var value string
if fromStdin {
data, err := io.ReadAll(in)
if err != nil {
return "", err
}
value = strings.TrimRight(string(data), "\r\n")
} else if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
fmt.Printf("Value for %s (input hidden): ", name)
data, err := readPasswordRestoringOnInterrupt(int(f.Fd()))
fmt.Println()
if err != nil {
return "", err
}
value = string(data)
} else {
line, err := bufio.NewReader(in).ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
value = strings.TrimRight(line, "\r\n")
}
if value == "" || value == placeholder {
return "", fmt.Errorf("refusing to set an empty or %s value", placeholder)
}
return value, nil
}

// readPasswordRestoringOnInterrupt reads hidden input and, if the user hits
// Ctrl-C mid-prompt, restores the terminal (echo on) before exiting non-zero.
func readPasswordRestoringOnInterrupt(fd int) ([]byte, error) {
state, err := term.GetState(fd)
if err != nil {
return nil, err
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt)
done := make(chan struct{})
defer func() {
signal.Stop(sigs)
close(done)
}()
go func() {
select {
case <-sigs:
_ = term.Restore(fd, state)
fmt.Println()
os.Exit(130)
case <-done:
}
}()
return term.ReadPassword(fd)
}

func init() {
secretCmd.PersistentFlags().StringVar(&secretAppYaml, "app-yaml", "app.yaml", "path to app.yaml")
secretSetCmd.Flags().BoolVar(&secretStdin, "stdin", false, "read the value from stdin (e.g. from a password manager)")
secretCmd.AddCommand(secretListCmd, secretSetCmd)
rootCmd.AddCommand(secretCmd)
}
98 changes: 98 additions & 0 deletions cmd/secret_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cmd

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ssm"
ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
)

func writeAppYaml(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "app.yaml")
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}

func TestSecretPathMatchesServiceSecretModule(t *testing.T) {
app, err := loadAppYaml(writeAppYaml(t, "name: cupcake-backend\nteam: cupcake\nresources:\n secrets:\n - name: sp_user\n env: SP_USER\n"))
if err != nil {
t.Fatal(err)
}
if got := secretPath(app, "sp_user"); got != "/javabin/apps/cupcake/cupcake-backend/sp_user" {
t.Errorf("secretPath = %s", got)
}
if !app.declares("sp_user") || app.declares("sp_password") {
t.Error("declares() should only accept secrets listed in app.yaml")
}
}

func TestLoadAppYamlRequiresNameAndTeam(t *testing.T) {
if _, err := loadAppYaml(writeAppYaml(t, "name: x\n")); err == nil {
t.Error("expected an error without team")
}
}

func TestReadSecretValueFromStdinTrimsNewline(t *testing.T) {
v, err := readSecretValue(strings.NewReader("s3cret\n"), true, "sp_user")
if err != nil || v != "s3cret" {
t.Errorf("got %q, %v", v, err)
}
}

func TestReadSecretValueRejectsEmptyAndPlaceholder(t *testing.T) {
for _, in := range []string{"\n", "PLACEHOLDER\n"} {
if _, err := readSecretValue(strings.NewReader(in), true, "sp_user"); err == nil {
t.Errorf("expected rejection for %q", in)
}
}
}

type fakeSSM struct {
exists bool
puts []*ssm.PutParameterInput
}

func (f *fakeSSM) GetParameter(_ context.Context, in *ssm.GetParameterInput, _ ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) {
if !f.exists {
return nil, &ssmtypes.ParameterNotFound{}
}
return &ssm.GetParameterOutput{Parameter: &ssmtypes.Parameter{Name: in.Name, Value: aws.String(placeholder)}}, nil
}

func (f *fakeSSM) PutParameter(_ context.Context, in *ssm.PutParameterInput, _ ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) {
f.puts = append(f.puts, in)
return &ssm.PutParameterOutput{}, nil
}

func TestSetSecretRefusesToCreateMissingParameter(t *testing.T) {
f := &fakeSSM{exists: false}
err := setSecret(context.Background(), f, "/javabin/apps/t/a/x", "v")
if err == nil || !strings.Contains(err.Error(), "does not exist yet") {
t.Fatalf("expected not-exist error, got %v", err)
}
if len(f.puts) != 0 {
t.Error("PutParameter must not be called for a missing parameter")
}
}

func TestSetSecretOverwritesExistingParameter(t *testing.T) {
f := &fakeSSM{exists: true}
if err := setSecret(context.Background(), f, "/javabin/apps/t/a/x", "v"); err != nil {
t.Fatal(err)
}
if len(f.puts) != 1 {
t.Fatalf("expected 1 put, got %d", len(f.puts))
}
p := f.puts[0]
if aws.ToString(p.Value) != "v" || !aws.ToBool(p.Overwrite) || p.Type != ssmtypes.ParameterTypeSecureString {
t.Errorf("unexpected put input: %+v", p)
}
}
Loading