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
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ main.go Entrypoint — calls cmd.Execute()
cmd/
root.go Cobra root command, registers subcommands
init.go Interactive app scaffolding wizard
scaffold.go Per-runtime minimal app + Dockerfile written by init
register.go Interactive team registration wizard
status.go Project status (costs, ECS services)
whoami.go Show AWS + GitHub identity
Expand Down Expand Up @@ -38,10 +39,10 @@ internal/

| Command | What it does |
|---------|-------------|
| `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` (with `routing:`), a runtime `Dockerfile` (Maven for java, Gradle for kotlin) and `deploy.yml` (with the permissions the platform pipeline needs), then prints next steps: the command to add the repo to the app.yaml team, `javabin aws setup`, and `javabin secret set` after the first deploy |
| `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 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`, serialised with yaml.v3; first member gets `role: maintainer`) |
| `javabin init` | Interactive wizard — scaffolds a new app repo from `javaBin/app-template`, writes `app.yaml` (with `routing.host`, no `priority`: the platform derives it), `deploy.yml` (with the permissions the platform pipeline needs) and a minimal runtime app from `scaffold.go` (java/Maven image, kotlin/Gradle image, typescript, python, go; no wrapper scripts or lockfiles needed) serving `GET /health` on the chosen port, running as uid 1000 with only `/tmp` written. Commits, adds the repo to the app.yaml GitHub team (`PUT orgs/javaBin/teams/<team>/repos/javaBin/<repo>`), then pushes; if the team add is refused (not a team maintainer/org owner, or no team) it stops before pushing and prints the command for a maintainer plus `git push`. `JAVABIN_SCAFFOLD_DOCKER=1 go test ./cmd -run TestScaffoldImagesServeHealth` builds and runs every scaffold image |
| `javabin status [--team] [--service] [-r]` | Shows month-to-date cost (Cost Explorer, USD with `~NOK` at the platform's 10.5 rate) and ECS service status. Team/service from flags or `./app.yaml`; AWS profile resolved like `secret` (`LoadConfigForTeam`) |
| `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-team-<team>` SSO role (`javabin-developer` has no team tag, so it can't reach team parameters). Missing `name:` defaults to the repo name like the pipeline; invalid repo names (`2027.javazone.no`) fail with a suggested `name:` |
| `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) |
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ go install github.com/javaBin/javabin-cli@latest # Go toolchain

### `javabin register-team`

Interactive wizard to register a new team with the platform. Prompts for team name, description, members (Google handle + GitHub username), and optional budget. Creates a PR against [javaBin/registry](https://github.com/javaBin/registry) with `teams/{name}.yaml`.
Interactive wizard to register a new team with the platform. Prompts for team name, description, members (Google handle + GitHub username), and optional budget. Creates a PR against [javaBin/registry](https://github.com/javaBin/registry) with `teams/{name}.yaml`. The first member is written as `role: maintainer`, so they can add repos to the GitHub team.

After your team is created, add repos to your GitHub team:
After your team is created, a maintainer adds existing repos to the GitHub team (`javabin init` does this for new repos):

```bash
gh api orgs/javaBin/teams/TEAM/repos -f owner=javaBin -f repo=REPO -f permission=push
gh api -X PUT orgs/javaBin/teams/TEAM/repos/javaBin/REPO -f permission=push
```

```bash
Expand All @@ -27,21 +27,25 @@ javabin register-team

### `javabin init`

Interactive wizard to scaffold a new app repo from the Javabin app template. Creates the repo under `javaBin/`, writes `app.yaml`, `Dockerfile`, and a deploy workflow, then prints next steps: adding the repo to your GitHub team, `javabin aws setup` for AWS access, and `javabin secret set` for secrets after the first deploy.
Interactive wizard to scaffold a new app repo from the Javabin app template. Creates the repo under `javaBin/`, writes `app.yaml` (routing without a fixed `priority`; the platform derives one), a deploy workflow and a minimal app for the runtime (java/Maven, kotlin/Gradle, typescript, python, go) that builds from a clean checkout and answers `GET /health` on the chosen port, with a `Dockerfile` that runs it as uid 1000 writing only to `/tmp` (as the platform runs it). It then adds the repo to the app's GitHub team and only then pushes to `main`, so the first pipeline run is accepted. If you can't add repos to the team (you need to be a team maintainer or org owner), the scaffold stays committed locally and the CLI prints the command for a maintainer and the `git push` to run afterwards.

```bash
javabin init
```

### `javabin status`

Show project status: costs, ECS services, deployments.
Show month-to-date team cost and ECS service status. Costs are AWS USD amounts shown with their approximate NOK value (team budgets are in NOK). Uses the same AWS profile as `secret`: `--profile`, `AWS_PROFILE`, or your `javabin-team-<team>` profile from `javabin aws setup`.

```bash
javabin status # infers project from git remote
javabin status --project moresleep
javabin status # team and service from ./app.yaml
javabin status --team cupcake --service cupcake-web
```

### `javabin secret`

Run in an app repo. `list` shows which `resources.secrets` still hold `PLACEHOLDER`; `set <name>` writes a value. Needs your team role `javabin-team-<team>` (`javabin-developer` cannot reach team secrets). If `app.yaml` has no `name`, the repo name is used, as in the platform pipeline; repo names that aren't valid app names (e.g. `2027.javazone.no`) need an explicit `name:` and the CLI suggests one (`javazone-no-2027`).

### `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`).
Expand Down
192 changes: 66 additions & 126 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
Expand All @@ -24,89 +25,6 @@ var initCmd = &cobra.Command{
RunE: runInit,
}

type runtimeConfig struct {
defaultPort string
dockerfile string
}

var runtimes = map[string]runtimeConfig{
"java": {
defaultPort: "8080",
dockerfile: `FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN apk add --no-cache maven && mvn package -DskipTests

FROM eclipse-temurin:21-jre-alpine
COPY --from=build /app/target/*.jar /app/app.jar
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]
`,
},
"kotlin": {
defaultPort: "8080",
dockerfile: `FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
# installDist names the distribution after the Gradle project; normalise it
# to /out with a stable bin/start entrypoint.
RUN ./gradlew --no-daemon installDist -x test \
&& mkdir /out && cp -r build/install/*/. /out/ \
&& ln -s "$(ls /out/bin | grep -v '\.bat$' | head -1)" /out/bin/start

FROM eclipse-temurin:21-jre-alpine
COPY --from=build /out /opt/app
EXPOSE 8080
CMD ["/opt/app/bin/start"]
`,
},
"typescript": {
defaultPort: "3000",
dockerfile: `FROM node:22-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]
`,
},
"python": {
defaultPort: "8000",
dockerfile: `FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
`,
},
"go": {
defaultPort: "8080",
dockerfile: `FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM alpine:3.19
COPY --from=build /app/server /server
EXPOSE 8080
CMD ["/server"]
`,
},
}

func runInit(cmd *cobra.Command, args []string) error {
reader := bufio.NewReader(os.Stdin)
prompt := func(label, defaultVal string) string {
Expand Down Expand Up @@ -171,11 +89,8 @@ func runInit(cmd *cobra.Command, args []string) error {
port := prompt("Port", rc.defaultPort)

// Routing — required for a web service to get a URL and ALB rule
// (routing.priority is left out: the platform derives one.)
host := prompt("Hostname", name+".javazone.no")
priority := prompt("ALB rule priority (unique across apps, 1-50000)", "")
if !regexp.MustCompile(`^[1-9][0-9]{0,4}$`).MatchString(priority) {
return fmt.Errorf("priority must be a number between 1 and 50000")
}

// Visibility
visibility := prompt("Public repo? (y/n)", "n")
Expand All @@ -187,7 +102,7 @@ func runInit(cmd *cobra.Command, args []string) error {
fmt.Printf(" Team: %s\n", team)
fmt.Printf(" Runtime: %s\n", runtime)
fmt.Printf(" Port: %s\n", port)
fmt.Printf(" Host: %s (priority %s)\n", host, priority)
fmt.Printf(" Host: %s\n", host)
if private {
fmt.Println(" Repo: private")
} else {
Expand Down Expand Up @@ -223,58 +138,83 @@ func runInit(cmd *cobra.Command, args []string) error {

repoDir := filepath.Join(".", name)

// Write app.yaml
if err := os.WriteFile(filepath.Join(repoDir, "app.yaml"), []byte(renderAppYaml(name, team, port, host, priority)), 0644); err != nil {
return fmt.Errorf("write app.yaml: %w", err)
// Write app.yaml, the runtime scaffold and the deploy workflow
files := rc.render(name, port)
files["app.yaml"] = renderAppYaml(name, team, port, host)
files[".github/workflows/deploy.yml"] = deployYaml
written, err := writeFiles(repoDir, files)
if err != nil {
return fmt.Errorf("write scaffold: %w", err)
}
fmt.Println(" wrote app.yaml")

// Write Dockerfile
if err := os.WriteFile(filepath.Join(repoDir, "Dockerfile"), []byte(rc.dockerfile), 0644); err != nil {
return fmt.Errorf("write Dockerfile: %w", err)
for _, f := range written {
fmt.Printf(" wrote %s\n", f)
}
fmt.Println(" wrote Dockerfile")

// Write deploy workflow
workflowDir := filepath.Join(repoDir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
return fmt.Errorf("create workflow dir: %w", err)
fmt.Print("Committing... ")
if err := runGit(repoDir, append([]string{"add", "--"}, written...)...); err != nil {
fmt.Println("failed")
return err
}
if err := os.WriteFile(filepath.Join(workflowDir, "deploy.yml"), []byte(deployYaml), 0644); err != nil {
return fmt.Errorf("write deploy.yml: %w", err)
if err := runGit(repoDir, "commit", "-m", fmt.Sprintf("Configure %s for Javabin platform", name)); err != nil {
fmt.Println("failed")
return err
}
fmt.Println(" wrote .github/workflows/deploy.yml")

// Commit and push
fmt.Print("Committing and pushing... ")
gitCmds := [][]string{
{"add", "app.yaml", "Dockerfile", ".github/workflows/deploy.yml"},
{"commit", "-m", fmt.Sprintf("Configure %s for Javabin platform", name)},
{"push"},
fmt.Println("done")

// The platform only deploys repos that belong to the app.yaml team, so
// the repo must be in the GitHub team before the first push to main
// triggers the pipeline.
fmt.Printf("Adding javaBin/%s to GitHub team %s... ", name, team)
if err := gh.AddRepoToTeam(token, team, name); err != nil {
fmt.Println("failed")
return teamAccessError(err, team, name)
}
for _, gitArgs := range gitCmds {
c := exec.Command("git", gitArgs...)
c.Dir = repoDir
if out, err := c.CombinedOutput(); err != nil {
fmt.Println("failed")
return fmt.Errorf("git %s: %w\n%s", gitArgs[0], err, string(out))
}
fmt.Println("done")

fmt.Print("Pushing to main... ")
if err := runGit(repoDir, "push"); err != nil {
fmt.Println("failed")
return err
}
fmt.Println("done")

fmt.Printf("\nRepo ready: https://github.com/javaBin/%s\n", name)
fmt.Println("The push to main started the platform pipeline (Actions tab).")
fmt.Println("\nNext steps:")
fmt.Println(" 1. Add this repo to your GitHub team (the platform deploys as the app.yaml team):")
fmt.Printf(" gh api -X PUT orgs/javaBin/teams/%s/repos/javaBin/%s -f permission=push\n", team, name)
fmt.Println(" 2. Push to main — the platform CI takes over")
fmt.Println(" 3. For AWS access (logs, status, cost), set up your team's SSO profile:")
fmt.Println(" 1. For AWS access (logs, status, cost), set up your team's SSO profile:")
fmt.Println(" javabin aws setup")
fmt.Println(" 4. Secrets: list them under resources.secrets in app.yaml, deploy, then set each value:")
fmt.Println(" 2. Secrets: list them under resources.secrets in app.yaml, deploy, then set each value:")
fmt.Println(" javabin secret set <name> (from the repo dir)")

return nil
}

func runGit(dir string, args ...string) error {
c := exec.Command("git", args...)
c.Dir = dir
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("git %s: %w\n%s", args[0], err, string(out))
}
return nil
}

// teamAccessError explains why the repo could not be added to the team. The
// scaffold is committed locally but not pushed, so the first pipeline run is
// not rejected for a repo outside the team.
func teamAccessError(err error, team, name string) error {
addCmd := fmt.Sprintf("gh api -X PUT orgs/javaBin/teams/%s/repos/javaBin/%s -f permission=push", team, name)
var why string
if errors.Is(err, gh.ErrNoTeamAccess) {
why = fmt.Sprintf("you are not allowed to add repos to team %s (only its maintainers, role: maintainer in\n"+
"javaBin/registry teams/%s.yaml, and org owners can), or the team does not exist yet", team, team)
} else {
why = err.Error()
}
return fmt.Errorf("could not add javaBin/%s to GitHub team %s: %s\n\n"+
"The scaffold is committed in ./%s but NOT pushed: the platform rejects deploys from repos outside the app.yaml team.\n"+
"Ask a team maintainer or org owner to run:\n %s\nthen push:\n cd %s && git push", name, team, why, name, addCmd, name)
}

// deployYaml is the app's CI entrypoint. The permissions are required:
// id-token for AWS OIDC, contents: write so the platform's expand step can
// commit generated Terraform, pull-requests for plan comments.
Expand All @@ -296,7 +236,8 @@ jobs:
`

// renderAppYaml writes the minimal app.yaml for a routed web service.
func renderAppYaml(name, team, port, host, priority string) string {
// routing.priority is omitted; the platform derives a unique one.
func renderAppYaml(name, team, port, host string) string {
return fmt.Sprintf(`name: %s
team: %s

Expand All @@ -306,8 +247,7 @@ compute:

routing:
host: %s
priority: %s
`, name, team, port, host, priority)
`, name, team, port, host)
}

func listTeams(token string) ([]string, error) {
Expand Down
11 changes: 7 additions & 4 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ import (
)

func TestRenderAppYamlIncludesRouting(t *testing.T) {
got := renderAppYaml("moresleep", "core", "8080", "moresleep.javazone.no", "120")
got := renderAppYaml("moresleep", "core", "8080", "moresleep.javazone.no")
for _, want := range []string{
"name: moresleep\n",
"team: core\n",
" port: 8080\n",
"routing:\n host: moresleep.javazone.no\n priority: 120\n",
"routing:\n host: moresleep.javazone.no\n",
} {
if !strings.Contains(got, want) {
t.Errorf("app.yaml missing %q:\n%s", want, got)
}
}
// The platform derives a unique priority when it is absent.
if strings.Contains(got, "priority") {
t.Errorf("app.yaml must not set routing.priority:\n%s", got)
}
}

func TestDeployYamlGrantsPipelinePermissions(t *testing.T) {
Expand All @@ -39,7 +43,7 @@ func TestDeployYamlGrantsPipelinePermissions(t *testing.T) {
// TestRenderAppYamlGolden pins the full output; it is the file validated
// against platform/scripts/generate-modules.py.
func TestRenderAppYamlGolden(t *testing.T) {
got := renderAppYaml("initcheck", "platform", "8080", "initcheck.javazone.no", "4242")
got := renderAppYaml("initcheck", "platform", "8080", "initcheck.javazone.no")
if dir := os.Getenv("INIT_APP_YAML_OUT"); dir != "" {
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(got), 0o644); err != nil {
t.Fatal(err)
Expand All @@ -54,7 +58,6 @@ compute:

routing:
host: initcheck.javazone.no
priority: 4242
`
if got != want {
t.Errorf("app.yaml =\n%s\nwant\n%s", got, want)
Expand Down
Loading
Loading