diff --git a/CHANGELOG.md b/CHANGELOG.md index da3e9326..0f33235c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ This file is maintained by [release-please](.github/workflows/release-please.yml from conventional commit messages. Add entries by writing good commit messages, not by editing this file by hand. +## Unreleased — provider push + +`tfpfgen provider push -out DIR -repo URL` publishes the generated provider +tree to its own repository: shallow-clone, sync, manifest-based pruning of +files the generator owned and no longer produces, a commit with stated +provenance on the generator-owned `tfpfgen/generate-` branch, and a +pull request against the default branch. No difference means exit 0 and +nothing pushed. The token comes from `TFPFGEN_GITHUB_TOKEN` (or +`GITHUB_TOKEN`) — env only, never a flag. + ## Unreleased — Naming standard hard cut Everything below was renamed in one release; no aliases were kept. diff --git a/cmd/tfpfgen/generate.go b/cmd/tfpfgen/generate.go index 61d62eec..62e23a71 100644 --- a/cmd/tfpfgen/generate.go +++ b/cmd/tfpfgen/generate.go @@ -24,6 +24,12 @@ var providerVerbs = []command{ usage: usageProviderGenerate, run: runProviderGenerate, }, + { + name: "push", + summary: "publish the generated tree to its repository as a branch and pull request", + usage: usageProviderPush, + run: runProviderPush, + }, { name: "scaffold", summary: "write a blank resource from the scaffold template, registered and compiling", diff --git a/cmd/tfpfgen/main_test.go b/cmd/tfpfgen/main_test.go index db1fecd2..82b16229 100644 --- a/cmd/tfpfgen/main_test.go +++ b/cmd/tfpfgen/main_test.go @@ -89,6 +89,7 @@ var builtCommands = map[string]bool{ "probe sweep": true, "probe list": true, "provider generate": true, + "provider push": true, "bindings check": true, "bindings facts": true, // spec exports, and imports resources-only drafts. diff --git a/cmd/tfpfgen/push.go b/cmd/tfpfgen/push.go new file mode 100644 index 00000000..ff8f2e27 --- /dev/null +++ b/cmd/tfpfgen/push.go @@ -0,0 +1,522 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/manifest" +) + +const usageProviderPush = "provider push -out DIR -repo URL [-branch NAME] [-base NAME] [-dry-run]" + +// The environment is the only place the token comes from, for the same reason +// as the probe credential: a flag puts it in shell history and in the process +// table. GITHUB_TOKEN is accepted as a fallback because that is what Actions +// injects, so CI needs no extra plumbing. +const ( + pushTokenEnv = "TFPFGEN_GITHUB_TOKEN" + pushTokenFallbackEnv = "GITHUB_TOKEN" +) + +// pushBranchPrefix namespaces the branches this verb creates. Everything under +// it is generator-owned, which is what makes force-pushing one defensible: the +// branch's content is a pure function of the blueprints, so history on it holds +// nothing a human wrote. +const pushBranchPrefix = "tfpfgen/generate-" + +// pushTimeout bounds each git invocation and the PR call. A provider tree is a +// few megabytes; anything slower is a network problem worth hearing about. +const pushTimeout = 2 * time.Minute + +type pushOptions struct { + out string + repo string + branch string + base string + dryRun bool +} + +func runProviderPush(args []string) error { + fs, _ := newFlagSet("provider push", usageProviderPush) + + var o pushOptions + fs.StringVar(&o.out, "out", "", "provider root to publish, as written by provider generate (required)") + fs.StringVar(&o.repo, "repo", "", "target repository: a clone URL or GitHub owner/name (required)") + fs.StringVar(&o.branch, "branch", "", + "branch to push; defaults to "+pushBranchPrefix+" derived from the manifest") + fs.StringVar(&o.base, "base", "", + "branch to diff and open the pull request against; defaults to the repository's default branch") + fs.BoolVar(&o.dryRun, "dry-run", false, "clone and compare, but push nothing and open nothing") + + if err := parse(fs, args); err != nil { + return err + } + + if o.out == "" { + return usagef("-out is required: it names the provider root to publish") + } + if o.repo == "" { + return usagef("-repo is required: it names the repository to publish into") + } + + // A tree with no manifest is a tree this generator has not produced, and + // publishing one would put content of unknown provenance under a commit + // message that claims otherwise. + m, ok, err := manifest.Load(o.out) + if err != nil { + return err + } + if !ok { + return usagef("%s has no %s; run provider generate first -- push publishes generated output, not arbitrary trees", + o.out, manifest.Name) + } + + target, err := parseRepo(o.repo) + if err != nil { + return err + } + + token := os.Getenv(pushTokenEnv) + if token == "" { + token = os.Getenv(pushTokenFallbackEnv) + } + if target.gitHub && token == "" && !o.dryRun { + return usagef("%s (or %s) must be set to push to %s", pushTokenEnv, pushTokenFallbackEnv, target.host) + } + + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("provider push needs git on PATH: %w", err) + } + + work, err := os.MkdirTemp("", "tfpfgen-push-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(work) }() + + g := gitRunner{dir: work, host: target.host, token: token} + + cloneArgs := []string{"clone", "--depth", "1"} + if o.base != "" { + cloneArgs = append(cloneArgs, "--branch", o.base) + } + cloneArgs = append(cloneArgs, target.cloneURL, work) + if _, err := g.in("").run(cloneArgs...); err != nil { + return fmt.Errorf("cloning %s: %w", target.cloneURL, err) + } + + base := o.base + if base == "" { + out, err := g.run("rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return fmt.Errorf("finding the default branch: %w", err) + } + base = strings.TrimSpace(out) + } + + // The target's previous manifest is read before the copy overwrites it: it + // is the only record of which files the generator owned there last time, + // and pruning works off it. Files the target repository carries that the + // generator never produced -- its own workflows, its licence -- are never + // touched, which is the same ownership rule the drift check enforces. + previous, hadManifest, err := manifest.Load(work) + if err != nil { + return err + } + + produced, err := syncTree(o.out, work) + if err != nil { + return err + } + + if hadManifest { + orphans, err := previous.Orphans(work, produced) + if err != nil { + return err + } + for _, p := range orphans { + if err := os.Remove(filepath.Join(work, p)); err != nil { + return fmt.Errorf("pruning %s: %w", p, err) + } + log.Printf("pruned %s (generated last push, no longer produced)", p) + } + } + + if _, err := g.run("add", "--all"); err != nil { + return err + } + status, err := g.run("status", "--porcelain") + if err != nil { + return err + } + if strings.TrimSpace(status) == "" { + log.Printf("✅ %s already matches %s; nothing to push", target.cloneURL, o.out) + return nil + } + + changed := strings.Count(strings.TrimSpace(status), "\n") + 1 + + if o.dryRun { + fmt.Fprint(os.Stdout, status) + log.Printf("%d file(s) would change on %s; dry run, nothing was pushed", changed, base) + return nil + } + + branch := o.branch + if branch == "" { + digest, err := manifestDigest(o.out) + if err != nil { + return err + } + branch = pushBranchPrefix + digest + } + + if _, err := g.run("checkout", "-B", branch); err != nil { + return err + } + if _, err := g.run("commit", "-m", pushCommitMessage(m, changed)); err != nil { + return err + } + // Forced, and only ever onto the generator-owned branch namespace: the + // content is a pure function of the blueprints, so the newest generation is + // always the right thing for the branch to hold. + if _, err := g.run("push", "--force", "origin", "HEAD:refs/heads/"+branch); err != nil { + return fmt.Errorf("pushing %s: %w", branch, err) + } + + log.Printf("pushed %d file change(s) to %s on %s", changed, target.cloneURL, branch) + + if !target.gitHub { + log.Printf("note: %s is not a GitHub host, so no pull request was opened; merge %s where the repository lives", + target.host, branch) + return nil + } + + prURL, err := openPullRequest(target, token, branch, base, m, changed) + if err != nil { + // The push itself succeeded, and saying so matters more than the PR + // call failing: the work is on the branch either way. + return fmt.Errorf("the branch is pushed, but opening the pull request failed: %w", err) + } + + log.Printf("✅ pull request: %s", prURL) + return nil +} + +// repoTarget is a parsed -repo value. +type repoTarget struct { + host string + owner string + name string + cloneURL string + // gitHub reports whether the host speaks the GitHub API, which is what + // decides whether a pull request can be opened. + gitHub bool + apiBase string +} + +// parseRepo accepts a clone URL (https or ssh) or a bare GitHub owner/name. +func parseRepo(raw string) (repoTarget, error) { + var t repoTarget + + switch { + // git@host:owner/name(.git) + case strings.HasPrefix(raw, "git@"): + rest := strings.TrimPrefix(raw, "git@") + host, path, ok := strings.Cut(rest, ":") + if !ok { + return t, usagef("-repo %q is not a usable ssh remote", raw) + } + t.host = host + t.cloneURL = raw + t.owner, t.name = splitOwnerName(path) + + case strings.Contains(raw, "://"): + u, err := url.Parse(raw) + if err != nil || (u.Host == "" && u.Scheme != "file") { + return t, usagef("-repo %q is not a usable URL", raw) + } + // A file:// remote has no host and no forge; it exists so the whole + // flow short of the pull request can run against a local repository. + t.host = u.Scheme + t.cloneURL = raw + if u.Host != "" { + t.host = u.Host + t.owner, t.name = splitOwnerName(u.Path) + } + + default: + // owner/name shorthand means github.com, matching the gh CLI. + t.host = "github.com" + t.owner, t.name = splitOwnerName(raw) + if t.owner == "" || t.name == "" { + return t, usagef("-repo %q is not owner/name or a clone URL", raw) + } + t.cloneURL = "https://github.com/" + t.owner + "/" + t.name + ".git" + } + + if t.host == "github.com" { + t.gitHub = true + t.apiBase = "https://api.github.com" + } else if strings.Contains(t.host, "github") { + // GitHub Enterprise serves its REST API under /api/v3. + t.gitHub = true + t.apiBase = "https://" + t.host + "/api/v3" + } + if t.gitHub && (t.owner == "" || t.name == "") { + return t, usagef("-repo %q does not name an owner and repository", raw) + } + + return t, nil +} + +func splitOwnerName(path string) (owner, name string) { + path = strings.Trim(strings.TrimSuffix(path, ".git"), "/") + parts := strings.Split(path, "/") + if len(parts) != 2 { + return "", "" + } + return parts[0], parts[1] +} + +// gitRunner runs git with the credential in the environment rather than on the +// command line: GIT_CONFIG_* is invisible to the process table, argv is not. +type gitRunner struct { + dir string + host string + token string +} + +// in returns a runner working in a different directory; in("") means no +// directory, for the clone that creates it. +func (g gitRunner) in(dir string) gitRunner { + g.dir = dir + return g +} + +func (g gitRunner) run(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = g.dir + + env := append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "GIT_AUTHOR_NAME=tfpfgen", + "GIT_AUTHOR_EMAIL=tfpfgen@users.noreply.github.com", + "GIT_COMMITTER_NAME=tfpfgen", + "GIT_COMMITTER_EMAIL=tfpfgen@users.noreply.github.com", + ) + if g.token != "" { + basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + g.token)) + env = append(env, + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http.https://"+g.host+"/.extraHeader", + "GIT_CONFIG_VALUE_0=Authorization: Basic "+basic, + ) + } + cmd.Env = env + + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %v\n%s", args[0], err, errb.String()) + } + return out.String(), nil +} + +// syncTree copies every file under src into dst, and returns the set of +// relative slash paths it produced -- the shape Orphans wants. +func syncTree(src, dst string) (map[string]bool, error) { + produced := map[string]bool{} + + err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + // The provider root should carry no .git of its own, but publishing one + // into another repository would be corrupting, so it is refused by skip + // rather than trusted to be absent. + if info.IsDir() { + if info.Name() == ".git" { + return filepath.SkipDir + } + return os.MkdirAll(filepath.Join(dst, rel), 0o750) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file; a generated tree holds nothing else", path) + } + + data, err := os.ReadFile(path) //nolint:gosec // operator-supplied tree by design + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dst, rel), data, info.Mode().Perm()); err != nil { + return err + } + produced[filepath.ToSlash(rel)] = true + return nil + }) + + return produced, err +} + +// manifestDigest derives the branch suffix from the manifest bytes. The same +// generated content always names the same branch, so re-running push after an +// interruption updates the branch instead of scattering siblings. +func manifestDigest(root string) (string, error) { + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(manifest.Name))) //nolint:gosec // fixed relative path + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:])[:12], nil +} + +// pushCommitMessage states provenance: what produced the tree, from what, and +// how much moved. The blueprint set is taken from the manifest so the message +// cannot claim a source the inventory does not. +func pushCommitMessage(m manifest.Manifest, changed int) string { + return fmt.Sprintf( + "Regenerate provider from %s\n\nProduced by tfpfgen %s; %d file(s) changed.\nThis branch is generator-owned: edit the blueprints, not these files.", + blueprintSources(m), m.ToolVersion, changed) +} + +// blueprintSources names the distinct blueprint roots the manifest records. +func blueprintSources(m manifest.Manifest) string { + seen := map[string]bool{} + var sources []string + for _, e := range m.Files { + if e.Blueprint == "" || e.Blueprint == "orphaned" || seen[e.Blueprint] { + continue + } + seen[e.Blueprint] = true + sources = append(sources, e.Blueprint) + } + if len(sources) == 0 { + return "its blueprints" + } + return strings.Join(sources, ", ") +} + +// openPullRequest opens the PR, or finds the one already open for the branch. +// A second push to the same branch must not fail over a PR that already says +// exactly what this one would. +func openPullRequest(t repoTarget, token, branch, base string, m manifest.Manifest, changed int) (string, error) { + title := "Regenerate provider from blueprints" + body := fmt.Sprintf( + "Generated by `tfpfgen %s` from %s — %d file(s) changed.\n\n"+ + "This branch is generator-owned and force-pushed on regeneration. "+ + "Review the diff here; to change the content, edit the blueprints and regenerate.", + m.ToolVersion, blueprintSources(m), changed) + + payload, err := json.Marshal(map[string]string{ + "title": title, + "body": body, + "head": branch, + "base": base, + }) + if err != nil { + return "", err + } + + status, resp, err := gitHubAPI(t, token, http.MethodPost, "/repos/"+t.owner+"/"+t.name+"/pulls", payload) + if err != nil { + return "", err + } + + switch status { + case http.StatusCreated: + var pr struct { + HTMLURL string `json:"html_url"` + } + if err := json.Unmarshal(resp, &pr); err != nil { + return "", err + } + return pr.HTMLURL, nil + + case http.StatusUnprocessableEntity: + // Usually "a pull request already exists" -- find it and report it, + // because the branch it points at was just updated by this run. + status, resp, err := gitHubAPI(t, token, http.MethodGet, + "/repos/"+t.owner+"/"+t.name+"/pulls?state=open&head="+url.QueryEscape(t.owner+":"+branch), nil) + if err == nil && status == http.StatusOK { + var prs []struct { + HTMLURL string `json:"html_url"` + } + if json.Unmarshal(resp, &prs) == nil && len(prs) > 0 { + return prs[0].HTMLURL + " (already open; the branch behind it was updated)", nil + } + } + return "", fmt.Errorf("GitHub refused the pull request (422): %s", firstAPIError(resp)) + + default: + return "", fmt.Errorf("GitHub answered %d to the pull request: %s", status, firstAPIError(resp)) + } +} + +// gitHubAPI issues one authenticated REST call. +func gitHubAPI(t repoTarget, token, method, path string, body []byte) (int, []byte, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequest(method, t.apiBase+path, reader) + if err != nil { + return 0, nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := &http.Client{Timeout: pushTimeout} + resp, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, data, nil +} + +// firstAPIError pulls the human sentence out of a GitHub error body. +func firstAPIError(body []byte) string { + var e struct { + Message string `json:"message"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if json.Unmarshal(body, &e) != nil || e.Message == "" { + return strings.TrimSpace(string(body)) + } + if len(e.Errors) > 0 && e.Errors[0].Message != "" { + return e.Message + ": " + e.Errors[0].Message + } + return e.Message +} diff --git a/cmd/tfpfgen/push_test.go b/cmd/tfpfgen/push_test.go new file mode 100644 index 00000000..3a75e252 --- /dev/null +++ b/cmd/tfpfgen/push_test.go @@ -0,0 +1,326 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/manifest" +) + +// needGit skips a test when git is not on PATH: push shells out to it by +// design, the same way postcheck shells out to terraform. +func needGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not on PATH") + } +} + +// noTokens keeps the developer's real credentials out of the test runs. +func noTokens(t *testing.T) { + t.Helper() + t.Setenv(pushTokenEnv, "") + t.Setenv(pushTokenFallbackEnv, "") +} + +func gitIn(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "init.defaultBranch=main"}, args...)...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return string(out) +} + +// seedTarget builds a bare repository whose default branch holds the given +// files, and returns its file:// clone URL. +func seedTarget(t *testing.T, files map[string]string) string { + t.Helper() + + bare := filepath.Join(t.TempDir(), "target.git") + gitIn(t, t.TempDir(), "init", "--bare", bare) + + work := t.TempDir() + gitIn(t, work, "clone", bare, filepath.Join(work, "clone")) + clone := filepath.Join(work, "clone") + + for rel, content := range files { + p := filepath.Join(clone, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + gitIn(t, clone, "add", "--all") + gitIn(t, clone, "commit", "-m", "seed") + gitIn(t, clone, "push", "origin", "HEAD:refs/heads/main") + + return "file://" + bare +} + +// generatedTree writes a provider root carrying a manifest, which is what +// makes it something push will agree to publish. +func generatedTree(t *testing.T, files map[string]string) string { + t.Helper() + + root := t.TempDir() + entries := make([]manifest.Entry, 0, len(files)) + for rel, content := range files { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + entries = append(entries, manifest.Entry{Path: rel, SHA256: "x", Blueprint: "blueprints/test"}) + } + if err := manifest.Save(root, manifest.New("test", entries)); err != nil { + t.Fatal(err) + } + return root +} + +func TestUnit_CLI_Push_RequiredFlagsAndManifest(t *testing.T) { + quiet(t) + noTokens(t) + + if err := runProviderPush(nil); err == nil { + t.Error("expected -out to be required") + } + if err := runProviderPush([]string{"-out", t.TempDir()}); err == nil { + t.Error("expected -repo to be required") + } + + // A tree nobody generated has no manifest, and push must refuse it rather + // than publish content of unknown provenance. + err := runProviderPush([]string{"-out", t.TempDir(), "-repo", "owner/name"}) + if err == nil || !strings.Contains(err.Error(), "provider generate first") { + t.Errorf("expected the manifest refusal, got: %v", err) + } +} + +func TestUnit_CLI_Push_GitHubNeedsAToken(t *testing.T) { + quiet(t) + noTokens(t) + + out := generatedTree(t, map[string]string{"main.go": "package main\n"}) + + err := runProviderPush([]string{"-out", out, "-repo", "owner/name"}) + if err == nil || !strings.Contains(err.Error(), pushTokenEnv) { + t.Errorf("expected the token refusal to name %s, got: %v", pushTokenEnv, err) + } +} + +func TestUnit_CLI_Push_ParseRepo(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + in string + owner, repo string + gitHub bool + }{ + "https": {"https://github.com/org/prov.git", "org", "prov", true}, + "https bare": {"https://github.com/org/prov", "org", "prov", true}, + "ssh": {"git@github.com:org/prov.git", "org", "prov", true}, + "shorthand": {"org/prov", "org", "prov", true}, + "enterprise": {"https://github.example.com/org/prov", "org", "prov", true}, + "file": {"file:///somewhere/target.git", "", "", false}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + got, err := parseRepo(tc.in) + if err != nil { + t.Fatalf("parseRepo(%q): %v", tc.in, err) + } + if got.owner != tc.owner || got.name != tc.repo || got.gitHub != tc.gitHub { + t.Errorf("parseRepo(%q) = %+v", tc.in, got) + } + }) + } + + if _, err := parseRepo("not-a-repo"); err == nil { + t.Error("expected a bare word to be refused") + } +} + +// TestUnit_CLI_Push_FirstPushOpensABranch is the main path: a target whose +// default branch holds an older generation plus files of its own, a source +// tree that renamed one generated file, and a push that must sync, prune the +// orphan, leave the target's own files alone, and land on the generator-owned +// branch -- without a pull request, because file:// is not a GitHub host. +func TestUnit_CLI_Push_FirstPushOpensABranch(t *testing.T) { + quiet(t) + needGit(t) + noTokens(t) + + oldManifest, err := manifest.Marshal(manifest.New("old", []manifest.Entry{ + {Path: "internal/old_resource.go", SHA256: "x", Blueprint: "blueprints/test"}, + })) + if err != nil { + t.Fatal(err) + } + + repo := seedTarget(t, map[string]string{ + ".github/workflows/release.yml": "name: release\n", + "internal/old_resource.go": "package internal\n", + manifest.Name: string(oldManifest), + }) + + out := generatedTree(t, map[string]string{ + "internal/new_resource.go": "package internal\n", + "go.mod": "module example.com/prov\n", + }) + + if err := runProviderPush([]string{"-out", out, "-repo", repo}); err != nil { + t.Fatalf("push: %v", err) + } + + // Read the pushed branch back out of the bare repository. + bare := strings.TrimPrefix(repo, "file://") + branches := gitIn(t, bare, "branch", "--list") + if !strings.Contains(branches, pushBranchPrefix) { + t.Fatalf("no generator-owned branch was pushed; branches:\n%s", branches) + } + branch := "" + for _, b := range strings.Fields(branches) { + if strings.HasPrefix(b, pushBranchPrefix) { + branch = b + } + } + + files := gitIn(t, bare, "ls-tree", "-r", "--name-only", branch) + for _, want := range []string{"internal/new_resource.go", "go.mod", ".github/workflows/release.yml", manifest.Name} { + if !strings.Contains(files, want) { + t.Errorf("the pushed branch is missing %s:\n%s", want, files) + } + } + if strings.Contains(files, "internal/old_resource.go") { + t.Error("the orphaned generated file was not pruned") + } + + msg := gitIn(t, bare, "log", "-1", "--format=%B", branch) + for _, want := range []string{"blueprints/test", "tfpfgen test", "generator-owned"} { + if !strings.Contains(msg, want) { + t.Errorf("the commit message omits %q:\n%s", want, msg) + } + } +} + +func TestUnit_CLI_Push_UpToDateIsANoOp(t *testing.T) { + quiet(t) + needGit(t) + noTokens(t) + + content := map[string]string{"go.mod": "module example.com/prov\n"} + out := generatedTree(t, content) + + // The target's default branch already holds exactly what the source tree + // holds, manifest included. + data, err := os.ReadFile(filepath.Join(out, filepath.FromSlash(manifest.Name))) + if err != nil { + t.Fatal(err) + } + repo := seedTarget(t, map[string]string{ + "go.mod": content["go.mod"], + manifest.Name: string(data), + }) + + if err := runProviderPush([]string{"-out", out, "-repo", repo}); err != nil { + t.Fatalf("an up-to-date push must succeed quietly: %v", err) + } + + bare := strings.TrimPrefix(repo, "file://") + if branches := gitIn(t, bare, "branch", "--list"); strings.Contains(branches, pushBranchPrefix) { + t.Errorf("an up-to-date push must not create a branch:\n%s", branches) + } +} + +func TestUnit_CLI_Push_DryRunPushesNothing(t *testing.T) { + quiet(t) + needGit(t) + noTokens(t) + + repo := seedTarget(t, map[string]string{"README.md": "seed\n"}) + out := generatedTree(t, map[string]string{"go.mod": "module example.com/prov\n"}) + + stdout := captureStdout(t, func() { + if err := runProviderPush([]string{"-out", out, "-repo", repo, "-dry-run"}); err != nil { + t.Errorf("dry run: %v", err) + } + }) + if !strings.Contains(stdout, "go.mod") { + t.Errorf("the dry run should report what would change:\n%s", stdout) + } + + bare := strings.TrimPrefix(repo, "file://") + if branches := gitIn(t, bare, "branch", "--list"); strings.Contains(branches, pushBranchPrefix) { + t.Errorf("a dry run must not push:\n%s", branches) + } +} + +// TestUnit_CLI_Push_PullRequest exercises the API half against a stub server: +// a 201 yields the new PR's URL, and a 422 -- the branch already has one -- +// finds and reports the open PR instead of failing a push that succeeded. +func TestUnit_CLI_Push_PullRequest(t *testing.T) { + t.Parallel() + + m := manifest.New("test", nil) + + t.Run("created", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/repos/org/prov/pulls" { + t.Errorf("unexpected call: %s %s", r.Method, r.URL) + } + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + t.Errorf("Authorization = %q", got) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"html_url":"https://github.example/pr/1"}`)) + })) + defer srv.Close() + + target := repoTarget{owner: "org", name: "prov", gitHub: true, apiBase: srv.URL} + url, err := openPullRequest(target, "tok", "tfpfgen/generate-abc", "main", m, 3) + if err != nil { + t.Fatalf("openPullRequest: %v", err) + } + if url != "https://github.example/pr/1" { + t.Errorf("url = %q", url) + } + }) + + t.Run("already open", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation Failed","errors":[{"message":"A pull request already exists"}]}`)) + return + } + _, _ = w.Write([]byte(`[{"html_url":"https://github.example/pr/7"}]`)) + })) + defer srv.Close() + + target := repoTarget{owner: "org", name: "prov", gitHub: true, apiBase: srv.URL} + url, err := openPullRequest(target, "tok", "tfpfgen/generate-abc", "main", m, 3) + if err != nil { + t.Fatalf("openPullRequest: %v", err) + } + if !strings.Contains(url, "https://github.example/pr/7") { + t.Errorf("url = %q", url) + } + }) +} diff --git a/docs/cli.md b/docs/cli.md index c6873181..9bf0752a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -21,7 +21,7 @@ order `help` prints them. | `openapi` | fetch and pin upstream OpenAPI documents | `fetch` | | `blueprint` | draft, merge, validate, diff or list blueprints | `draft`, `merge`; `validate`, `diff`, `list` planned | | `probe` | exercise a resource's lifecycle; record or replay cassettes | `record`, `replay`, `verify`, `sweep`, `list` | -| `provider` | generate a terraform-plugin-framework provider from blueprints | `generate`; `scaffold` planned | +| `provider` | generate a terraform-plugin-framework provider from blueprints | `generate`, `push`; `scaffold` planned | | `bindings` | check blueprint SDK bindings against the pinned SDK | `check`, `facts` | | `spec` | export or import Provider Code Specification v0.1 JSON | `export`, `import` | | `version` | print the toolkit version and exit | — | @@ -352,6 +352,44 @@ generating into a scratch directory for inspection stays cheap. should keep the battery on — a skipped battery just moves the same failures to the CI checks. +### `provider push` + +Publishes the generated tree to its own repository, as a branch and a pull +request. + +``` +tfpfgen provider push -out DIR -repo URL [-branch NAME] [-base NAME] [-dry-run] +``` + +| Flag | Default | Purpose | +|---|---|---| +| `-out` | — | provider root to publish, as written by `provider generate` (required) | +| `-repo` | — | target repository: a clone URL or GitHub `owner/name` (required) | +| `-branch` | `tfpfgen/generate-` | branch to push; the digest is derived from the manifest, so the same content always names the same branch | +| `-base` | the repository's default branch | branch to diff and open the pull request against | +| `-dry-run` | `false` | clone and compare, but push nothing and open nothing | + +The token comes from `TFPFGEN_GITHUB_TOKEN` (or `GITHUB_TOKEN`, which is what +Actions injects), never from a flag — the same doctrine as the probe +credential. It reaches git through the environment, not the command line, so it +never appears in the process table. + +Push refuses a tree that carries no `.tfpfgen/manifest.json`: it publishes +generated output with stated provenance, not arbitrary trees. The target is +shallow-cloned, the generated tree is synced over it, and files the target's +*previous* manifest owned that are no longer produced are pruned — the same +ownership rule the drift check enforces, so the target repository's own files +(its release workflows, its licence) are never touched. No difference means +exit `0` and nothing pushed. + +A real difference is committed to the generator-owned `tfpfgen/generate-*` +branch namespace and force-pushed — the content is a pure function of the +blueprints, so the newest generation is always what the branch should hold — +and a pull request is opened against the base branch with the tool version, +blueprint sources and change count in its body. A branch whose pull request is +already open is updated rather than duplicated. A remote that is not a GitHub +host gets the branch push and a note instead of a pull request. + ### `provider scaffold` ```