From 5c021fcaa98e3e022f01d49c67fd0c045e1de57b Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Fri, 23 Oct 2026 10:52:47 +0200 Subject: [PATCH 1/2] fix(init): scaffold apps that the platform pipeline can deploy - deploy.yml granted contents: read, but the platform's expand step commits generated Terraform; grant contents: write at workflow level and drop secrets: inherit (no secrets are needed). - app.yaml had no routing:, so the service got no URL or ALB rule. Prompt for host and priority. - kotlin emitted a Maven Dockerfile; use Gradle installDist. - The printed add-repo-to-team command used the wrong API shape. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- cmd/init.go | 78 +++++++++++++++++++++++++++++++++--------------- cmd/init_test.go | 32 ++++++++++++++++++++ 3 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 cmd/init_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 726c938..2ec303e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,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 the command to add the repo to the app.yaml team | | `javabin status` | Shows month-to-date cost (Cost Explorer) and ECS service status. Infers project from git remote or accepts `--project` flag | | `javabin whoami` | Shows AWS identity (STS GetCallerIdentity) and GitHub user (gh API) | diff --git a/cmd/init.go b/cmd/init.go index 46ee041..b8ceda9 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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": { @@ -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" @@ -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 { @@ -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") @@ -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) } @@ -269,13 +264,48 @@ 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") 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) diff --git a/cmd/init_test.go b/cmd/init_test.go new file mode 100644 index 0000000..9c11f3c --- /dev/null +++ b/cmd/init_test.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "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{"id-token: write", "contents: write", "pull-requests: write"} { + if !strings.Contains(deployYaml, want) { + t.Errorf("deploy.yml missing %q", want) + } + } + if strings.Contains(deployYaml, "contents: read") { + t.Error("deploy.yml must not restrict contents to read") + } +} From 8c1312447b2d5921656e62b94f90e52b9de42bf3 Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Sat, 24 Oct 2026 12:48:26 +0200 Subject: [PATCH 2/2] fix(init): point next steps at aws setup and secret set Merge main into the init scaffolding branch and extend init's next steps with `javabin aws setup` and `javabin secret set` after the first deploy. Pin the rendered app.yaml with a golden test (validated against the platform generator) and assert deploy.yml has workflow_dispatch and no secrets: inherit. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- README.md | 2 +- cmd/init.go | 4 ++++ cmd/init_test.go | 32 +++++++++++++++++++++++++++++++- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c1a9a3..429e77e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` (with `routing:`), a runtime `Dockerfile` (Maven for java, Gradle for kotlin) and `deploy.yml` (with the permissions the platform pipeline needs), then prints the command to add the repo to the app.yaml 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 [--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 ]` per role (`javabin-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 `.bak-` (symlinks are followed and kept), writes atomically with 0600. A session pointing at another start URL stops unless `--force` | diff --git a/README.md b/README.md index fd31bac..1344a29 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/init.go b/cmd/init.go index b8ceda9..8a18a9d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -267,6 +267,10 @@ func runInit(cmd *cobra.Command, args []string) error { 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 (from the repo dir)") return nil } diff --git a/cmd/init_test.go b/cmd/init_test.go index 9c11f3c..5c22c22 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "path/filepath" "strings" "testing" ) @@ -21,12 +23,40 @@ func TestRenderAppYamlIncludesRouting(t *testing.T) { func TestDeployYamlGrantsPipelinePermissions(t *testing.T) { // expand commits generated Terraform, so contents must be writable - for _, want := range []string{"id-token: write", "contents: write", "pull-requests: write"} { + 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) + } +}