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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ 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`, `Dockerfile`, and deploy workflow, then prints next steps for adding the repo to a GitHub team |
| `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 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` |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ 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 for adding the repo to your GitHub team.
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.

```bash
javabin init
Expand Down
82 changes: 58 additions & 24 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@ CMD ["java", "-jar", "/app/app.jar"]
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
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 /app/target/*.jar /app/app.jar
COPY --from=build /out /opt/app
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]
CMD ["/opt/app/bin/start"]
`,
},
"typescript": {
Expand Down Expand Up @@ -167,6 +170,13 @@ func runInit(cmd *cobra.Command, args []string) error {
// Port
port := prompt("Port", rc.defaultPort)

// Routing — required for a web service to get a URL and ALB rule
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")
private := strings.ToLower(visibility) != "y"
Expand All @@ -177,6 +187,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)
if private {
fmt.Println(" Repo: private")
} else {
Expand Down Expand Up @@ -213,8 +224,7 @@ func runInit(cmd *cobra.Command, args []string) error {
repoDir := filepath.Join(".", name)

// Write app.yaml
appYaml := fmt.Sprintf("name: %s\nteam: %s\ncompute:\n port: %s\n", name, team, port)
if err := os.WriteFile(filepath.Join(repoDir, "app.yaml"), []byte(appYaml), 0644); err != nil {
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)
}
fmt.Println(" wrote app.yaml")
Expand All @@ -230,21 +240,6 @@ func runInit(cmd *cobra.Command, args []string) error {
if err := os.MkdirAll(workflowDir, 0755); err != nil {
return fmt.Errorf("create workflow dir: %w", err)
}
deployYaml := `name: Deploy
on:
push:
branches: [main]
pull_request:

jobs:
javabin:
uses: javaBin/platform/.github/workflows/javabin.yml@main
permissions:
id-token: write
contents: read
pull-requests: write
secrets: inherit
`
if err := os.WriteFile(filepath.Join(workflowDir, "deploy.yml"), []byte(deployYaml), 0644); err != nil {
return fmt.Errorf("write deploy.yml: %w", err)
}
Expand All @@ -269,13 +264,52 @@ jobs:

fmt.Printf("\nRepo ready: https://github.com/javaBin/%s\n", name)
fmt.Println("\nNext steps:")
fmt.Println(" 1. Add this repo to your GitHub team:")
fmt.Printf(" gh api orgs/javaBin/teams/%s/repos -f owner=javaBin -f repo=%s -f permission=push\n", team, name)
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(" javabin aws setup")
fmt.Println(" 4. 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
}

// 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.
const deployYaml = `name: Deploy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
id-token: write
contents: write
pull-requests: write

jobs:
javabin:
uses: javaBin/platform/.github/workflows/javabin.yml@main
`

// renderAppYaml writes the minimal app.yaml for a routed web service.
func renderAppYaml(name, team, port, host, priority string) string {
return fmt.Sprintf(`name: %s
team: %s

compute:
port: %s
health_check: /health

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

func listTeams(token string) ([]string, error) {
url := "https://api.github.com/repos/javaBin/registry/contents/teams"
req, _ := http.NewRequest("GET", url, nil)
Expand Down
62 changes: 62 additions & 0 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cmd

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

func TestRenderAppYamlIncludesRouting(t *testing.T) {
got := renderAppYaml("moresleep", "core", "8080", "moresleep.javazone.no", "120")
for _, want := range []string{
"name: moresleep\n",
"team: core\n",
" port: 8080\n",
"routing:\n host: moresleep.javazone.no\n priority: 120\n",
} {
if !strings.Contains(got, want) {
t.Errorf("app.yaml missing %q:\n%s", want, got)
}
}
}

func TestDeployYamlGrantsPipelinePermissions(t *testing.T) {
// expand commits generated Terraform, so contents must be writable
for _, want := range []string{"workflow_dispatch:", "id-token: write", "contents: write", "pull-requests: write"} {
if !strings.Contains(deployYaml, want) {
t.Errorf("deploy.yml missing %q", want)
}
}
if strings.Contains(deployYaml, "secrets: inherit") {
t.Error("deploy.yml must not use secrets: inherit")
}
if strings.Contains(deployYaml, "contents: read") {
t.Error("deploy.yml must not restrict contents to read")
}
}

// 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")
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)
}
}
want := `name: initcheck
team: platform

compute:
port: 8080
health_check: /health

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