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
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ cmd/
status.go Project status (costs, ECS services)
whoami.go Show AWS + GitHub identity
secret.go List/set app.yaml secrets in SSM
aws.go aws setup / aws doctor (SSO config for javaBin)
internal/
aws/aws.go AWS SDK helpers (STS, Cost Explorer, ECS)
aws/profile.go --profile and team-profile auto-discovery
awsconfig/ Edits ~/.aws/config preserving unrelated content; SSO token cache + ListAccountRoles
config/config.go Config directory (~/.javabin/)
github/github.go GitHub API client (token, branches, PRs)
```
Expand All @@ -28,7 +31,7 @@ internal/
## Authentication

- **GitHub**: Uses `gh auth token` (gh CLI) first, then falls back to `GITHUB_TOKEN` / `GH_TOKEN` environment variables
- **AWS**: Standard credential chain via `aws-sdk-go-v2/config` (env vars, `~/.aws/credentials`, SSO). Region defaults to `eu-central-1`.
- **AWS**: Standard credential chain via `aws-sdk-go-v2/config` (env vars, `~/.aws/credentials`, SSO). Region defaults to `eu-central-1`. Team commands resolve `--profile`, then `AWS_PROFILE`, then the single profile with `sso_role_name = javabin-team-<team>` (written by `javabin aws setup`); without one the SDK would hang on EC2 IMDS, so `credentialsHelp` points at `javabin aws setup`.
- **Cognito**: TODO — will add device flow auth against internal Cognito pool when Identity (Phase 1) is implemented. Token will be cached at `~/.javabin/token`.

## Commands
Expand All @@ -39,6 +42,8 @@ internal/
| `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 aws setup [--dry-run] [--no-login] [--force]` | Adds `[sso-session javabin]` (Identity Center start URL, `eu-central-1`) to `AWS_CONFIG_FILE`/`~/.aws/config`, runs `aws sso login --sso-session javabin`, lists the user's roles in account 553637109631 (SSO ListAccountRoles with the cached token) and writes one `[profile <role>]` per role (`javabin-team-<team>`, `javabin-developer`, ...; non-`javabin-` roles get a `javabin-` prefix). Never edits/deletes existing profiles (conflicts are reported), skips roles another `sso_session = javabin` profile already uses (profiles on other sessions are kept but warned about, as is a role with several profiles, which turns off team-profile auto-discovery), backs up to `<target>.bak-<ts>` (symlinks are followed and kept), writes atomically with 0600. A session pointing at another start URL stops unless `--force` |
| `javabin aws doctor [--app-yaml]` | Checks the session block, SSO token expiry, and which profile `secret` would pick for `app.yaml`'s team |
| `javabin whoami` | Shows AWS identity (STS GetCallerIdentity) and GitHub user (gh API) |

## Build and Test
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ javabin status # infers project from git remote
javabin status --project moresleep
```

### `javabin aws setup`

One-time AWS setup — no hand-editing of `~/.aws/config`. Requires the [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) (`brew install awscli`).

```bash
javabin aws setup # add SSO session, sign in, write a profile per role you have
javabin aws setup --dry-run # show what would change
javabin aws setup --no-login # reuse a valid SSO session
javabin aws doctor # check config, token and the profile `secret` would use
```

It adds an `[sso-session javabin]` block for javaBin's IAM Identity Center, runs `aws sso login --sso-session javabin`, then writes one profile per role you are assigned in account `553637109631`. Profiles are named after the role: `javabin-team-<team>`, `javabin-developer`, `javabin-admin`, `javabin-readonly`. Existing profiles are never changed; the config is backed up to `config.bak-<timestamp>` first. `javabin secret ...` picks your team's profile automatically; elsewhere use `--profile javabin-team-<team>` or `AWS_PROFILE`. Sessions expire after ~8h: `aws sso login --sso-session javabin`.

### `javabin whoami`

Show current identity (AWS + GitHub).
Expand All @@ -53,7 +66,7 @@ javabin whoami
## Authentication

- **GitHub:** Uses `gh auth token` if available, or `GITHUB_TOKEN`/`GH_TOKEN` environment variables
- **AWS:** Standard credential chain (environment variables, `~/.aws/credentials`, SSO)
- **AWS:** Standard credential chain (environment variables, `~/.aws/credentials`, SSO). Run `javabin aws setup` once to get SSO profiles.

## What This CLI Does NOT Do

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

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strings"
"time"

"github.com/spf13/cobra"

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

var (
awsSetupForce bool
awsSetupNoLogin bool
awsSetupDryRun bool
awsDoctorYaml string
)

// awsDeps is everything `aws setup` touches outside the config file, so
// tests can swap in fakes.
type awsDeps struct {
configPath string
cacheDir string
lookAws func() error // checks the AWS CLI is installed
login func() error
roles awsconfig.RoleLister
now func() time.Time
out io.Writer
}

func defaultAwsDeps() awsDeps {
return awsDeps{
configPath: awsconfig.DefaultPath(),
cacheDir: awsconfig.DefaultCacheDir(),
lookAws: func() error { _, err := findAws(); return err },
login: ssoLogin,
roles: awsconfig.SSORoleLister{Region: awsconfig.SSORegion},
now: time.Now,
out: os.Stdout,
}
}

var awsCmd = &cobra.Command{
Use: "aws",
Short: "Set up and check AWS SSO access to the javaBin account",
// errors here are actionable messages, not usage mistakes
PersistentPreRun: func(cmd *cobra.Command, args []string) { cmd.SilenceUsage = true },
}

var awsSetupCmd = &cobra.Command{
Use: "setup",
Short: "Write the javaBin SSO session and one profile per role you have to ~/.aws/config",
Long: `Adds an [sso-session javabin] block for javaBin's IAM Identity Center to the
AWS config file (AWS_CONFIG_FILE or ~/.aws/config), signs in with
'aws sso login --sso-session javabin', lists the roles you are assigned in
account ` + awsconfig.AccountID + ` and writes one profile per role.

Profiles are named after the role: javabin-team-<team>, javabin-developer,
javabin-admin, ... (a role without the javabin- prefix gets one). Existing
profiles are never changed or deleted; the file is backed up to
<config>.bak-<timestamp> before writing.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAwsSetup(cmd.Context(), defaultAwsDeps(), awsSetupForce, awsSetupNoLogin, awsSetupDryRun)
},
}

func runAwsSetup(ctx context.Context, d awsDeps, force, noLogin, dryRun bool) error {
if ctx == nil {
ctx = context.Background()
}
orig, err := readConfig(d.configPath)
if err != nil {
return err
}

needLogin := !noLogin && !dryRun
if needLogin { // fail before touching the config
if err := d.lookAws(); err != nil {
return err
}
}

// 1. The sso-session block must be on disk before aws sso login can use it.
withSession, r, err := awsconfig.Apply(orig, nil, force)
if err != nil {
return err
}
backedUp := false
if r.SessionChanged {
if dryRun {
fmt.Fprintf(d.out, "Would add/update [%s] in %s\n", awsconfig.SessionSection, d.configPath)
} else {
bak, err := awsconfig.WriteFile(d.configPath, withSession, true, d.now())
if err != nil {
return err
}
backedUp = true
fmt.Fprintf(d.out, "Wrote [%s] to %s\n", awsconfig.SessionSection, d.configPath)
if bak != "" {
fmt.Fprintf(d.out, "Backup: %s\n", bak)
}
}
}

// 2. Sign in (writes only the AWS CLI's token cache).
if needLogin {
if err := d.login(); err != nil {
return err
}
}

// 3. Roles this user actually has.
tok, err := awsconfig.ReadToken(d.cacheDir, awsconfig.SessionName, d.now())
if err != nil {
if dryRun && errors.Is(err, awsconfig.ErrNoToken) {
fmt.Fprintf(d.out, "Not signed in (%v); profiles are listed after 'aws sso login --sso-session %s'.\n", err, awsconfig.SessionName)
if r.SessionChanged {
printDiff(d.out, orig, withSession)
}
return nil
}
return fmt.Errorf("%w — run: aws sso login --sso-session %s", err, awsconfig.SessionName)
}
roles, err := d.roles.ListRoles(ctx, tok.AccessToken, awsconfig.AccountID)
if err != nil {
return fmt.Errorf("list your roles in account %s: %w", awsconfig.AccountID, err)
}
if len(roles) == 0 {
return fmt.Errorf("you have no roles in account %s — ask a javaBin admin to add you to your team", awsconfig.AccountID)
}

// 4. Profiles.
final, pr, err := awsconfig.Apply(withSession, roles, force)
if err != nil {
return err
}
if dryRun {
if final == orig {
fmt.Fprintln(d.out, "No changes.")
} else {
printDiff(d.out, orig, final)
}
} else if final != withSession {
bak, err := awsconfig.WriteFile(d.configPath, final, !backedUp, d.now())
if err != nil {
return err
}
if bak != "" {
fmt.Fprintf(d.out, "Backup: %s\n", bak)
}
}
printSetupSummary(d.out, roles, pr, dryRun)
return nil
}

func readConfig(path string) (string, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "", nil
}
return string(data), err
}

// printDiff shows the lines added (new content is always appended or
// inserted, never removed).
func printDiff(w io.Writer, before, after string) {
old := map[string]int{}
for _, l := range strings.Split(before, "\n") {
old[l]++
}
fmt.Fprintln(w, "Changes:")
for _, l := range strings.Split(strings.TrimRight(after, "\n"), "\n") {
if old[l] > 0 {
old[l]--
continue
}
fmt.Fprintln(w, "+ "+l)
}
}

func printSetupSummary(w io.Writer, roles []string, r *awsconfig.Result, dryRun bool) {
verb := "Added"
if dryRun {
verb = "Would add"
}
if len(r.Added) > 0 {
fmt.Fprintf(w, "%s profiles: %s\n", verb, strings.Join(r.Added, ", "))
}
if len(r.Unchanged) > 0 {
fmt.Fprintf(w, "Already up to date: %s\n", strings.Join(r.Unchanged, ", "))
}
for _, role := range r.Conflicts {
fmt.Fprintf(w, "Left alone: [profile %s] exists with different settings — fix or remove it yourself if it should point at role %s\n", awsconfig.ProfileName(role), role)
}
sort.Strings(roles)
for _, role := range roles {
for _, ref := range r.OtherSession[role] {
how := "sso_session = " + ref.Session
if ref.Session == "" {
how = "no sso_session (legacy sso_start_url)"
}
fmt.Fprintf(w, "Warning: existing [profile %s] also uses role %s but with %s; `aws sso login --sso-session %s` does not refresh it — consider removing it\n",
ref.Profile, role, how, awsconfig.SessionName)
}
if refs, ok := r.Ambiguous[role]; ok {
use := awsconfig.ProfileName(role)
if c, ok := r.Covered[role]; ok {
use = c
}
if team, isTeam := strings.CutPrefix(role, "javabin-team-"); isTeam {
fmt.Fprintf(w, "Warning: %d profiles use role %s (%s), so `javabin secret` cannot pick one for team %s — pass --profile %s (or set AWS_PROFILE), or remove the others\n",
len(refs), role, strings.Join(refs, ", "), team, use)
}
}
}
var teams []string
for _, role := range roles {
profile := awsconfig.ProfileName(role)
if other, ok := r.Covered[role]; ok {
profile = other
fmt.Fprintf(w, "Role %s already has profile %s; not adding another\n", role, other)
}
if team, ok := strings.CutPrefix(role, "javabin-team-"); ok {
teams = append(teams, fmt.Sprintf(" team %-20s --profile %s", team, profile))
}
}
if len(teams) > 0 {
fmt.Fprintln(w, "\nProfiles per team:")
for _, t := range teams {
fmt.Fprintln(w, t)
}
if len(r.Ambiguous) == 0 {
fmt.Fprintln(w, "\n`javabin secret ...` picks the team profile from app.yaml automatically.")
}
}
fmt.Fprintf(w, "Sessions last about 8 hours; renew with: aws sso login --sso-session %s\n", awsconfig.SessionName)
}

func findAws() (string, error) {
bin, err := exec.LookPath("aws")
if err != nil {
return "", errors.New("the AWS CLI (v2) is not installed. Install it, then rerun javabin aws setup:\n" +
" macOS: brew install awscli\n" +
" other: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html")
}
return bin, nil
}

func ssoLogin() error {
bin, err := findAws()
if err != nil {
return err
}
c := exec.Command(bin, "sso", "login", "--sso-session", awsconfig.SessionName)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := c.Run(); err != nil {
return fmt.Errorf("aws sso login: %w", err)
}
return nil
}

var awsDoctorCmd = &cobra.Command{
Use: "doctor",
Short: "Check the javaBin SSO config, token and which profile `secret` would use",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAwsDoctor(defaultAwsDeps(), awsDoctorYaml)
},
}

func runAwsDoctor(d awsDeps, appYaml string) error {
ok := true
report := func(good bool, format string, a ...any) {
mark := "ok "
if !good {
mark, ok = "FAIL", false
}
fmt.Fprintf(d.out, "[%s] %s\n", mark, fmt.Sprintf(format, a...))
}
data, err := readConfig(d.configPath)
if err != nil {
return err
}
f := awsconfig.Parse(data)
sess := f.Values(awsconfig.SessionSection)
report(sess != nil && awsconfig.SameStartURL(sess["sso_start_url"]), "%s has [%s] for %s", d.configPath, awsconfig.SessionSection, awsconfig.StartURL)
if tok, err := awsconfig.ReadToken(d.cacheDir, awsconfig.SessionName, d.now()); err != nil {
report(false, "SSO token: %v (run: aws sso login --sso-session %s)", err, awsconfig.SessionName)
} else {
report(true, "SSO token valid until %s", tok.ExpiresAt.Local().Format("15:04 Mon 2 Jan"))
}
if app, err := loadAppYaml(appYaml); err == nil {
p := jbaws.FindTeamProfile(d.configPath, app.Team)
report(p != "", "%s (team %s): javabin secret would use profile %q", appYaml, app.Team, p)
} else {
fmt.Fprintf(d.out, "[skip] no %s here — team profile not checked\n", appYaml)
}
if !ok {
return errors.New("problems found — run: javabin aws setup")
}
return nil
}

func init() {
awsSetupCmd.Flags().BoolVar(&awsSetupForce, "force", false, "replace the settings of an existing [sso-session javabin] that points elsewhere")
awsSetupCmd.Flags().BoolVar(&awsSetupNoLogin, "no-login", false, "skip aws sso login (use the cached token)")
awsSetupCmd.Flags().BoolVar(&awsSetupDryRun, "dry-run", false, "print the changes without writing or signing in")
awsDoctorCmd.Flags().StringVar(&awsDoctorYaml, "app-yaml", "app.yaml", "path to app.yaml")
awsCmd.AddCommand(awsSetupCmd, awsDoctorCmd)
rootCmd.AddCommand(awsCmd)
}
Loading
Loading