diff --git a/CLAUDE.md b/CLAUDE.md index 429e77e..ae8945c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 [--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//repos/javaBin/`), 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 [--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-` 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 ]` 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` | | `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) | diff --git a/README.md b/README.md index 1344a29..3bc329a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: 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 @@ -35,13 +35,17 @@ 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-` 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 ` writes a value. Needs your team role `javabin-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`). diff --git a/cmd/init.go b/cmd/init.go index 8a18a9d..e5b80aa 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -3,6 +3,7 @@ package cmd import ( "bufio" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -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 { @@ -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") @@ -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 { @@ -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 (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. @@ -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 @@ -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) { diff --git a/cmd/init_test.go b/cmd/init_test.go index 5c22c22..6219b04 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -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) { @@ -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) @@ -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) diff --git a/cmd/register.go b/cmd/register.go index b33e6d0..dfd003f 100644 --- a/cmd/register.go +++ b/cmd/register.go @@ -2,13 +2,16 @@ package cmd import ( "bufio" + "bytes" "fmt" "os" + "strconv" "strings" "github.com/javaBin/javabin-cli/internal/config" gh "github.com/javaBin/javabin-cli/internal/github" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) var registerCmd = &cobra.Command{ @@ -19,8 +22,47 @@ var registerCmd = &cobra.Command{ } type member struct { - Google string - GitHub string + Google string `yaml:"google"` + GitHub string `yaml:"github"` + // Role is "maintainer" for the first member (the person registering), + // who can then add repos to the GitHub team; others default to member. + Role string `yaml:"role,omitempty"` +} + +type teamYaml struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Members []member `yaml:"members"` + BudgetNOK *int `yaml:"budget_nok,omitempty"` +} + +// renderTeamYaml serialises teams/.yaml for javaBin/registry. The first +// member is the team maintainer. budget_nok is omitted at the default (500). +func renderTeamYaml(name, description string, members []member, budget string) (string, error) { + t := teamYaml{Name: name, Description: description} + for i, m := range members { + if i == 0 { + m.Role = "maintainer" + } + t.Members = append(t.Members, m) + } + if budget != "" && budget != "500" { + n, err := strconv.Atoi(budget) + if err != nil || n <= 0 { + return "", fmt.Errorf("budget must be a positive whole number of NOK, got %q", budget) + } + t.BudgetNOK = &n + } + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(t); err != nil { + return "", err + } + if err := enc.Close(); err != nil { + return "", err + } + return buf.String(), nil } func runRegister(cmd *cobra.Command, args []string) error { @@ -80,6 +122,9 @@ func runRegister(cmd *cobra.Command, args []string) error { // Budget budget := prompt("\nMonthly budget (NOK)", "500") + if _, err := renderTeamYaml(teamName, description, members, budget); err != nil { + return err + } // Confirm fmt.Println("\n--- Team Registration Summary ---") @@ -88,8 +133,12 @@ func runRegister(cmd *cobra.Command, args []string) error { fmt.Printf(" Google Group: team-%s@java.no\n", teamName) fmt.Printf(" Budget: %s NOK/mo\n", budget) fmt.Println(" Members:") - for _, m := range members { - fmt.Printf(" - %s (github: %s)\n", m.Google, m.GitHub) + for i, m := range members { + role := "" + if i == 0 { + role = ", maintainer" + } + fmt.Printf(" - %s (github: %s%s)\n", m.Google, m.GitHub, role) } fmt.Println() @@ -99,19 +148,10 @@ func runRegister(cmd *cobra.Command, args []string) error { return nil } - // Build team YAML content - var yamlLines []string - yamlLines = append(yamlLines, fmt.Sprintf("name: %s", teamName)) - yamlLines = append(yamlLines, fmt.Sprintf("description: %s", description)) - yamlLines = append(yamlLines, "members:") - for _, m := range members { - yamlLines = append(yamlLines, fmt.Sprintf(" - google: %s", m.Google)) - yamlLines = append(yamlLines, fmt.Sprintf(" github: %s", m.GitHub)) - } - if budget != "500" && budget != "" { - yamlLines = append(yamlLines, fmt.Sprintf("budget_nok: %s", budget)) + yamlContent, err := renderTeamYaml(teamName, description, members, budget) + if err != nil { + return fmt.Errorf("render team yaml: %w", err) } - yamlContent := strings.Join(yamlLines, "\n") + "\n" // Create PR via GitHub API filePath := fmt.Sprintf("teams/%s.yaml", teamName) @@ -131,7 +171,8 @@ func runRegister(cmd *cobra.Command, args []string) error { fmt.Printf("\nRegistration PR created: %s\n", prURL) fmt.Println("A platform owner will review and merge it.") fmt.Println("\nAfter your team is created, add repos to your GitHub team:") - fmt.Printf(" gh api orgs/javaBin/teams/%s/repos -f owner=javaBin -f repo=REPO -f permission=push\n", teamName) + fmt.Printf(" gh api -X PUT orgs/javaBin/teams/%s/repos/javaBin/REPO -f permission=push\n", teamName) + fmt.Printf("(%s is the team maintainer and can do this; `javabin init` does it for new repos.)\n", members[0].GitHub) _ = config.EnsureConfigDir() return nil diff --git a/cmd/register_test.go b/cmd/register_test.go new file mode 100644 index 0000000..829cabb --- /dev/null +++ b/cmd/register_test.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestRenderTeamYamlFirstMemberIsMaintainerAndQuotesDescription(t *testing.T) { + desc := "Video: recording # and streaming" + got, err := renderTeamYaml("video", desc, []member{ + {Google: "ada.lovelace", GitHub: "ada"}, + {Google: "alan.turing", GitHub: "alan"}, + }, "750") + if err != nil { + t.Fatal(err) + } + var parsed struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Members []struct { + Google string `yaml:"google"` + GitHub string `yaml:"github"` + Role string `yaml:"role"` + } `yaml:"members"` + BudgetNOK int `yaml:"budget_nok"` + } + if err := yaml.Unmarshal([]byte(got), &parsed); err != nil { + t.Fatalf("invalid YAML: %v\n%s", err, got) + } + if parsed.Name != "video" || parsed.Description != desc || parsed.BudgetNOK != 750 { + t.Errorf("round trip lost data:\n%s", got) + } + if len(parsed.Members) != 2 || parsed.Members[0].Role != "maintainer" || parsed.Members[1].Role != "" { + t.Errorf("first member must be the only maintainer:\n%s", got) + } + if parsed.Members[1].GitHub != "alan" || parsed.Members[0].Google != "ada.lovelace" { + t.Errorf("members wrong:\n%s", got) + } +} + +func TestRenderTeamYamlDefaultBudgetOmitted(t *testing.T) { + got, err := renderTeamYaml("video", "Video", []member{{Google: "a.b", GitHub: "ab"}}, "500") + if err != nil { + t.Fatal(err) + } + want := "name: video\ndescription: Video\nmembers:\n - google: a.b\n github: ab\n role: maintainer\n" + if got != want { + t.Errorf("got\n%s\nwant\n%s", got, want) + } + if _, err := renderTeamYaml("video", "Video", []member{{Google: "a.b", GitHub: "ab"}}, "lots"); err == nil { + t.Error("expected an error for a non-numeric budget") + } +} diff --git a/cmd/scaffold.go b/cmd/scaffold.go new file mode 100644 index 0000000..8e712ed --- /dev/null +++ b/cmd/scaffold.go @@ -0,0 +1,372 @@ +package cmd + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// runtimeConfig is what `javabin init` writes for a runtime: a minimal app +// that builds from a clean checkout (no wrapper scripts or lockfiles needed), +// listens on $PORT (baked in as the chosen port) and answers GET /health with +// 200. The image runs as uid 1000 and writes nothing outside /tmp, matching +// the platform's ECS task (user "1000", read-only root filesystem, /tmp +// mounted writable). +type runtimeConfig struct { + defaultPort string + // files maps repo-relative paths to contents. {{NAME}} and {{PORT}} are + // replaced when rendering. + files map[string]string +} + +const dockerignoreCommon = `.git +.github +app.yaml +README.md +CLAUDE.md +` + +var runtimes = map[string]runtimeConfig{ + "java": { + defaultPort: "8080", + files: map[string]string{ + "Dockerfile": `FROM maven:3.9-eclipse-temurin-21-alpine AS build +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn -q -B package -DskipTests + +FROM eclipse-temurin:21-jre-alpine +RUN adduser -D -H -u 1000 app +WORKDIR /app +COPY --from=build /app/target/app.jar app.jar +# The platform runs the task as uid 1000 with a read-only root filesystem; +# only /tmp is writable. +USER 1000 +ENV PORT={{PORT}} +EXPOSE {{PORT}} +ENTRYPOINT ["java", "-jar", "/app/app.jar"] +`, + ".dockerignore": dockerignoreCommon + "target\n", + "pom.xml": ` + + 4.0.0 + + no.java + {{NAME}} + 1.0.0 + jar + + + 21 + UTF-8 + + + + app + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + no.java.app.App + + + + + + + +`, + "src/main/java/no/java/app/App.java": `package no.java.app; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +public class App { + public static void main(String[] args) throws IOException { + int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "{{PORT}}")); + HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); + server.createContext("/health", exchange -> respond(exchange, "ok")); + server.createContext("/", exchange -> respond(exchange, "Hello from {{NAME}}")); + server.start(); + System.out.println("Listening on :" + port); + } + + private static void respond(HttpExchange exchange, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } +} +`, + }, + }, + "kotlin": { + defaultPort: "8080", + files: map[string]string{ + "Dockerfile": `FROM gradle:8-jdk21-alpine AS build +WORKDIR /app +COPY settings.gradle.kts build.gradle.kts ./ +COPY src ./src +RUN gradle --no-daemon -q installDist + +FROM eclipse-temurin:21-jre-alpine +RUN adduser -D -H -u 1000 app +COPY --from=build /app/build/install/app /opt/app +# The platform runs the task as uid 1000 with a read-only root filesystem; +# only /tmp is writable. +USER 1000 +ENV PORT={{PORT}} +EXPOSE {{PORT}} +CMD ["/opt/app/bin/app"] +`, + ".dockerignore": dockerignoreCommon + "build\n.gradle\n", + "settings.gradle.kts": `rootProject.name = "app" +`, + "build.gradle.kts": `plugins { + kotlin("jvm") version "2.1.21" + application +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) +} + +application { + mainClass.set("no.java.app.MainKt") +} +`, + "src/main/kotlin/no/java/app/Main.kt": `package no.java.app + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress + +fun main() { + val port = System.getenv("PORT")?.toIntOrNull() ?: {{PORT}} + val server = HttpServer.create(InetSocketAddress(port), 0) + server.createContext("/health") { respond(it, "ok") } + server.createContext("/") { respond(it, "Hello from {{NAME}}") } + server.start() + println("Listening on :$port") +} + +private fun respond(exchange: HttpExchange, body: String) { + val bytes = body.toByteArray() + exchange.responseHeaders.set("Content-Type", "text/plain; charset=utf-8") + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } +} +`, + }, + }, + "typescript": { + defaultPort: "3000", + files: map[string]string{ + "Dockerfile": `FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json tsconfig.json ./ +# npm ci once you commit a package-lock.json; npm install works without one. +RUN if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi +COPY src ./src +RUN npm run build && npm prune --omit=dev + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +# The platform runs the task as uid 1000 (the image's "node" user) with a +# read-only root filesystem; only /tmp is writable. +USER 1000 +ENV PORT={{PORT}} +EXPOSE {{PORT}} +CMD ["node", "dist/index.js"] +`, + ".dockerignore": dockerignoreCommon + "node_modules\ndist\n", + "package.json": `{ + "name": "{{NAME}}", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} +`, + "tsconfig.json": `{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} +`, + "src/index.ts": `import { createServer } from "node:http"; + +const port = Number(process.env.PORT ?? {{PORT}}); + +const server = createServer((req, res) => { + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + if (req.url === "/health") { + res.end("ok"); + return; + } + res.end("Hello from {{NAME}}"); +}); + +server.listen(port, () => console.log(` + "`Listening on :${port}`" + `)); +`, + }, + }, + "python": { + defaultPort: "8000", + files: map[string]string{ + "Dockerfile": `FROM python:3.12-slim +# No .pyc writes: the root filesystem is read-only at runtime. +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +RUN useradd --uid 1000 --no-create-home app +# The platform runs the task as uid 1000 with a read-only root filesystem; +# only /tmp is writable. +USER 1000 +ENV PORT={{PORT}} +EXPOSE {{PORT}} +CMD ["python", "main.py"] +`, + ".dockerignore": dockerignoreCommon + "__pycache__\n.venv\n", + "requirements.txt": `# Add dependencies here, one per line (the scaffold uses only the standard library). +`, + "main.py": `import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + body = b"ok" if self.path == "/health" else b"Hello from {{NAME}}" + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "{{PORT}}")) + print(f"Listening on :{port}", flush=True) + ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever() +`, + }, + }, + "go": { + defaultPort: "8080", + files: map[string]string{ + "Dockerfile": `FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.* ./ +RUN go mod download +COPY *.go ./ +RUN CGO_ENABLED=0 go build -o /out/server . + +FROM alpine:3.20 +RUN adduser -D -H -u 1000 app +COPY --from=build /out/server /server +# The platform runs the task as uid 1000 with a read-only root filesystem; +# only /tmp is writable. +USER 1000 +ENV PORT={{PORT}} +EXPOSE {{PORT}} +CMD ["/server"] +`, + ".dockerignore": dockerignoreCommon, + "go.mod": `module github.com/javaBin/{{NAME}} + +go 1.23 +`, + "main.go": `package main + +import ( + "fmt" + "log" + "net/http" + "os" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "{{PORT}}" + } + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "ok") + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello from {{NAME}}") + }) + log.Printf("Listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} +`, + }, + }, +} + +// render returns the runtime's files with name and port filled in. +func (rc runtimeConfig) render(name, port string) map[string]string { + r := strings.NewReplacer("{{NAME}}", name, "{{PORT}}", port) + out := make(map[string]string, len(rc.files)) + for path, body := range rc.files { + out[path] = r.Replace(body) + } + return out +} + +// writeFiles writes files under dir and returns their paths, sorted. +func writeFiles(dir string, files map[string]string) ([]string, error) { + var paths []string + for path, body := range files { + full := filepath.Join(dir, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + return nil, err + } + paths = append(paths, path) + } + sort.Strings(paths) + return paths, nil +} diff --git a/cmd/scaffold_test.go b/cmd/scaffold_test.go new file mode 100644 index 0000000..09c72ca --- /dev/null +++ b/cmd/scaffold_test.go @@ -0,0 +1,152 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + gh "github.com/javaBin/javabin-cli/internal/github" +) + +// buildContextSources returns the source paths of COPY instructions that read +// from the build context (not --from=). +func buildContextSources(dockerfile string) []string { + var srcs []string + for _, line := range strings.Split(dockerfile, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 || fields[0] != "COPY" || strings.HasPrefix(fields[1], "--from") { + continue + } + srcs = append(srcs, fields[1:len(fields)-1]...) + } + return srcs +} + +func TestScaffoldsAreSelfContained(t *testing.T) { + for runtime, rc := range runtimes { + t.Run(runtime, func(t *testing.T) { + files := rc.render("demo", "9090") + df, ok := files["Dockerfile"] + if !ok { + t.Fatal("no Dockerfile") + } + // Every file the build copies from the context is part of the scaffold. + for _, src := range buildContextSources(df) { + found := false + for path := range files { + if m, _ := filepath.Match(src, path); m || path == src || strings.HasPrefix(path, strings.TrimSuffix(src, "/")+"/") { + found = true + } + } + if !found { + t.Errorf("Dockerfile copies %q, which the scaffold does not write", src) + } + } + for _, banned := range []string{"mvnw", "gradlew"} { + if strings.Contains(df, banned) { + t.Errorf("Dockerfile uses %s, which the scaffold does not ship", banned) + } + } + for _, want := range []string{"USER 1000\n", "ENV PORT=9090\n", "EXPOSE 9090\n"} { + if !strings.Contains(df, want) { + t.Errorf("Dockerfile missing %q", want) + } + } + var src strings.Builder + for path, body := range files { + if strings.Contains(body, "{{") { + t.Errorf("%s has an unrendered placeholder", path) + } + src.WriteString(body) + } + if !strings.Contains(src.String(), "/health") || !strings.Contains(src.String(), "9090") { + t.Error("app does not serve /health on the chosen port by default") + } + }) + } +} + +func TestWriteFilesCreatesDirectories(t *testing.T) { + dir := t.TempDir() + paths, err := writeFiles(dir, runtimes["java"].render("demo", "8080")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "src/main/java/no/java/app/App.java")); err != nil { + t.Error(err) + } + if len(paths) != len(runtimes["java"].files) || paths[0] != ".dockerignore" { + t.Errorf("paths = %v", paths) + } +} + +// TestScaffoldImagesServeHealth builds each scaffold and runs it the way the +// platform does (uid 1000, read-only root, writable /tmp). It needs Docker +// and network, so it only runs with JAVABIN_SCAFFOLD_DOCKER=1. +func TestScaffoldImagesServeHealth(t *testing.T) { + if os.Getenv("JAVABIN_SCAFFOLD_DOCKER") != "1" { + t.Skip("set JAVABIN_SCAFFOLD_DOCKER=1 to build and run the scaffold images") + } + hostPort := 18080 + for runtime, rc := range runtimes { + hostPort++ + port := "9090" + t.Run(runtime, func(t *testing.T) { + dir := t.TempDir() + if _, err := writeFiles(dir, rc.render("demo", port)); err != nil { + t.Fatal(err) + } + tag := "javabin-scaffold-test-" + runtime + if out, err := exec.Command("docker", "build", "-q", "-t", tag, dir).CombinedOutput(); err != nil { + t.Fatalf("docker build: %v\n%s", err, out) + } + out, err := exec.Command("docker", "run", "-d", "--rm", "--user", "1000", "--read-only", + "--tmpfs", "/tmp", "-p", fmt.Sprintf("%d:%s", hostPort, port), tag).Output() + if err != nil { + t.Fatalf("docker run: %v", err) + } + id := strings.TrimSpace(string(out)) + defer exec.Command("docker", "rm", "-f", id).Run() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + url := fmt.Sprintf("http://127.0.0.1:%d/health", hostPort) + for { + resp, err := http.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("GET /health = %d", resp.StatusCode) + } + return + } + select { + case <-ctx.Done(): + logs, _ := exec.Command("docker", "logs", id).CombinedOutput() + t.Fatalf("no /health response: %v\n%s", err, logs) + case <-time.After(time.Second): + } + } + }) + } +} + +func TestTeamAccessErrorSaysHowToRecover(t *testing.T) { + err := teamAccessError(fmt.Errorf("x: %w", gh.ErrNoTeamAccess), "video", "recorder").Error() + for _, want := range []string{ + "gh api -X PUT orgs/javaBin/teams/video/repos/javaBin/recorder -f permission=push", + "NOT pushed", + "cd recorder && git push", + "maintainer", + } { + if !strings.Contains(err, want) { + t.Errorf("error missing %q:\n%s", want, err) + } + } +} diff --git a/cmd/secret.go b/cmd/secret.go index 235ee6b..f250a3a 100644 --- a/cmd/secret.go +++ b/cmd/secret.go @@ -7,7 +7,10 @@ import ( "fmt" "io" "os" + "os/exec" "os/signal" + "path/filepath" + "regexp" "strings" "github.com/aws/aws-sdk-go-v2/aws" @@ -46,12 +49,86 @@ func loadAppYaml(path string) (*appSecrets, error) { 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) + if app.Team == "" { + return nil, fmt.Errorf("%s must set team", path) + } + if app.Name == "" { + // The platform defaults name to the repo name, but only accepts it if + // it is a valid app name. + repo := repoName(filepath.Dir(path)) + if appNameRE.MatchString(repo) { + app.Name = repo + } else { + return nil, missingNameError(path, repo) + } + } + if !appNameRE.MatchString(app.Name) { + return nil, fmt.Errorf("%s: name %q must start with a letter and use lowercase letters, digits and hyphens, max 20 chars%s", + path, app.Name, suggestName(app.Name)) } return &app, nil } +// appNameRE is the platform's app.yaml name rule (generate-modules.py NAME_RE). +var appNameRE = regexp.MustCompile(`^[a-z][a-z0-9-]{0,19}$`) + +func missingNameError(path, repo string) error { + if repo == "" { + return fmt.Errorf("%s has no name; set name: (lowercase letters, digits, hyphens, max 20 chars)", path) + } + return fmt.Errorf("%s has no name, and the repo name %q is not a valid app name (the platform pipeline rejects it too);\n"+ + "add a name to app.yaml (lowercase letters, digits, hyphens, start with a letter, max 20 chars)%s", path, repo, suggestName(repo)) +} + +func suggestName(s string) string { + if n := sanitizeAppName(s); n != "" { + return fmt.Sprintf(", e.g.\n name: %s", n) + } + return "" +} + +// sanitizeAppName turns a repo name like "2027.javazone.no" into a valid app +// name ("javazone-no-2027"): lowercase, non-alphanumerics become hyphens, +// leading numeric parts move to the end, max 20 chars. Returns "" if nothing +// usable is left. +func sanitizeAppName(s string) string { + parts := regexp.MustCompile(`[^a-z0-9]+`).Split(strings.ToLower(s), -1) + var words, numbers []string + for _, p := range parts { + switch { + case p == "": + case len(words) == 0 && (p[0] < 'a' || p[0] > 'z'): + numbers = append(numbers, p) + default: + words = append(words, p) + } + } + if len(words) == 0 { + return "" + } + name := strings.Join(append(words, numbers...), "-") + if len(name) > 20 { + name = strings.TrimRight(name[:20], "-") + } + return name +} + +// repoName is the GitHub repo name of the checkout in dir (from the origin +// remote), falling back to the directory name. +func repoName(dir string) string { + if out, err := exec.Command("git", "-C", dir, "remote", "get-url", "origin").Output(); err == nil { + url := strings.TrimSuffix(strings.TrimSpace(string(out)), ".git") + if i := strings.LastIndexAny(url, "/:"); i >= 0 && i < len(url)-1 { + return url[i+1:] + } + } + abs, err := filepath.Abs(dir) + if err != nil { + return "" + } + return filepath.Base(abs) +} + // secretPath is where app.yaml `resources.secrets` entries live in SSM // (service-secret module). func secretPath(app *appSecrets, name string) string { @@ -84,7 +161,9 @@ var secretCmd = &cobra.Command{ 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/// and injected into the -container at task start. Needs AWS SSO credentials (javabin-developer).`, +container at task start. Needs your team's AWS SSO role, javabin-team- +(run "javabin aws setup" once; the profile is picked from app.yaml's team). +javabin-developer cannot reach team secrets.`, } var secretListCmd = &cobra.Command{ diff --git a/cmd/secret_test.go b/cmd/secret_test.go index c4fb9e1..4242a65 100644 --- a/cmd/secret_test.go +++ b/cmd/secret_test.go @@ -34,12 +34,69 @@ func TestSecretPathMatchesServiceSecretModule(t *testing.T) { } } -func TestLoadAppYamlRequiresNameAndTeam(t *testing.T) { +func TestLoadAppYamlRequiresTeam(t *testing.T) { if _, err := loadAppYaml(writeAppYaml(t, "name: x\n")); err == nil { t.Error("expected an error without team") } } +func writeAppYamlIn(t *testing.T, dirName, body string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), dirName) + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestLoadAppYamlDefaultsNameToRepo(t *testing.T) { + app, err := loadAppYaml(writeAppYamlIn(t, "cupcake-web", "team: cupcake\n")) + if err != nil { + t.Fatal(err) + } + if app.Name != "cupcake-web" { + t.Errorf("name = %q, want the repo name", app.Name) + } +} + +func TestLoadAppYamlExplainsDottedRepoName(t *testing.T) { + _, err := loadAppYaml(writeAppYamlIn(t, "2027.javazone.no", "team: javazoneweb\n")) + if err == nil { + t.Fatal("expected an error: 2027.javazone.no is not a valid app name") + } + for _, want := range []string{`"2027.javazone.no"`, "name: javazone-no-2027"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q:\n%v", want, err) + } + } + if _, err := loadAppYaml(writeAppYaml(t, "name: 2027.javazone.no\nteam: javazoneweb\n")); err == nil || + !strings.Contains(err.Error(), "name: javazone-no-2027") { + t.Errorf("invalid name should be rejected with a suggestion, got %v", err) + } +} + +func TestSanitizeAppName(t *testing.T) { + for in, want := range map[string]string{ + "2027.javazone.no": "javazone-no-2027", + "java.no": "java-no", + "Moresleep": "moresleep", + "a-very-long-repository-name": "a-very-long-reposito", + "2024": "", + "cupcake--web.": "cupcake-web", + } { + if got := sanitizeAppName(in); got != want { + t.Errorf("sanitizeAppName(%q) = %q, want %q", in, got, want) + } + if got := sanitizeAppName(in); got != "" && !appNameRE.MatchString(got) { + t.Errorf("sanitizeAppName(%q) = %q is not a valid name", in, got) + } + } +} + func TestReadSecretValueFromStdinTrimsNewline(t *testing.T) { v, err := readSecretValue(strings.NewReader("s3cret\n"), true, "sp_user") if err != nil || v != "s3cret" { diff --git a/cmd/status.go b/cmd/status.go index 29a76f9..b202fe3 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -25,7 +25,11 @@ Flags --team and --service override auto-detection. If run from a directory with an app.yaml, team and service name are read from it automatically. Use --resources to show resource-level cost breakdown from CUR data via Athena. -Use --period to control the time window (day, week, month).`, +Use --period to control the time window (day, week, month). + +AWS credentials: --profile, AWS_PROFILE, or the javabin-team- profile +written by "javabin aws setup" (picked automatically). Costs are AWS USD +amounts shown with their approximate NOK value (team budgets are in NOK).`, RunE: runStatus, } @@ -79,9 +83,11 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Println() ctx := context.Background() - cfg, err := aws.LoadConfig(ctx) + // Same profile resolution as `secret`: --profile, AWS_PROFILE, then the + // javabin-team- profile written by `javabin aws setup`. + cfg, err := aws.LoadConfigForTeam(ctx, team) if err != nil { - return fmt.Errorf("AWS credentials not configured: %w", err) + return err } // Cost this month @@ -90,7 +96,7 @@ func runStatus(cmd *cobra.Command, args []string) error { if err != nil { fmt.Printf(" Could not fetch costs: %v\n", err) } else { - fmt.Printf(" Team spend: $%.2f\n", cost) + fmt.Printf(" Team spend: %s\n", formatCost(cost)) } // Resource-level breakdown from CUR @@ -103,7 +109,7 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Println(" No CUR data available yet") } else { for _, r := range resources { - fmt.Printf(" %-40s %-20s $%.2f\n", r.FriendlyName(), r.Service, r.Cost) + fmt.Printf(" %-40s %-20s %s\n", r.FriendlyName(), r.Service, formatCost(r.Cost)) } } } @@ -137,3 +143,14 @@ func runStatus(cmd *cobra.Command, args []string) error { return nil } + +// usdToNOK is the approximate rate the platform uses to turn team budgets +// (budget_nok) into USD AWS Budgets and to report costs +// (platform terraform/lambda-src/shared/constants.py USD_TO_NOK). +const usdToNOK = 10.5 + +// formatCost shows an AWS (USD) amount next to its NOK equivalent, the +// currency team budgets are set in, like the platform's cost reports. +func formatCost(usd float64) string { + return fmt.Sprintf("$%.2f (~%.0f NOK)", usd, usd*usdToNOK) +} diff --git a/cmd/status_test.go b/cmd/status_test.go new file mode 100644 index 0000000..128bd64 --- /dev/null +++ b/cmd/status_test.go @@ -0,0 +1,9 @@ +package cmd + +import "testing" + +func TestFormatCostShowsNOK(t *testing.T) { + if got := formatCost(12.34); got != "$12.34 (~130 NOK)" { + t.Errorf("formatCost = %q", got) + } +} diff --git a/internal/github/github.go b/internal/github/github.go index b9f8f5b..053cae3 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -16,9 +17,11 @@ const ( orgName = "javaBin" registryOwner = "javaBin" registryRepo = "registry" - apiBase = "https://api.github.com" ) +// apiBase is a variable so tests can point it at an httptest server. +var apiBase = "https://api.github.com" + // GetToken returns a GitHub token from gh CLI or environment. func GetToken() (string, error) { // Try gh CLI first @@ -194,3 +197,31 @@ func RepoExists(token, name string) bool { func getenv(key string) string { return os.Getenv(key) } + +// ErrNoTeamAccess means the caller may not add repos to the team (not a team +// maintainer or org owner), or the team does not exist. +var ErrNoTeamAccess = errors.New("no permission to manage the team's repos") + +// AddRepoToTeam gives a GitHub team push access to javaBin/ +// (PUT /orgs/javaBin/teams//repos/javaBin/). +func AddRepoToTeam(token, team, repo string) error { + url := fmt.Sprintf("%s/orgs/%s/teams/%s/repos/%s/%s", apiBase, orgName, team, orgName, repo) + data, _ := json.Marshal(map[string]string{"permission": "push"}) + req, _ := http.NewRequest("PUT", url, bytes.NewReader(data)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + switch { + case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound: + return fmt.Errorf("%w (GitHub API %d: %s)", ErrNoTeamAccess, resp.StatusCode, strings.TrimSpace(string(body))) + case resp.StatusCode >= 400: + return fmt.Errorf("GitHub API PUT %s: %d %s", req.URL.Path, resp.StatusCode, string(body)) + } + return nil +} diff --git a/internal/github/github_test.go b/internal/github/github_test.go new file mode 100644 index 0000000..ead6cca --- /dev/null +++ b/internal/github/github_test.go @@ -0,0 +1,42 @@ +package github + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAddRepoToTeam(t *testing.T) { + var method, path string + var body map[string]string + status := http.StatusNoContent + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(status) + })) + defer srv.Close() + old := apiBase + apiBase = srv.URL + defer func() { apiBase = old }() + + if err := AddRepoToTeam("tok", "video", "recorder"); err != nil { + t.Fatal(err) + } + if method != "PUT" || path != "/orgs/javaBin/teams/video/repos/javaBin/recorder" || body["permission"] != "push" { + t.Errorf("request = %s %s %v", method, path, body) + } + + for _, s := range []int{http.StatusForbidden, http.StatusNotFound} { + status = s + if err := AddRepoToTeam("tok", "video", "recorder"); !errors.Is(err, ErrNoTeamAccess) { + t.Errorf("status %d: err = %v, want ErrNoTeamAccess", s, err) + } + } + status = http.StatusInternalServerError + if err := AddRepoToTeam("tok", "video", "recorder"); err == nil || errors.Is(err, ErrNoTeamAccess) { + t.Errorf("status 500: err = %v", err) + } +}