feat(ci): serve the owner's git URLs and the REST subset the actions call
Git over HTTP also serves /<owner>/<repo>[.git]/... for ERMITE_OWNER, so
actions/checkout can clone github.repository without the web redirect; other
owners are 404. Authorization accepts the "token" scheme next to Basic (any
user, x-access-token) and Bearer.
/-/github/api serves GET /repos/{owner}/{repo}, /hash-algorithm and
/tarball[/{ref}] (git archive, gzip, one top-level directory, streamed with
200 instead of GitHub's 302 to codeload) with the forge's own access rules;
everything else is GitHub's 404 JSON. The job repository token and the archive
tokens are wired into Auth.- commit
- 5b66afc2db2d0b78021c13b448e92e5f7ba321fe
- author
- brice · · 2026-10-13 10:41 UTC
- parent
- 8d1000a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +1549 -21
M .hexcheck.yaml +3 -0
| @@ -73,6 +73,9 @@ allow: | |||
| 73 | 73 | - rule: no-adapter-to-adapter-imports | |
| 74 | 74 | path: internal/adapters/githttp/** | |
| 75 | 75 | reason: rate-limits Git clients with internal/clientlimit | |
| 76 | + | - rule: no-adapter-to-adapter-imports | |
| 77 | + | path: internal/adapters/githubapi/** | |
| 78 | + | reason: rate-limits the clients of the REST subset with internal/clientlimit | |
| 76 | 79 | - rule: no-adapter-to-adapter-imports | |
| 77 | 80 | path: internal/adapters/rpc/** | |
| 78 | 81 | reason: rate-limits API clients with internal/clientlimit | |
| @@ -73,6 +73,9 @@ allow: | |||||
| 73 | - rule: no-adapter-to-adapter-imports | 73 | - rule: no-adapter-to-adapter-imports | ||
| 74 | path: internal/adapters/githttp/** | 74 | path: internal/adapters/githttp/** | ||
| 75 | reason: rate-limits Git clients with internal/clientlimit | 75 | reason: rate-limits Git clients with internal/clientlimit | ||
| 76 | + | - rule: no-adapter-to-adapter-imports | |||
| 77 | + | path: internal/adapters/githubapi/** | |||
| 78 | + | reason: rate-limits the clients of the REST subset with internal/clientlimit | |||
| 76 | - rule: no-adapter-to-adapter-imports | 79 | - rule: no-adapter-to-adapter-imports | ||
| 77 | path: internal/adapters/rpc/** | 80 | path: internal/adapters/rpc/** | ||
| 78 | reason: rate-limits API clients with internal/clientlimit | 81 | reason: rate-limits API clients with internal/clientlimit | ||
A internal/adapters/gitcli/archive.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "os/exec" | |
| 13 | + | "strings" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // archiveTimeout bounds one archive when ctx has no deadline: a large | |
| 21 | + | // repository takes minutes, a stalled client must not hold a process forever. | |
| 22 | + | const archiveTimeout = 15 * time.Minute | |
| 23 | + | ||
| 24 | + | var _ ports.GitArchive = (*Git)(nil) | |
| 25 | + | ||
| 26 | + | // Archive streams the tree of the commit as a gzip-compressed tar, every path | |
| 27 | + | // below prefix ("<prefix>/..."), with `git archive`. The tar is compressed here, | |
| 28 | + | // not by a gzip binary. A client that goes away ends ctx, which kills git. | |
| 29 | + | // Nothing is written to w when the commit does not exist | |
| 30 | + | // (domain.ErrNotFound). | |
| 31 | + | // | |
| 32 | + | //hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's | |
| 33 | + | func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error { | |
| 34 | + | dir, err := g.repoDir(repo) | |
| 35 | + | if err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | if _, ok := ctx.Deadline(); !ok { | |
| 39 | + | var cancel context.CancelFunc | |
| 40 | + | ctx, cancel = context.WithTimeout(ctx, archiveTimeout) | |
| 41 | + | defer cancel() | |
| 42 | + | } | |
| 43 | + | release, err := g.acquireExec(ctx) | |
| 44 | + | if err != nil { | |
| 45 | + | return fmt.Errorf("git archive: %w", err) | |
| 46 | + | } | |
| 47 | + | defer release() | |
| 48 | + | ||
| 49 | + | cmd := exec.CommandContext(ctx, g.bin, readArgs(dir, "archive", "--format=tar", "--prefix="+prefix+"/", commit.String(), "--")...) | |
| 50 | + | cmd.Env = g.env | |
| 51 | + | cmd.Dir = g.dataDir | |
| 52 | + | cmd.WaitDelay = 5 * time.Second | |
| 53 | + | stderr := &tailBuffer{max: 4096} | |
| 54 | + | cmd.Stderr = stderr | |
| 55 | + | stdout, err := cmd.StdoutPipe() | |
| 56 | + | if err != nil { | |
| 57 | + | return fmt.Errorf("git archive: %w", err) | |
| 58 | + | } | |
| 59 | + | if err := cmd.Start(); err != nil { | |
| 60 | + | return fmt.Errorf("git archive: %w", err) | |
| 61 | + | } | |
| 62 | + | // The first byte decides whether git found the commit: wait for it before | |
| 63 | + | // writing to w, so that a missing commit can still be answered. | |
| 64 | + | first := make([]byte, 1) | |
| 65 | + | if n, _ := io.ReadFull(stdout, first); n == 0 { | |
| 66 | + | werr := cmd.Wait() | |
| 67 | + | switch { | |
| 68 | + | case ctx.Err() != nil: | |
| 69 | + | return ctx.Err() | |
| 70 | + | case werr != nil: | |
| 71 | + | return fmt.Errorf("git archive %s %s: %w: %s", repo, commit, domain.ErrNotFound, strings.TrimSpace(stderr.String())) | |
| 72 | + | } | |
| 73 | + | return errors.New("git archive: empty output") | |
| 74 | + | } | |
| 75 | + | zw := gzip.NewWriter(w) | |
| 76 | + | _, cerr := io.Copy(zw, io.MultiReader(bytes.NewReader(first), stdout)) | |
| 77 | + | if cerr == nil { | |
| 78 | + | cerr = zw.Close() | |
| 79 | + | } | |
| 80 | + | if cerr != nil { | |
| 81 | + | _ = cmd.Process.Kill() | |
| 82 | + | } | |
| 83 | + | werr := cmd.Wait() | |
| 84 | + | switch { | |
| 85 | + | case ctx.Err() != nil: | |
| 86 | + | return ctx.Err() | |
| 87 | + | case cerr != nil: | |
| 88 | + | return fmt.Errorf("git archive %s: %w", repo, cerr) | |
| 89 | + | case werr != nil: | |
| 90 | + | return fmt.Errorf("git archive %s: %w: %s", repo, werr, strings.TrimSpace(stderr.String())) | |
| 91 | + | } | |
| 92 | + | return nil | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package gitcli | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "compress/gzip" | |||
| 8 | + | "context" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "os/exec" | |||
| 13 | + | "strings" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | "bnema.dev/ermite/internal/ports" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // archiveTimeout bounds one archive when ctx has no deadline: a large | |||
| 21 | + | // repository takes minutes, a stalled client must not hold a process forever. | |||
| 22 | + | const archiveTimeout = 15 * time.Minute | |||
| 23 | + | ||||
| 24 | + | var _ ports.GitArchive = (*Git)(nil) | |||
| 25 | + | ||||
| 26 | + | // Archive streams the tree of the commit as a gzip-compressed tar, every path | |||
| 27 | + | // below prefix ("<prefix>/..."), with `git archive`. The tar is compressed here, | |||
| 28 | + | // not by a gzip binary. A client that goes away ends ctx, which kills git. | |||
| 29 | + | // Nothing is written to w when the commit does not exist | |||
| 30 | + | // (domain.ErrNotFound). | |||
| 31 | + | // | |||
| 32 | + | //hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's | |||
| 33 | + | func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error { | |||
| 34 | + | dir, err := g.repoDir(repo) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return err | |||
| 37 | + | } | |||
| 38 | + | if _, ok := ctx.Deadline(); !ok { | |||
| 39 | + | var cancel context.CancelFunc | |||
| 40 | + | ctx, cancel = context.WithTimeout(ctx, archiveTimeout) | |||
| 41 | + | defer cancel() | |||
| 42 | + | } | |||
| 43 | + | release, err := g.acquireExec(ctx) | |||
| 44 | + | if err != nil { | |||
| 45 | + | return fmt.Errorf("git archive: %w", err) | |||
| 46 | + | } | |||
| 47 | + | defer release() | |||
| 48 | + | ||||
| 49 | + | cmd := exec.CommandContext(ctx, g.bin, readArgs(dir, "archive", "--format=tar", "--prefix="+prefix+"/", commit.String(), "--")...) | |||
| 50 | + | cmd.Env = g.env | |||
| 51 | + | cmd.Dir = g.dataDir | |||
| 52 | + | cmd.WaitDelay = 5 * time.Second | |||
| 53 | + | stderr := &tailBuffer{max: 4096} | |||
| 54 | + | cmd.Stderr = stderr | |||
| 55 | + | stdout, err := cmd.StdoutPipe() | |||
| 56 | + | if err != nil { | |||
| 57 | + | return fmt.Errorf("git archive: %w", err) | |||
| 58 | + | } | |||
| 59 | + | if err := cmd.Start(); err != nil { | |||
| 60 | + | return fmt.Errorf("git archive: %w", err) | |||
| 61 | + | } | |||
| 62 | + | // The first byte decides whether git found the commit: wait for it before | |||
| 63 | + | // writing to w, so that a missing commit can still be answered. | |||
| 64 | + | first := make([]byte, 1) | |||
| 65 | + | if n, _ := io.ReadFull(stdout, first); n == 0 { | |||
| 66 | + | werr := cmd.Wait() | |||
| 67 | + | switch { | |||
| 68 | + | case ctx.Err() != nil: | |||
| 69 | + | return ctx.Err() | |||
| 70 | + | case werr != nil: | |||
| 71 | + | return fmt.Errorf("git archive %s %s: %w: %s", repo, commit, domain.ErrNotFound, strings.TrimSpace(stderr.String())) | |||
| 72 | + | } | |||
| 73 | + | return errors.New("git archive: empty output") | |||
| 74 | + | } | |||
| 75 | + | zw := gzip.NewWriter(w) | |||
| 76 | + | _, cerr := io.Copy(zw, io.MultiReader(bytes.NewReader(first), stdout)) | |||
| 77 | + | if cerr == nil { | |||
| 78 | + | cerr = zw.Close() | |||
| 79 | + | } | |||
| 80 | + | if cerr != nil { | |||
| 81 | + | _ = cmd.Process.Kill() | |||
| 82 | + | } | |||
| 83 | + | werr := cmd.Wait() | |||
| 84 | + | switch { | |||
| 85 | + | case ctx.Err() != nil: | |||
| 86 | + | return ctx.Err() | |||
| 87 | + | case cerr != nil: | |||
| 88 | + | return fmt.Errorf("git archive %s: %w", repo, cerr) | |||
| 89 | + | case werr != nil: | |||
| 90 | + | return fmt.Errorf("git archive %s: %w: %s", repo, werr, strings.TrimSpace(stderr.String())) | |||
| 91 | + | } | |||
| 92 | + | return nil | |||
| 93 | + | } | |||
A internal/adapters/gitcli/archive_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "archive/tar" | |
| 7 | + | "bytes" | |
| 8 | + | "compress/gzip" | |
| 9 | + | "context" | |
| 10 | + | "io" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestArchiveWritesAGzipTarUnderThePrefix(t *testing.T) { | |
| 21 | + | g, data := newGit(t) | |
| 22 | + | ctx := context.Background() | |
| 23 | + | m := newMergeFixture(t, data) | |
| 24 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 25 | + | ||
| 26 | + | var buf bytes.Buffer | |
| 27 | + | require.NoError(t, g.Archive(ctx, m.f.Name, m.d, "owner-proj-abcdef0", &buf)) | |
| 28 | + | zr, err := gzip.NewReader(&buf) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | tr := tar.NewReader(zr) | |
| 31 | + | files := map[string]string{} | |
| 32 | + | for { | |
| 33 | + | h, err := tr.Next() | |
| 34 | + | if err == io.EOF { | |
| 35 | + | break | |
| 36 | + | } | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | if h.Typeflag == tar.TypeXGlobalHeader { | |
| 39 | + | // git archive records the commit id here, as GitHub's tarballs do. | |
| 40 | + | assert.Equal(t, m.d.String(), h.PAXRecords["comment"]) | |
| 41 | + | continue | |
| 42 | + | } | |
| 43 | + | require.True(t, strings.HasPrefix(h.Name, "owner-proj-abcdef0/"), h.Name) | |
| 44 | + | if h.Typeflag == tar.TypeReg { | |
| 45 | + | b, _ := io.ReadAll(tr) | |
| 46 | + | files[h.Name] = string(b) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | assert.Equal(t, "one\n2\nthree\n", files["owner-proj-abcdef0/a.txt"], "the tree of the commit asked, not of HEAD") | |
| 50 | + | assert.Equal(t, "see\n", files["owner-proj-abcdef0/c.txt"]) | |
| 51 | + | assert.NotContains(t, files, "owner-proj-abcdef0/.git") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestArchiveOfAMissingCommitWritesNothing(t *testing.T) { | |
| 55 | + | g, data := newGit(t) | |
| 56 | + | ctx := context.Background() | |
| 57 | + | m := newMergeFixture(t, data) | |
| 58 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 59 | + | ||
| 60 | + | var buf bytes.Buffer | |
| 61 | + | missing, err := domain.ParseObjectID(strings.Repeat("1", 40)) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | err = g.Archive(ctx, m.f.Name, missing, "p", &buf) | |
| 64 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 65 | + | assert.Zero(t, buf.Len(), "nothing was written, so the caller can still answer 404") | |
| 66 | + | ||
| 67 | + | err = g.Archive(ctx, name(t, "nope"), m.d, "p", &buf) | |
| 68 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestArchiveStopsWithItsContext(t *testing.T) { | |
| 72 | + | g, data := newGit(t) | |
| 73 | + | m := newMergeFixture(t, data) | |
| 74 | + | t.Cleanup(func() { _ = g.Close(context.Background()) }) | |
| 75 | + | ||
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 77 | + | cancel() | |
| 78 | + | err := g.Archive(ctx, m.f.Name, m.d, "p", io.Discard) | |
| 79 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package gitcli_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "archive/tar" | |||
| 7 | + | "bytes" | |||
| 8 | + | "compress/gzip" | |||
| 9 | + | "context" | |||
| 10 | + | "io" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestArchiveWritesAGzipTarUnderThePrefix(t *testing.T) { | |||
| 21 | + | g, data := newGit(t) | |||
| 22 | + | ctx := context.Background() | |||
| 23 | + | m := newMergeFixture(t, data) | |||
| 24 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |||
| 25 | + | ||||
| 26 | + | var buf bytes.Buffer | |||
| 27 | + | require.NoError(t, g.Archive(ctx, m.f.Name, m.d, "owner-proj-abcdef0", &buf)) | |||
| 28 | + | zr, err := gzip.NewReader(&buf) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | tr := tar.NewReader(zr) | |||
| 31 | + | files := map[string]string{} | |||
| 32 | + | for { | |||
| 33 | + | h, err := tr.Next() | |||
| 34 | + | if err == io.EOF { | |||
| 35 | + | break | |||
| 36 | + | } | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | if h.Typeflag == tar.TypeXGlobalHeader { | |||
| 39 | + | // git archive records the commit id here, as GitHub's tarballs do. | |||
| 40 | + | assert.Equal(t, m.d.String(), h.PAXRecords["comment"]) | |||
| 41 | + | continue | |||
| 42 | + | } | |||
| 43 | + | require.True(t, strings.HasPrefix(h.Name, "owner-proj-abcdef0/"), h.Name) | |||
| 44 | + | if h.Typeflag == tar.TypeReg { | |||
| 45 | + | b, _ := io.ReadAll(tr) | |||
| 46 | + | files[h.Name] = string(b) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | assert.Equal(t, "one\n2\nthree\n", files["owner-proj-abcdef0/a.txt"], "the tree of the commit asked, not of HEAD") | |||
| 50 | + | assert.Equal(t, "see\n", files["owner-proj-abcdef0/c.txt"]) | |||
| 51 | + | assert.NotContains(t, files, "owner-proj-abcdef0/.git") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestArchiveOfAMissingCommitWritesNothing(t *testing.T) { | |||
| 55 | + | g, data := newGit(t) | |||
| 56 | + | ctx := context.Background() | |||
| 57 | + | m := newMergeFixture(t, data) | |||
| 58 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |||
| 59 | + | ||||
| 60 | + | var buf bytes.Buffer | |||
| 61 | + | missing, err := domain.ParseObjectID(strings.Repeat("1", 40)) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | err = g.Archive(ctx, m.f.Name, missing, "p", &buf) | |||
| 64 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 65 | + | assert.Zero(t, buf.Len(), "nothing was written, so the caller can still answer 404") | |||
| 66 | + | ||||
| 67 | + | err = g.Archive(ctx, name(t, "nope"), m.d, "p", &buf) | |||
| 68 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestArchiveStopsWithItsContext(t *testing.T) { | |||
| 72 | + | g, data := newGit(t) | |||
| 73 | + | m := newMergeFixture(t, data) | |||
| 74 | + | t.Cleanup(func() { _ = g.Close(context.Background()) }) | |||
| 75 | + | ||||
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 77 | + | cancel() | |||
| 78 | + | err := g.Archive(ctx, m.f.Name, m.d, "p", io.Discard) | |||
| 79 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 80 | + | } | |||
M internal/adapters/githttp/handler.go +49 -10
| @@ -38,6 +38,21 @@ var Patterns = []string{ | |||
| 38 | 38 | "POST /{repo}/git-receive-pack", | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | // OwnerPatterns are the patterns of Patterns under /<owner>/, where owner is | |
| 42 | + | // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones | |
| 43 | + | // <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal | |
| 44 | + | // segment so that the patterns never overlap the /-/ routes, and other owners | |
| 45 | + | // fall through to the web, which answers 404. Register them only for a valid | |
| 46 | + | // owner; Config.Owner must be the same. | |
| 47 | + | func OwnerPatterns(owner string) []string { | |
| 48 | + | out := make([]string, len(Patterns)) | |
| 49 | + | for i, p := range Patterns { | |
| 50 | + | method, path, _ := strings.Cut(p, " ") | |
| 51 | + | out[i] = method + " /" + owner + path | |
| 52 | + | } | |
| 53 | + | return out | |
| 54 | + | } | |
| 55 | + | ||
| 41 | 56 | // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes | |
| 42 | 57 | // is zero. | |
| 43 | 58 | const DefaultMaxBodyBytes int64 = 4 << 30 | |
| @@ -67,6 +82,9 @@ type Config struct { | |||
| 67 | 82 | Repos ports.RepoLookup | |
| 68 | 83 | Pushes ports.PushService | |
| 69 | 84 | Backend ports.GitBackend | |
| 85 | + | // Owner is ERMITE_OWNER: /<owner>/<repo>/... is served like /<repo>/... | |
| 86 | + | // (see OwnerPatterns). Empty serves the second form nowhere. | |
| 87 | + | Owner string | |
| 70 | 88 | // Hooks provides hook tokens for pushes. It is required. | |
| 71 | 89 | Hooks ports.HookTokens | |
| 72 | 90 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| @@ -146,12 +164,34 @@ type route struct { | |||
| 146 | 164 | ||
| 147 | 165 | func (rt route) push() bool { return rt.service == serviceReceive } | |
| 148 | 166 | ||
| 167 | + | // gitSuffixes are the ends of the paths the handler serves, after the | |
| 168 | + | // repository. | |
| 169 | + | var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceReceive} | |
| 170 | + | ||
| 149 | 171 | // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack | |
| 150 | - | // and /<repo>/git-receive-pack. info/refs without a service is the dumb | |
| 151 | - | // protocol, which ermite does not serve. | |
| 152 | - | func parseRoute(r *http.Request) (route, int) { | |
| 153 | - | seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") | |
| 154 | - | if !ok || seg == "" { | |
| 172 | + | // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of | |
| 173 | + | // the forge. info/refs without a service is the dumb protocol, which ermite | |
| 174 | + | // does not serve. | |
| 175 | + | func (h *Handler) parseRoute(r *http.Request) (route, int) { | |
| 176 | + | p := strings.TrimPrefix(r.URL.Path, "/") | |
| 177 | + | var head, rest string | |
| 178 | + | for _, suffix := range gitSuffixes { | |
| 179 | + | if h, ok := strings.CutSuffix(p, suffix); ok { | |
| 180 | + | head, rest = h, suffix[1:] | |
| 181 | + | break | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if rest == "" { | |
| 185 | + | return route{}, http.StatusNotFound | |
| 186 | + | } | |
| 187 | + | seg := head | |
| 188 | + | if owner, repo, ok := strings.Cut(head, "/"); ok { | |
| 189 | + | if h.cfg.Owner == "" || owner != h.cfg.Owner { | |
| 190 | + | return route{}, http.StatusNotFound | |
| 191 | + | } | |
| 192 | + | seg = repo | |
| 193 | + | } | |
| 194 | + | if seg == "" || strings.Contains(seg, "/") { | |
| 155 | 195 | return route{}, http.StatusNotFound | |
| 156 | 196 | } | |
| 157 | 197 | rt := route{repo: seg, suffix: rest} | |
| @@ -175,8 +215,6 @@ func parseRoute(r *http.Request) (route, int) { | |||
| 175 | 215 | return route{}, http.StatusMethodNotAllowed | |
| 176 | 216 | } | |
| 177 | 217 | rt.service = rest | |
| 178 | - | default: | |
| 179 | - | return route{}, http.StatusNotFound | |
| 180 | 218 | } | |
| 181 | 219 | return rt, 0 | |
| 182 | 220 | } | |
| @@ -198,7 +236,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 198 | 236 | Msg("git request") | |
| 199 | 237 | }() | |
| 200 | 238 | ||
| 201 | - | rt, status := parseRoute(r) | |
| 239 | + | rt, status := h.parseRoute(r) | |
| 202 | 240 | if status != 0 { | |
| 203 | 241 | http.Error(w, http.StatusText(status), status) | |
| 204 | 242 | return | |
| @@ -265,7 +303,8 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, | |||
| 265 | 303 | } | |
| 266 | 304 | ||
| 267 | 305 | // credentials extracts the token from an Authorization header: the password of | |
| 268 | - | // Basic (the username is ignored) or a Bearer token. present is true when the | |
| 306 | + | // Basic (the username is ignored, actions/checkout sends x-access-token), or | |
| 307 | + | // the value of "Bearer" or "token". present is true when the | |
| 269 | 308 | // header exists, even if it carries nothing usable, which then fails | |
| 270 | 309 | // authentication. | |
| 271 | 310 | func credentials(r *http.Request) (secret string, present bool) { | |
| @@ -276,7 +315,7 @@ func credentials(r *http.Request) (secret string, present bool) { | |||
| 276 | 315 | if _, pass, ok := r.BasicAuth(); ok { | |
| 277 | 316 | return pass, true | |
| 278 | 317 | } | |
| 279 | - | if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") { | |
| 318 | + | if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) { | |
| 280 | 319 | return strings.TrimSpace(tok), true | |
| 281 | 320 | } | |
| 282 | 321 | return "", true | |
| @@ -38,6 +38,21 @@ var Patterns = []string{ | |||||
| 38 | "POST /{repo}/git-receive-pack", | 38 | "POST /{repo}/git-receive-pack", | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | // OwnerPatterns are the patterns of Patterns under /<owner>/, where owner is | |||
| 42 | + | // ERMITE_OWNER: the URL github.repository gives (actions/checkout clones | |||
| 43 | + | // <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal | |||
| 44 | + | // segment so that the patterns never overlap the /-/ routes, and other owners | |||
| 45 | + | // fall through to the web, which answers 404. Register them only for a valid | |||
| 46 | + | // owner; Config.Owner must be the same. | |||
| 47 | + | func OwnerPatterns(owner string) []string { | |||
| 48 | + | out := make([]string, len(Patterns)) | |||
| 49 | + | for i, p := range Patterns { | |||
| 50 | + | method, path, _ := strings.Cut(p, " ") | |||
| 51 | + | out[i] = method + " /" + owner + path | |||
| 52 | + | } | |||
| 53 | + | return out | |||
| 54 | + | } | |||
| 55 | + | ||||
| 41 | // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes | 56 | // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes | ||
| 42 | // is zero. | 57 | // is zero. | ||
| 43 | const DefaultMaxBodyBytes int64 = 4 << 30 | 58 | const DefaultMaxBodyBytes int64 = 4 << 30 | ||
| @@ -67,6 +82,9 @@ type Config struct { | |||||
| 67 | Repos ports.RepoLookup | 82 | Repos ports.RepoLookup | ||
| 68 | Pushes ports.PushService | 83 | Pushes ports.PushService | ||
| 69 | Backend ports.GitBackend | 84 | Backend ports.GitBackend | ||
| 85 | + | // Owner is ERMITE_OWNER: /<owner>/<repo>/... is served like /<repo>/... | |||
| 86 | + | // (see OwnerPatterns). Empty serves the second form nowhere. | |||
| 87 | + | Owner string | |||
| 70 | // Hooks provides hook tokens for pushes. It is required. | 88 | // Hooks provides hook tokens for pushes. It is required. | ||
| 71 | Hooks ports.HookTokens | 89 | Hooks ports.HookTokens | ||
| 72 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | 90 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | ||
| @@ -146,12 +164,34 @@ type route struct { | |||||
| 146 | 164 | ||||
| 147 | func (rt route) push() bool { return rt.service == serviceReceive } | 165 | func (rt route) push() bool { return rt.service == serviceReceive } | ||
| 148 | 166 | ||||
| 167 | + | // gitSuffixes are the ends of the paths the handler serves, after the | |||
| 168 | + | // repository. | |||
| 169 | + | var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceReceive} | |||
| 170 | + | ||||
| 149 | // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack | 171 | // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack | ||
| 150 | - | // and /<repo>/git-receive-pack. info/refs without a service is the dumb | 172 | + | // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of |
| 151 | - | // protocol, which ermite does not serve. | 173 | + | // the forge. info/refs without a service is the dumb protocol, which ermite |
| 152 | - | func parseRoute(r *http.Request) (route, int) { | 174 | + | // does not serve. |
| 153 | - | seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") | 175 | + | func (h *Handler) parseRoute(r *http.Request) (route, int) { |
| 154 | - | if !ok || seg == "" { | 176 | + | p := strings.TrimPrefix(r.URL.Path, "/") |
| 177 | + | var head, rest string | |||
| 178 | + | for _, suffix := range gitSuffixes { | |||
| 179 | + | if h, ok := strings.CutSuffix(p, suffix); ok { | |||
| 180 | + | head, rest = h, suffix[1:] | |||
| 181 | + | break | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | if rest == "" { | |||
| 185 | + | return route{}, http.StatusNotFound | |||
| 186 | + | } | |||
| 187 | + | seg := head | |||
| 188 | + | if owner, repo, ok := strings.Cut(head, "/"); ok { | |||
| 189 | + | if h.cfg.Owner == "" || owner != h.cfg.Owner { | |||
| 190 | + | return route{}, http.StatusNotFound | |||
| 191 | + | } | |||
| 192 | + | seg = repo | |||
| 193 | + | } | |||
| 194 | + | if seg == "" || strings.Contains(seg, "/") { | |||
| 155 | return route{}, http.StatusNotFound | 195 | return route{}, http.StatusNotFound | ||
| 156 | } | 196 | } | ||
| 157 | rt := route{repo: seg, suffix: rest} | 197 | rt := route{repo: seg, suffix: rest} | ||
| @@ -175,8 +215,6 @@ func parseRoute(r *http.Request) (route, int) { | |||||
| 175 | return route{}, http.StatusMethodNotAllowed | 215 | return route{}, http.StatusMethodNotAllowed | ||
| 176 | } | 216 | } | ||
| 177 | rt.service = rest | 217 | rt.service = rest | ||
| 178 | - | default: | |||
| 179 | - | return route{}, http.StatusNotFound | |||
| 180 | } | 218 | } | ||
| 181 | return rt, 0 | 219 | return rt, 0 | ||
| 182 | } | 220 | } | ||
| @@ -198,7 +236,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 198 | Msg("git request") | 236 | Msg("git request") | ||
| 199 | }() | 237 | }() | ||
| 200 | 238 | ||||
| 201 | - | rt, status := parseRoute(r) | 239 | + | rt, status := h.parseRoute(r) |
| 202 | if status != 0 { | 240 | if status != 0 { | ||
| 203 | http.Error(w, http.StatusText(status), status) | 241 | http.Error(w, http.StatusText(status), status) | ||
| 204 | return | 242 | return | ||
| @@ -265,7 +303,8 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, | |||||
| 265 | } | 303 | } | ||
| 266 | 304 | ||||
| 267 | // credentials extracts the token from an Authorization header: the password of | 305 | // credentials extracts the token from an Authorization header: the password of | ||
| 268 | - | // Basic (the username is ignored) or a Bearer token. present is true when the | 306 | + | // Basic (the username is ignored, actions/checkout sends x-access-token), or |
| 307 | + | // the value of "Bearer" or "token". present is true when the | |||
| 269 | // header exists, even if it carries nothing usable, which then fails | 308 | // header exists, even if it carries nothing usable, which then fails | ||
| 270 | // authentication. | 309 | // authentication. | ||
| 271 | func credentials(r *http.Request) (secret string, present bool) { | 310 | func credentials(r *http.Request) (secret string, present bool) { | ||
| @@ -276,7 +315,7 @@ func credentials(r *http.Request) (secret string, present bool) { | |||||
| 276 | if _, pass, ok := r.BasicAuth(); ok { | 315 | if _, pass, ok := r.BasicAuth(); ok { | ||
| 277 | return pass, true | 316 | return pass, true | ||
| 278 | } | 317 | } | ||
| 279 | - | if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") { | 318 | + | if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) { |
| 280 | return strings.TrimSpace(tok), true | 319 | return strings.TrimSpace(tok), true | ||
| 281 | } | 320 | } | ||
| 282 | return "", true | 321 | return "", true | ||
M internal/adapters/githttp/handler_test.go +52 -2
| @@ -172,7 +172,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |||
| 172 | 172 | repos := mocks.NewRepoLookup(t) | |
| 173 | 173 | r.stubUseCases(repos) | |
| 174 | 174 | cfg := githttp.Config{ | |
| 175 | - | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | |
| 175 | + | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, Owner: "acme", | |
| 176 | 176 | } | |
| 177 | 177 | script := cgiScript(t) | |
| 178 | 178 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | |
| @@ -188,7 +188,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |||
| 188 | 188 | } | |
| 189 | 189 | r.h = githttp.New(cfg) | |
| 190 | 190 | mux := http.NewServeMux() | |
| 191 | - | for _, p := range githttp.Patterns { | |
| 191 | + | for _, p := range append(githttp.Patterns, githttp.OwnerPatterns("acme")...) { | |
| 192 | 192 | mux.Handle(p, r.h) | |
| 193 | 193 | } | |
| 194 | 194 | r.srv = httptest.NewServer(mux) | |
| @@ -506,6 +506,54 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |||
| 506 | 506 | assert.Equal(t, 200, code) | |
| 507 | 507 | } | |
| 508 | 508 | ||
| 509 | + | // The clone URL of GitHub-shaped clients, /<owner>/<repo>, with or without .git: | |
| 510 | + | // served for the owner of the forge, 404 for anyone else and for what is not a | |
| 511 | + | // Git path. | |
| 512 | + | func TestHandlerServesTheOwnerForm(t *testing.T) { | |
| 513 | + | t.Parallel() | |
| 514 | + | tests := []struct { | |
| 515 | + | name, method, path, pass string | |
| 516 | + | want int | |
| 517 | + | }{ | |
| 518 | + | {"owner, no suffix", "GET", "/acme/pub/info/refs?service=git-upload-pack", "", 200}, | |
| 519 | + | {"owner, .git", "GET", "/acme/pub.git/info/refs?service=git-upload-pack", "", 200}, | |
| 520 | + | {"owner, private, reader", "GET", "/acme/priv/info/refs?service=git-upload-pack", "reader-secret", 200}, | |
| 521 | + | {"owner, private, anonymous", "GET", "/acme/priv/info/refs?service=git-upload-pack", "", 401}, | |
| 522 | + | {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200}, | |
| 523 | + | {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200}, | |
| 524 | + | {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 525 | + | {"owner case is exact", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 526 | + | {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 527 | + | {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 528 | + | {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 529 | + | {"invalid repository", "GET", "/acme/Bad/info/refs?service=git-upload-pack", "root-secret", 404}, | |
| 530 | + | {"owner, wrong method", "POST", "/acme/pub/info/refs?service=git-upload-pack", "", 405}, | |
| 531 | + | {"owner, not a git path", "GET", "/acme/pub/HEAD", "", 404}, | |
| 532 | + | } | |
| 533 | + | for _, tt := range tests { | |
| 534 | + | t.Run(tt.name, func(t *testing.T) { | |
| 535 | + | t.Parallel() | |
| 536 | + | r := newRig(t, nil) | |
| 537 | + | user := "" | |
| 538 | + | if tt.pass != "" { | |
| 539 | + | user = "x-access-token" | |
| 540 | + | } | |
| 541 | + | code, _, _ := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil) | |
| 542 | + | assert.Equal(t, tt.want, code) | |
| 543 | + | }) | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // Without an owner configured only the plain form is served. | |
| 548 | + | func TestHandlerWithoutOwnerServesThePlainFormOnly(t *testing.T) { | |
| 549 | + | t.Parallel() | |
| 550 | + | r := newRig(t, func(c *githttp.Config) { c.Owner = "" }) | |
| 551 | + | code, _, _ := r.do(t, "GET", "/acme/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | |
| 552 | + | assert.Equal(t, 404, code) | |
| 553 | + | code, _, _ = r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | |
| 554 | + | assert.Equal(t, 200, code) | |
| 555 | + | } | |
| 556 | + | ||
| 509 | 557 | func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | |
| 510 | 558 | t.Parallel() | |
| 511 | 559 | r := newRig(t, nil) | |
| @@ -514,6 +562,8 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | |||
| 514 | 562 | assert.Equal(t, 200, code) | |
| 515 | 563 | code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"}) | |
| 516 | 564 | assert.Equal(t, 200, code, "the scheme is case-insensitive") | |
| 565 | + | code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "token reader-secret"}) | |
| 566 | + | assert.Equal(t, 200, code, "GitHub's own scheme") | |
| 517 | 567 | for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} { | |
| 518 | 568 | code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h}) | |
| 519 | 569 | assert.Equal(t, 401, code, h) | |
| @@ -172,7 +172,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |||||
| 172 | repos := mocks.NewRepoLookup(t) | 172 | repos := mocks.NewRepoLookup(t) | ||
| 173 | r.stubUseCases(repos) | 173 | r.stubUseCases(repos) | ||
| 174 | cfg := githttp.Config{ | 174 | cfg := githttp.Config{ | ||
| 175 | - | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | 175 | + | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, Owner: "acme", |
| 176 | } | 176 | } | ||
| 177 | script := cgiScript(t) | 177 | script := cgiScript(t) | ||
| 178 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | 178 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | ||
| @@ -188,7 +188,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |||||
| 188 | } | 188 | } | ||
| 189 | r.h = githttp.New(cfg) | 189 | r.h = githttp.New(cfg) | ||
| 190 | mux := http.NewServeMux() | 190 | mux := http.NewServeMux() | ||
| 191 | - | for _, p := range githttp.Patterns { | 191 | + | for _, p := range append(githttp.Patterns, githttp.OwnerPatterns("acme")...) { |
| 192 | mux.Handle(p, r.h) | 192 | mux.Handle(p, r.h) | ||
| 193 | } | 193 | } | ||
| 194 | r.srv = httptest.NewServer(mux) | 194 | r.srv = httptest.NewServer(mux) | ||
| @@ -506,6 +506,54 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |||||
| 506 | assert.Equal(t, 200, code) | 506 | assert.Equal(t, 200, code) | ||
| 507 | } | 507 | } | ||
| 508 | 508 | ||||
| 509 | + | // The clone URL of GitHub-shaped clients, /<owner>/<repo>, with or without .git: | |||
| 510 | + | // served for the owner of the forge, 404 for anyone else and for what is not a | |||
| 511 | + | // Git path. | |||
| 512 | + | func TestHandlerServesTheOwnerForm(t *testing.T) { | |||
| 513 | + | t.Parallel() | |||
| 514 | + | tests := []struct { | |||
| 515 | + | name, method, path, pass string | |||
| 516 | + | want int | |||
| 517 | + | }{ | |||
| 518 | + | {"owner, no suffix", "GET", "/acme/pub/info/refs?service=git-upload-pack", "", 200}, | |||
| 519 | + | {"owner, .git", "GET", "/acme/pub.git/info/refs?service=git-upload-pack", "", 200}, | |||
| 520 | + | {"owner, private, reader", "GET", "/acme/priv/info/refs?service=git-upload-pack", "reader-secret", 200}, | |||
| 521 | + | {"owner, private, anonymous", "GET", "/acme/priv/info/refs?service=git-upload-pack", "", 401}, | |||
| 522 | + | {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200}, | |||
| 523 | + | {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200}, | |||
| 524 | + | {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | |||
| 525 | + | {"owner case is exact", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 404}, | |||
| 526 | + | {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404}, | |||
| 527 | + | {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404}, | |||
| 528 | + | {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404}, | |||
| 529 | + | {"invalid repository", "GET", "/acme/Bad/info/refs?service=git-upload-pack", "root-secret", 404}, | |||
| 530 | + | {"owner, wrong method", "POST", "/acme/pub/info/refs?service=git-upload-pack", "", 405}, | |||
| 531 | + | {"owner, not a git path", "GET", "/acme/pub/HEAD", "", 404}, | |||
| 532 | + | } | |||
| 533 | + | for _, tt := range tests { | |||
| 534 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 535 | + | t.Parallel() | |||
| 536 | + | r := newRig(t, nil) | |||
| 537 | + | user := "" | |||
| 538 | + | if tt.pass != "" { | |||
| 539 | + | user = "x-access-token" | |||
| 540 | + | } | |||
| 541 | + | code, _, _ := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil) | |||
| 542 | + | assert.Equal(t, tt.want, code) | |||
| 543 | + | }) | |||
| 544 | + | } | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | // Without an owner configured only the plain form is served. | |||
| 548 | + | func TestHandlerWithoutOwnerServesThePlainFormOnly(t *testing.T) { | |||
| 549 | + | t.Parallel() | |||
| 550 | + | r := newRig(t, func(c *githttp.Config) { c.Owner = "" }) | |||
| 551 | + | code, _, _ := r.do(t, "GET", "/acme/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | |||
| 552 | + | assert.Equal(t, 404, code) | |||
| 553 | + | code, _, _ = r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | |||
| 554 | + | assert.Equal(t, 200, code) | |||
| 555 | + | } | |||
| 556 | + | ||||
| 509 | func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | 557 | func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | ||
| 510 | t.Parallel() | 558 | t.Parallel() | ||
| 511 | r := newRig(t, nil) | 559 | r := newRig(t, nil) | ||
| @@ -514,6 +562,8 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | |||||
| 514 | assert.Equal(t, 200, code) | 562 | assert.Equal(t, 200, code) | ||
| 515 | code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"}) | 563 | code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"}) | ||
| 516 | assert.Equal(t, 200, code, "the scheme is case-insensitive") | 564 | assert.Equal(t, 200, code, "the scheme is case-insensitive") | ||
| 565 | + | code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "token reader-secret"}) | |||
| 566 | + | assert.Equal(t, 200, code, "GitHub's own scheme") | |||
| 517 | for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} { | 567 | for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} { | ||
| 518 | code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h}) | 568 | code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h}) | ||
| 519 | assert.Equal(t, 401, code, h) | 569 | assert.Equal(t, 401, code, h) | ||
A internal/adapters/githubapi/handler.go +270 -0
| @@ -0,0 +1,270 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package githubapi serves the subset of GitHub's REST API that the actions | |
| 4 | + | // running on ermite call under github.api_url (/-/github/api, ADR 0025 CI-2): | |
| 5 | + | // | |
| 6 | + | // GET /repos/{owner}/{repo} | |
| 7 | + | // GET /repos/{owner}/{repo}/hash-algorithm | |
| 8 | + | // GET /repos/{owner}/{repo}/tarball[/{ref}] | |
| 9 | + | // | |
| 10 | + | // Everything else under the prefix is GitHub's 404 JSON, so that an action | |
| 11 | + | // calling an endpoint of a feature ermite lacks fails as it does on a GitHub | |
| 12 | + | // instance without it, never silently succeeds. The auth is the forge's: a | |
| 13 | + | // token (stored, or the repository token of a job) as the password of Basic with | |
| 14 | + | // any user, or as "Bearer" or "token" credentials; no credentials is anonymous, | |
| 15 | + | // which reads public repositories. | |
| 16 | + | package githubapi | |
| 17 | + | ||
| 18 | + | import ( | |
| 19 | + | "context" | |
| 20 | + | "errors" | |
| 21 | + | "io" | |
| 22 | + | "net/http" | |
| 23 | + | "net/netip" | |
| 24 | + | "strconv" | |
| 25 | + | "strings" | |
| 26 | + | ||
| 27 | + | "github.com/bnema/zerowrap" | |
| 28 | + | ||
| 29 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 30 | + | "bnema.dev/ermite/internal/domain" | |
| 31 | + | "bnema.dev/ermite/internal/ports" | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | // Prefix is where the API is mounted. | |
| 35 | + | const Prefix = "/-/github/api" | |
| 36 | + | ||
| 37 | + | // Patterns are the ServeMux patterns the handler serves: its whole prefix. | |
| 38 | + | var Patterns = []string{Prefix + "/"} | |
| 39 | + | ||
| 40 | + | // notFoundBody is GitHub's body for an unknown endpoint or repository. | |
| 41 | + | const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | |
| 42 | + | ||
| 43 | + | // DefaultMaxArchives bounds the archives written at once. | |
| 44 | + | const DefaultMaxArchives = 8 | |
| 45 | + | ||
| 46 | + | // Config configures a Handler. | |
| 47 | + | type Config struct { | |
| 48 | + | Auth ports.AuthService | |
| 49 | + | API ports.GitHubAPIService | |
| 50 | + | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 51 | + | TrustedProxies []netip.Prefix | |
| 52 | + | // Limiter counts failed authentications; nil builds a default one. | |
| 53 | + | Limiter ports.FailureLimiter | |
| 54 | + | // MaxArchives bounds the archives written at once (each runs a git | |
| 55 | + | // process); zero means DefaultMaxArchives. | |
| 56 | + | MaxArchives int | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Handler serves the REST subset. | |
| 60 | + | type Handler struct { | |
| 61 | + | cfg Config | |
| 62 | + | limiter ports.FailureLimiter | |
| 63 | + | archives chan struct{} | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // New builds a Handler. | |
| 67 | + | func New(cfg Config) *Handler { | |
| 68 | + | h := &Handler{cfg: cfg, limiter: cfg.Limiter} | |
| 69 | + | if h.limiter == nil { | |
| 70 | + | h.limiter = clientlimit.NewLimiter(nil) | |
| 71 | + | } | |
| 72 | + | n := cfg.MaxArchives | |
| 73 | + | if n <= 0 { | |
| 74 | + | n = DefaultMaxArchives | |
| 75 | + | } | |
| 76 | + | h.archives = make(chan struct{}, n) | |
| 77 | + | return h | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // ServeHTTP implements http.Handler. | |
| 81 | + | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 82 | + | w.Header().Set("Cache-Control", "no-store") | |
| 83 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 84 | + | rest := strings.TrimPrefix(r.URL.Path, Prefix) | |
| 85 | + | kind, owner, repo, ref, ok := route(rest) | |
| 86 | + | if !ok { | |
| 87 | + | notFound(w) | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | if r.Method != http.MethodGet && r.Method != http.MethodHead { | |
| 91 | + | w.Header().Set("Allow", "GET, HEAD") | |
| 92 | + | jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed") | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | ctx := r.Context() | |
| 97 | + | addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies) | |
| 98 | + | if blocked, _ := h.limiter.Blocked(ctx, addr); blocked { | |
| 99 | + | jsonError(w, http.StatusTooManyRequests, "API rate limit exceeded") | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | p, err := h.authenticate(r, addr) | |
| 103 | + | if err != nil { | |
| 104 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 105 | + | jsonError(w, http.StatusUnauthorized, "Bad credentials") | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | log := zerowrap.FromCtx(ctx) | |
| 109 | + | log.Error().Err(err).Msg("authenticate github api request") | |
| 110 | + | jsonError(w, http.StatusInternalServerError, "Server Error") | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | switch kind { | |
| 115 | + | case "repo": | |
| 116 | + | body, err := h.cfg.API.GetRepo(ctx, p, owner, repo) | |
| 117 | + | if err != nil { | |
| 118 | + | h.fail(ctx, w, err) | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | writeJSON(w, r, body) | |
| 122 | + | case "hash": | |
| 123 | + | alg, err := h.cfg.API.HashAlgorithm(ctx, p, owner, repo) | |
| 124 | + | if err != nil { | |
| 125 | + | h.fail(ctx, w, err) | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`)) | |
| 129 | + | case "tarball": | |
| 130 | + | h.tarball(w, r, p, owner, repo, ref) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // route recognises the three endpoints. ref is "" for /tarball. | |
| 135 | + | func route(rest string) (kind, owner, repo, ref string, ok bool) { | |
| 136 | + | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | |
| 137 | + | if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" { | |
| 138 | + | return "", "", "", "", false | |
| 139 | + | } | |
| 140 | + | owner, repo = parts[1], parts[2] | |
| 141 | + | switch { | |
| 142 | + | case len(parts) == 3: | |
| 143 | + | return "repo", owner, repo, "", true | |
| 144 | + | case len(parts) == 4 && parts[3] == "hash-algorithm": | |
| 145 | + | return "hash", owner, repo, "", true | |
| 146 | + | case len(parts) >= 4 && parts[3] == "tarball": | |
| 147 | + | // The ref may hold slashes (feature/x): everything after tarball/. | |
| 148 | + | return "tarball", owner, repo, strings.Join(parts[4:], "/"), true | |
| 149 | + | } | |
| 150 | + | return "", "", "", "", false | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // tarball streams the archive. It is not a 302 to a codeload: ermite serves the | |
| 154 | + | // bytes itself, which the runner follows without a second credential. | |
| 155 | + | func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref string) { | |
| 156 | + | ctx := r.Context() | |
| 157 | + | select { | |
| 158 | + | case h.archives <- struct{}{}: | |
| 159 | + | defer func() { <-h.archives }() | |
| 160 | + | default: | |
| 161 | + | w.Header().Set("Retry-After", "5") | |
| 162 | + | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | if r.Method == http.MethodHead { | |
| 166 | + | // Resolve without writing: a HEAD is answered with the headers only. | |
| 167 | + | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | |
| 168 | + | setArchiveHeaders(w, name) | |
| 169 | + | w.WriteHeader(http.StatusOK) | |
| 170 | + | return io.Discard | |
| 171 | + | }) | |
| 172 | + | if err != nil && !errors.Is(err, context.Canceled) { | |
| 173 | + | h.fail(ctx, w, err) | |
| 174 | + | } | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | started := false | |
| 178 | + | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | |
| 179 | + | started = true | |
| 180 | + | setArchiveHeaders(w, name) | |
| 181 | + | return w | |
| 182 | + | }) | |
| 183 | + | switch { | |
| 184 | + | case err == nil: | |
| 185 | + | case started: | |
| 186 | + | // The status is out: the client sees a truncated archive. Log it. | |
| 187 | + | log := zerowrap.FromCtx(ctx) | |
| 188 | + | log.Warn().Err(err).Msg("github api archive cut short") | |
| 189 | + | default: | |
| 190 | + | h.fail(ctx, w, err) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func setArchiveHeaders(w http.ResponseWriter, name string) { | |
| 195 | + | w.Header().Set("Content-Type", "application/x-gzip") | |
| 196 | + | w.Header().Set("Content-Disposition", "attachment; filename="+name) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // authenticate reads the credentials of the request. None is the anonymous | |
| 200 | + | // principal; credentials that are not valid count as a failure of the client | |
| 201 | + | // address. | |
| 202 | + | func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) { | |
| 203 | + | secret, present := credentials(r) | |
| 204 | + | if !present { | |
| 205 | + | return domain.Anonymous(), nil | |
| 206 | + | } | |
| 207 | + | p, err := h.cfg.Auth.Authenticate(r.Context(), secret) | |
| 208 | + | if err != nil { | |
| 209 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 210 | + | h.limiter.Fail(r.Context(), addr) | |
| 211 | + | } | |
| 212 | + | return domain.Anonymous(), err | |
| 213 | + | } | |
| 214 | + | return p, nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // credentials extracts the token of an Authorization header: the password of | |
| 218 | + | // Basic (the user is ignored, checkout sends x-access-token), or the value of | |
| 219 | + | // "Bearer" or "token". | |
| 220 | + | func credentials(r *http.Request) (secret string, present bool) { | |
| 221 | + | auth := r.Header.Get("Authorization") | |
| 222 | + | if auth == "" { | |
| 223 | + | return "", false | |
| 224 | + | } | |
| 225 | + | if _, pass, ok := r.BasicAuth(); ok { | |
| 226 | + | return pass, true | |
| 227 | + | } | |
| 228 | + | if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) { | |
| 229 | + | return strings.TrimSpace(tok), true | |
| 230 | + | } | |
| 231 | + | return "", true | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // fail answers an error of a use case. A repository the caller cannot read is | |
| 235 | + | // as not found as one that does not exist. | |
| 236 | + | func (h *Handler) fail(ctx context.Context, w http.ResponseWriter, err error) { | |
| 237 | + | switch { | |
| 238 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): | |
| 239 | + | notFound(w) | |
| 240 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 241 | + | w.Header().Set("Retry-After", "5") | |
| 242 | + | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |
| 243 | + | case errors.Is(err, context.Canceled): | |
| 244 | + | default: | |
| 245 | + | log := zerowrap.FromCtx(ctx) | |
| 246 | + | log.Error().Err(err).Msg("github api request failed") | |
| 247 | + | jsonError(w, http.StatusInternalServerError, "Server Error") | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func notFound(w http.ResponseWriter) { | |
| 252 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 253 | + | w.WriteHeader(http.StatusNotFound) | |
| 254 | + | _, _ = io.WriteString(w, notFoundBody) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func jsonError(w http.ResponseWriter, status int, msg string) { | |
| 258 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 259 | + | w.WriteHeader(status) | |
| 260 | + | _, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`,"documentation_url":"https://docs.github.com/rest"}`) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) { | |
| 264 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
| 265 | + | w.Header().Set("Content-Length", strconv.Itoa(len(body))) | |
| 266 | + | w.WriteHeader(http.StatusOK) | |
| 267 | + | if r.Method != http.MethodHead { | |
| 268 | + | _, _ = w.Write(body) | |
| 269 | + | } | |
| 270 | + | } | |
| @@ -0,0 +1,270 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package githubapi serves the subset of GitHub's REST API that the actions | |||
| 4 | + | // running on ermite call under github.api_url (/-/github/api, ADR 0025 CI-2): | |||
| 5 | + | // | |||
| 6 | + | // GET /repos/{owner}/{repo} | |||
| 7 | + | // GET /repos/{owner}/{repo}/hash-algorithm | |||
| 8 | + | // GET /repos/{owner}/{repo}/tarball[/{ref}] | |||
| 9 | + | // | |||
| 10 | + | // Everything else under the prefix is GitHub's 404 JSON, so that an action | |||
| 11 | + | // calling an endpoint of a feature ermite lacks fails as it does on a GitHub | |||
| 12 | + | // instance without it, never silently succeeds. The auth is the forge's: a | |||
| 13 | + | // token (stored, or the repository token of a job) as the password of Basic with | |||
| 14 | + | // any user, or as "Bearer" or "token" credentials; no credentials is anonymous, | |||
| 15 | + | // which reads public repositories. | |||
| 16 | + | package githubapi | |||
| 17 | + | ||||
| 18 | + | import ( | |||
| 19 | + | "context" | |||
| 20 | + | "errors" | |||
| 21 | + | "io" | |||
| 22 | + | "net/http" | |||
| 23 | + | "net/netip" | |||
| 24 | + | "strconv" | |||
| 25 | + | "strings" | |||
| 26 | + | ||||
| 27 | + | "github.com/bnema/zerowrap" | |||
| 28 | + | ||||
| 29 | + | "bnema.dev/ermite/internal/clientlimit" | |||
| 30 | + | "bnema.dev/ermite/internal/domain" | |||
| 31 | + | "bnema.dev/ermite/internal/ports" | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | // Prefix is where the API is mounted. | |||
| 35 | + | const Prefix = "/-/github/api" | |||
| 36 | + | ||||
| 37 | + | // Patterns are the ServeMux patterns the handler serves: its whole prefix. | |||
| 38 | + | var Patterns = []string{Prefix + "/"} | |||
| 39 | + | ||||
| 40 | + | // notFoundBody is GitHub's body for an unknown endpoint or repository. | |||
| 41 | + | const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | |||
| 42 | + | ||||
| 43 | + | // DefaultMaxArchives bounds the archives written at once. | |||
| 44 | + | const DefaultMaxArchives = 8 | |||
| 45 | + | ||||
| 46 | + | // Config configures a Handler. | |||
| 47 | + | type Config struct { | |||
| 48 | + | Auth ports.AuthService | |||
| 49 | + | API ports.GitHubAPIService | |||
| 50 | + | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |||
| 51 | + | TrustedProxies []netip.Prefix | |||
| 52 | + | // Limiter counts failed authentications; nil builds a default one. | |||
| 53 | + | Limiter ports.FailureLimiter | |||
| 54 | + | // MaxArchives bounds the archives written at once (each runs a git | |||
| 55 | + | // process); zero means DefaultMaxArchives. | |||
| 56 | + | MaxArchives int | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Handler serves the REST subset. | |||
| 60 | + | type Handler struct { | |||
| 61 | + | cfg Config | |||
| 62 | + | limiter ports.FailureLimiter | |||
| 63 | + | archives chan struct{} | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // New builds a Handler. | |||
| 67 | + | func New(cfg Config) *Handler { | |||
| 68 | + | h := &Handler{cfg: cfg, limiter: cfg.Limiter} | |||
| 69 | + | if h.limiter == nil { | |||
| 70 | + | h.limiter = clientlimit.NewLimiter(nil) | |||
| 71 | + | } | |||
| 72 | + | n := cfg.MaxArchives | |||
| 73 | + | if n <= 0 { | |||
| 74 | + | n = DefaultMaxArchives | |||
| 75 | + | } | |||
| 76 | + | h.archives = make(chan struct{}, n) | |||
| 77 | + | return h | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // ServeHTTP implements http.Handler. | |||
| 81 | + | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 82 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 83 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |||
| 84 | + | rest := strings.TrimPrefix(r.URL.Path, Prefix) | |||
| 85 | + | kind, owner, repo, ref, ok := route(rest) | |||
| 86 | + | if !ok { | |||
| 87 | + | notFound(w) | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | if r.Method != http.MethodGet && r.Method != http.MethodHead { | |||
| 91 | + | w.Header().Set("Allow", "GET, HEAD") | |||
| 92 | + | jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed") | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | ctx := r.Context() | |||
| 97 | + | addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies) | |||
| 98 | + | if blocked, _ := h.limiter.Blocked(ctx, addr); blocked { | |||
| 99 | + | jsonError(w, http.StatusTooManyRequests, "API rate limit exceeded") | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | p, err := h.authenticate(r, addr) | |||
| 103 | + | if err != nil { | |||
| 104 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |||
| 105 | + | jsonError(w, http.StatusUnauthorized, "Bad credentials") | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | log := zerowrap.FromCtx(ctx) | |||
| 109 | + | log.Error().Err(err).Msg("authenticate github api request") | |||
| 110 | + | jsonError(w, http.StatusInternalServerError, "Server Error") | |||
| 111 | + | return | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | switch kind { | |||
| 115 | + | case "repo": | |||
| 116 | + | body, err := h.cfg.API.GetRepo(ctx, p, owner, repo) | |||
| 117 | + | if err != nil { | |||
| 118 | + | h.fail(ctx, w, err) | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | writeJSON(w, r, body) | |||
| 122 | + | case "hash": | |||
| 123 | + | alg, err := h.cfg.API.HashAlgorithm(ctx, p, owner, repo) | |||
| 124 | + | if err != nil { | |||
| 125 | + | h.fail(ctx, w, err) | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`)) | |||
| 129 | + | case "tarball": | |||
| 130 | + | h.tarball(w, r, p, owner, repo, ref) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // route recognises the three endpoints. ref is "" for /tarball. | |||
| 135 | + | func route(rest string) (kind, owner, repo, ref string, ok bool) { | |||
| 136 | + | parts := strings.Split(strings.TrimPrefix(rest, "/"), "/") | |||
| 137 | + | if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" { | |||
| 138 | + | return "", "", "", "", false | |||
| 139 | + | } | |||
| 140 | + | owner, repo = parts[1], parts[2] | |||
| 141 | + | switch { | |||
| 142 | + | case len(parts) == 3: | |||
| 143 | + | return "repo", owner, repo, "", true | |||
| 144 | + | case len(parts) == 4 && parts[3] == "hash-algorithm": | |||
| 145 | + | return "hash", owner, repo, "", true | |||
| 146 | + | case len(parts) >= 4 && parts[3] == "tarball": | |||
| 147 | + | // The ref may hold slashes (feature/x): everything after tarball/. | |||
| 148 | + | return "tarball", owner, repo, strings.Join(parts[4:], "/"), true | |||
| 149 | + | } | |||
| 150 | + | return "", "", "", "", false | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // tarball streams the archive. It is not a 302 to a codeload: ermite serves the | |||
| 154 | + | // bytes itself, which the runner follows without a second credential. | |||
| 155 | + | func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref string) { | |||
| 156 | + | ctx := r.Context() | |||
| 157 | + | select { | |||
| 158 | + | case h.archives <- struct{}{}: | |||
| 159 | + | defer func() { <-h.archives }() | |||
| 160 | + | default: | |||
| 161 | + | w.Header().Set("Retry-After", "5") | |||
| 162 | + | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | if r.Method == http.MethodHead { | |||
| 166 | + | // Resolve without writing: a HEAD is answered with the headers only. | |||
| 167 | + | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | |||
| 168 | + | setArchiveHeaders(w, name) | |||
| 169 | + | w.WriteHeader(http.StatusOK) | |||
| 170 | + | return io.Discard | |||
| 171 | + | }) | |||
| 172 | + | if err != nil && !errors.Is(err, context.Canceled) { | |||
| 173 | + | h.fail(ctx, w, err) | |||
| 174 | + | } | |||
| 175 | + | return | |||
| 176 | + | } | |||
| 177 | + | started := false | |||
| 178 | + | err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer { | |||
| 179 | + | started = true | |||
| 180 | + | setArchiveHeaders(w, name) | |||
| 181 | + | return w | |||
| 182 | + | }) | |||
| 183 | + | switch { | |||
| 184 | + | case err == nil: | |||
| 185 | + | case started: | |||
| 186 | + | // The status is out: the client sees a truncated archive. Log it. | |||
| 187 | + | log := zerowrap.FromCtx(ctx) | |||
| 188 | + | log.Warn().Err(err).Msg("github api archive cut short") | |||
| 189 | + | default: | |||
| 190 | + | h.fail(ctx, w, err) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func setArchiveHeaders(w http.ResponseWriter, name string) { | |||
| 195 | + | w.Header().Set("Content-Type", "application/x-gzip") | |||
| 196 | + | w.Header().Set("Content-Disposition", "attachment; filename="+name) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // authenticate reads the credentials of the request. None is the anonymous | |||
| 200 | + | // principal; credentials that are not valid count as a failure of the client | |||
| 201 | + | // address. | |||
| 202 | + | func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) { | |||
| 203 | + | secret, present := credentials(r) | |||
| 204 | + | if !present { | |||
| 205 | + | return domain.Anonymous(), nil | |||
| 206 | + | } | |||
| 207 | + | p, err := h.cfg.Auth.Authenticate(r.Context(), secret) | |||
| 208 | + | if err != nil { | |||
| 209 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |||
| 210 | + | h.limiter.Fail(r.Context(), addr) | |||
| 211 | + | } | |||
| 212 | + | return domain.Anonymous(), err | |||
| 213 | + | } | |||
| 214 | + | return p, nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // credentials extracts the token of an Authorization header: the password of | |||
| 218 | + | // Basic (the user is ignored, checkout sends x-access-token), or the value of | |||
| 219 | + | // "Bearer" or "token". | |||
| 220 | + | func credentials(r *http.Request) (secret string, present bool) { | |||
| 221 | + | auth := r.Header.Get("Authorization") | |||
| 222 | + | if auth == "" { | |||
| 223 | + | return "", false | |||
| 224 | + | } | |||
| 225 | + | if _, pass, ok := r.BasicAuth(); ok { | |||
| 226 | + | return pass, true | |||
| 227 | + | } | |||
| 228 | + | if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) { | |||
| 229 | + | return strings.TrimSpace(tok), true | |||
| 230 | + | } | |||
| 231 | + | return "", true | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // fail answers an error of a use case. A repository the caller cannot read is | |||
| 235 | + | // as not found as one that does not exist. | |||
| 236 | + | func (h *Handler) fail(ctx context.Context, w http.ResponseWriter, err error) { | |||
| 237 | + | switch { | |||
| 238 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): | |||
| 239 | + | notFound(w) | |||
| 240 | + | case errors.Is(err, domain.ErrResourceExhausted): | |||
| 241 | + | w.Header().Set("Retry-After", "5") | |||
| 242 | + | jsonError(w, http.StatusServiceUnavailable, "Server busy") | |||
| 243 | + | case errors.Is(err, context.Canceled): | |||
| 244 | + | default: | |||
| 245 | + | log := zerowrap.FromCtx(ctx) | |||
| 246 | + | log.Error().Err(err).Msg("github api request failed") | |||
| 247 | + | jsonError(w, http.StatusInternalServerError, "Server Error") | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func notFound(w http.ResponseWriter) { | |||
| 252 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |||
| 253 | + | w.WriteHeader(http.StatusNotFound) | |||
| 254 | + | _, _ = io.WriteString(w, notFoundBody) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func jsonError(w http.ResponseWriter, status int, msg string) { | |||
| 258 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |||
| 259 | + | w.WriteHeader(status) | |||
| 260 | + | _, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`,"documentation_url":"https://docs.github.com/rest"}`) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) { | |||
| 264 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") | |||
| 265 | + | w.Header().Set("Content-Length", strconv.Itoa(len(body))) | |||
| 266 | + | w.WriteHeader(http.StatusOK) | |||
| 267 | + | if r.Method != http.MethodHead { | |||
| 268 | + | _, _ = w.Write(body) | |||
| 269 | + | } | |||
| 270 | + | } | |||
A internal/app/ci_execution_test.go +792 -0
| @@ -0,0 +1,792 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "archive/tar" | |
| 7 | + | "bytes" | |
| 8 | + | "compress/gzip" | |
| 9 | + | "context" | |
| 10 | + | "encoding/base64" | |
| 11 | + | "encoding/json" | |
| 12 | + | "io" | |
| 13 | + | "net/http" | |
| 14 | + | "path/filepath" | |
| 15 | + | "slices" | |
| 16 | + | "strings" | |
| 17 | + | "sync" | |
| 18 | + | "testing" | |
| 19 | + | "time" | |
| 20 | + | ||
| 21 | + | "connectrpc.com/connect" | |
| 22 | + | "github.com/stretchr/testify/assert" | |
| 23 | + | "github.com/stretchr/testify/mock" | |
| 24 | + | "github.com/stretchr/testify/require" | |
| 25 | + | ||
| 26 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 27 | + | "bnema.dev/ermite/internal/domain" | |
| 28 | + | "bnema.dev/ermite/internal/ports" | |
| 29 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 30 | + | "bnema.dev/ermite/internal/usecase" | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // CI-2 execution (phase E1): the job repository token, the git URLs of the | |
| 34 | + | // owner, the REST subset and the resolution of actions. | |
| 35 | + | ||
| 36 | + | // fakeRemote stands in for github.com: no test reaches the network. It is the | |
| 37 | + | // generated mock of ports.GitRemote answering from a table the test edits. | |
| 38 | + | type fakeRemote struct { | |
| 39 | + | m *mocks.GitRemote | |
| 40 | + | mu sync.Mutex | |
| 41 | + | refs map[string][]ports.RemoteRef | |
| 42 | + | err map[string]error | |
| 43 | + | calls []string | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func newFakeRemote(t *testing.T) *fakeRemote { | |
| 47 | + | t.Helper() | |
| 48 | + | f := &fakeRemote{m: mocks.NewGitRemote(t), refs: map[string][]ports.RemoteRef{}, err: map[string]error{}} | |
| 49 | + | f.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 50 | + | func(_ context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| 51 | + | f.mu.Lock() | |
| 52 | + | defer f.mu.Unlock() | |
| 53 | + | key := strings.ToLower(owner + "/" + repo) | |
| 54 | + | f.calls = append(f.calls, key) | |
| 55 | + | if err := f.err[key]; err != nil { | |
| 56 | + | return nil, err | |
| 57 | + | } | |
| 58 | + | refs, ok := f.refs[key] | |
| 59 | + | if !ok { | |
| 60 | + | return nil, domain.ErrNotFound | |
| 61 | + | } | |
| 62 | + | return refs, nil | |
| 63 | + | }).Maybe() | |
| 64 | + | return f | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (f *fakeRemote) set(repo string, refs ...ports.RemoteRef) { | |
| 68 | + | f.mu.Lock() | |
| 69 | + | defer f.mu.Unlock() | |
| 70 | + | f.refs[repo] = refs | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (f *fakeRemote) asked(key string) bool { | |
| 74 | + | f.mu.Lock() | |
| 75 | + | defer f.mu.Unlock() | |
| 76 | + | return slices.Contains(f.calls, key) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (f *fakeRemote) count() int { | |
| 80 | + | f.mu.Lock() | |
| 81 | + | defer f.mu.Unlock() | |
| 82 | + | return len(f.calls) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // newExecHarness is newCIHarness with a fake github.com. | |
| 86 | + | func newExecHarness(t *testing.T, remote *fakeRemote, tweak func(*usecase.CIConfig)) *ciHarness { | |
| 87 | + | t.Helper() | |
| 88 | + | h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci, o.remoteLister = tweak, remote.m }) | |
| 89 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |
| 90 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | c.wt = h.workTree("main") | |
| 93 | + | return c | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // startJob pushes a workflow and returns the job given to a runner. | |
| 97 | + | func (c *ciHarness) startJob(runner *fakeRunner) *pb.Job { | |
| 98 | + | c.t.Helper() | |
| 99 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0], "README": "hello\n"}, "") | |
| 100 | + | return runner.await() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (c *ciHarness) heartbeat(runner *fakeRunner, job *pb.Job) { | |
| 104 | + | c.t.Helper() | |
| 105 | + | _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()})) | |
| 106 | + | require.NoError(c.t, err) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // checkoutHeader is what actions/checkout sets in http.extraheader. | |
| 110 | + | func checkoutHeader(token string) string { | |
| 111 | + | return "AUTHORIZATION: basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // clone clones url as actions/checkout does: the token in http.extraheader. | |
| 115 | + | func (c *ciHarness) clone(token, url string, args ...string) (string, error) { | |
| 116 | + | dest := filepath.Join(c.t.TempDir(), "w") | |
| 117 | + | a := []string{} | |
| 118 | + | if token != "" { | |
| 119 | + | a = append(a, "-c", "http.extraheader="+checkoutHeader(token)) | |
| 120 | + | } | |
| 121 | + | a = append(a, "clone", "-q") | |
| 122 | + | a = append(a, args...) | |
| 123 | + | a = append(a, url, dest) | |
| 124 | + | return c.git(c.t.TempDir(), a...) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (c *ciHarness) get(path, authorization string) (int, string, http.Header) { | |
| 128 | + | c.t.Helper() | |
| 129 | + | req, err := http.NewRequest(http.MethodGet, c.url(path), nil) | |
| 130 | + | require.NoError(c.t, err) | |
| 131 | + | if authorization != "" { | |
| 132 | + | req.Header.Set("Authorization", authorization) | |
| 133 | + | } | |
| 134 | + | resp, err := http.DefaultClient.Do(req) | |
| 135 | + | require.NoError(c.t, err) | |
| 136 | + | defer func() { _ = resp.Body.Close() }() | |
| 137 | + | b, err := io.ReadAll(resp.Body) | |
| 138 | + | require.NoError(c.t, err) | |
| 139 | + | return resp.StatusCode, string(b), resp.Header | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func basic(token string) string { | |
| 143 | + | return "Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // AC3: the job gets a repository token at its assignment, in the Job and not in | |
| 147 | + | // the contexts; it clones its repository, public or private, over the URL | |
| 148 | + | // github.repository gives, with and without .git, the way checkout does. | |
| 149 | + | func TestJobTokenClonesTheRepositoryOfTheRun(t *testing.T) { | |
| 150 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 151 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 152 | + | job := c.startJob(runner) | |
| 153 | + | tok := job.GetGithubToken() | |
| 154 | + | require.True(t, domain.IsRepoToken(tok), "ermitegh_ and 43 base64url characters") | |
| 155 | + | assert.NotEqual(t, job.GetJobToken(), tok) | |
| 156 | + | assert.NotContains(t, job.GetContexts(), tok, "the token does not sit in the contexts the runner stores") | |
| 157 | + | assert.NotContains(t, job.GetWorkflowYaml(), tok) | |
| 158 | + | c.heartbeat(runner, job) | |
| 159 | + | ||
| 160 | + | // Private: checkout's clone, shallow, without .git. | |
| 161 | + | out, err := c.clone(tok, c.url("/owner/proj"), "--depth", "1") | |
| 162 | + | require.NoError(t, err, out) | |
| 163 | + | out, err = c.clone(tok, c.url("/owner/proj.git")) | |
| 164 | + | require.NoError(t, err, out) | |
| 165 | + | out, err = c.clone(tok, c.url("/proj.git")) | |
| 166 | + | require.NoError(t, err, "the plain URL works too: "+out) | |
| 167 | + | // Without a token a private repository asks for credentials. | |
| 168 | + | _, err = c.clone("", c.url("/owner/proj")) | |
| 169 | + | require.Error(t, err) | |
| 170 | + | // Another owner has no repositories. | |
| 171 | + | out, err = c.clone(tok, c.url("/someone/proj")) | |
| 172 | + | require.Error(t, err, out) | |
| 173 | + | ||
| 174 | + | // The same token and the other schemes, on the wire. | |
| 175 | + | for _, h := range []string{basic(tok), "Bearer " + tok, "token " + tok} { | |
| 176 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", h) | |
| 177 | + | assert.Equal(t, http.StatusOK, status, h[:12]) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // Public: anyone clones, the token too. | |
| 181 | + | vis := domain.VisibilityPublic | |
| 182 | + | _, err = c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis}) | |
| 183 | + | require.NoError(t, err) | |
| 184 | + | out, err = c.clone("", c.url("/owner/proj")) | |
| 185 | + | require.NoError(t, err, out) | |
| 186 | + | out, err = c.clone(tok, c.url("/owner/proj")) | |
| 187 | + | require.NoError(t, err, out) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (c *ciHarness) h(name string) domain.RepoName { return c.name(name) } | |
| 191 | + | ||
| 192 | + | // AC3: refused on another private repository, for push, and for the REST | |
| 193 | + | // subset of another repository; never a stored token for the API. | |
| 194 | + | func TestJobTokenReadsItsRepositoryOnly(t *testing.T) { | |
| 195 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 196 | + | c.seed("secret", domain.VisibilityPrivate, "a.txt") | |
| 197 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 198 | + | job := c.startJob(runner) | |
| 199 | + | tok := job.GetGithubToken() | |
| 200 | + | c.heartbeat(runner, job) | |
| 201 | + | ||
| 202 | + | out, err := c.clone(tok, c.url("/owner/secret")) | |
| 203 | + | require.Error(t, err) | |
| 204 | + | assert.Contains(t, out, "not found", "the other private repository does not show itself") | |
| 205 | + | status, _, _ := c.get("/owner/secret/info/refs?service=git-upload-pack", basic(tok)) | |
| 206 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 207 | + | status, _, _ = c.get("/-/github/api/repos/owner/secret", basic(tok)) | |
| 208 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 209 | + | ||
| 210 | + | // Push: the receive-pack advertisement and the push itself are 403. | |
| 211 | + | status, _, _ = c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok)) | |
| 212 | + | assert.Equal(t, http.StatusForbidden, status) | |
| 213 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "x") | |
| 214 | + | out, err = c.git(c.wt, "-c", "http.extraheader="+checkoutHeader(tok), "push", c.url("/owner/proj"), "x:refs/heads/x") | |
| 215 | + | require.Error(t, err) | |
| 216 | + | assert.Contains(t, out, "403") | |
| 217 | + | assert.Empty(t, c.bareGit("proj", "branch", "--list", "x"), "nothing was pushed") | |
| 218 | + | ||
| 219 | + | // The API takes it as the principal it is (repo:read on one repository): it | |
| 220 | + | // lists that repository and no other, and writes nothing. | |
| 221 | + | list, err := c.api(tok).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{})) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | require.Len(t, list.Msg.GetRepos(), 1) | |
| 224 | + | assert.Equal(t, "proj", list.Msg.GetRepos()[0].GetName()) | |
| 225 | + | _, err = c.api(tok).repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"})) | |
| 226 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 227 | + | _, err = c.api(tok).token.CreateToken(c.ctx, connect.NewRequest(&pb.CreateTokenRequest{Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}})) | |
| 228 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 229 | + | _, err = c.api(tok).token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |
| 230 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |
| 231 | + | _, err = c.api(tok).run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |
| 232 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no ci:write") | |
| 233 | + | ||
| 234 | + | // A made-up token of the right shape is refused, and so are wrong ones. | |
| 235 | + | for _, bad := range []string{"ermitegh_" + strings.Repeat("A", 43), "ermitegh_short", tok + "x"} { | |
| 236 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(bad)) | |
| 237 | + | assert.Equal(t, http.StatusUnauthorized, status, bad) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // The token is not a stored token: ListTokens does not show it, token:admin | |
| 242 | + | // does not manage it, and the principal it gives is read-only on one repository. | |
| 243 | + | func TestJobTokenIsNotAStoredToken(t *testing.T) { | |
| 244 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 245 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 246 | + | job := c.startJob(runner) | |
| 247 | + | tok := job.GetGithubToken() | |
| 248 | + | ||
| 249 | + | before, err := c.root.token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |
| 250 | + | require.NoError(t, err) | |
| 251 | + | assert.Empty(t, before.Msg.GetTokens(), "no stored token exists") | |
| 252 | + | ||
| 253 | + | p, err := c.forge.Auth.Authenticate(c.ctx, tok) | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, p.Scopes()) | |
| 256 | + | assert.Equal(t, []domain.RepoName{c.h("proj")}, p.Repos()) | |
| 257 | + | assert.False(t, p.ExpiresAt().IsZero()) | |
| 258 | + | assert.False(t, p.HasScope(domain.ScopeTokenAdmin)) | |
| 259 | + | assert.False(t, p.IsRoot()) | |
| 260 | + | ||
| 261 | + | // Revoking by its id (the "job:" id the principal shows) finds nothing. | |
| 262 | + | _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: strings.TrimPrefix(p.TokenID(), "job:")})) | |
| 263 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 264 | + | _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: p.TokenID()})) | |
| 265 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |
| 266 | + | _, err = c.forge.Auth.Authenticate(c.ctx, tok) | |
| 267 | + | require.NoError(t, err, "still valid") | |
| 268 | + | require.NoError(t, c.forge.Auth.Revalidate(c.ctx, p)) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // AC3: refused after the job ends, however it ends. | |
| 272 | + | func TestJobTokenEndsWithTheJob(t *testing.T) { | |
| 273 | + | cloneStatus := func(c *ciHarness, tok string) int { | |
| 274 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok)) | |
| 275 | + | return status | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | t.Run("CompleteJob", func(t *testing.T) { | |
| 279 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 280 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 281 | + | job := c.startJob(runner) | |
| 282 | + | tok := job.GetGithubToken() | |
| 283 | + | p, err := c.forge.Auth.Authenticate(c.ctx, tok) | |
| 284 | + | require.NoError(t, err) | |
| 285 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |
| 286 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 287 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok)) | |
| 288 | + | assert.ErrorIs(t, c.forge.Auth.Revalidate(c.ctx, p), domain.ErrUnauthenticated, "a long operation stops") | |
| 289 | + | out, err := c.clone(tok, c.url("/owner/proj")) | |
| 290 | + | require.Error(t, err, out) | |
| 291 | + | }) | |
| 292 | + | ||
| 293 | + | t.Run("cancel", func(t *testing.T) { | |
| 294 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |
| 295 | + | cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 296 | + | }) | |
| 297 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 298 | + | job := c.startJob(runner) | |
| 299 | + | c.heartbeat(runner, job) | |
| 300 | + | tok := job.GetGithubToken() | |
| 301 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |
| 302 | + | _, err := c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |
| 303 | + | require.NoError(t, err) | |
| 304 | + | // The runner is told to stop, and the job concludes when it answers or | |
| 305 | + | // when the grace ends: either way the token goes with it. | |
| 306 | + | c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |
| 307 | + | assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, c.runs()[0].GetStatus()) | |
| 308 | + | }) | |
| 309 | + | ||
| 310 | + | t.Run("lost runner", func(t *testing.T) { | |
| 311 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |
| 312 | + | cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 313 | + | }) | |
| 314 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 315 | + | job := c.startJob(runner) | |
| 316 | + | c.heartbeat(runner, job) | |
| 317 | + | tok := job.GetGithubToken() | |
| 318 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |
| 319 | + | c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |
| 320 | + | assert.Contains(t, c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError(), "lost communication") | |
| 321 | + | }) | |
| 322 | + | ||
| 323 | + | t.Run("assignment not acknowledged", func(t *testing.T) { | |
| 324 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |
| 325 | + | cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 200*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |
| 326 | + | }) | |
| 327 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 328 | + | job := c.startJob(runner) | |
| 329 | + | tok := job.GetGithubToken() | |
| 330 | + | c.waitFor("the assignment is taken back", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |
| 331 | + | again := runner.await() | |
| 332 | + | assert.NotEqual(t, tok, again.GetGithubToken(), "the next assignment has its own token") | |
| 333 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, again.GetGithubToken())) | |
| 334 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok), "the old one stays revoked") | |
| 335 | + | }) | |
| 336 | + | ||
| 337 | + | t.Run("repository deleted", func(t *testing.T) { | |
| 338 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 339 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 340 | + | job := c.startJob(runner) | |
| 341 | + | tok := job.GetGithubToken() | |
| 342 | + | _, err := c.forge.Auth.Authenticate(c.ctx, tok) | |
| 343 | + | require.NoError(t, err) | |
| 344 | + | require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("proj"))) | |
| 345 | + | _, err = c.forge.Auth.Authenticate(c.ctx, tok) | |
| 346 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the repository, its runs and their tokens are gone") | |
| 347 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok)) | |
| 348 | + | }) | |
| 349 | + | ||
| 350 | + | t.Run("expiry", func(t *testing.T) { | |
| 351 | + | // The deadline of the job plus five minutes: a job of one minute. | |
| 352 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 353 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 354 | + | c.push("main", map[string]string{".github/workflows/t.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n steps:\n - run: echo\n"}, "") | |
| 355 | + | job := runner.await() | |
| 356 | + | p, err := c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken()) | |
| 357 | + | require.NoError(t, err) | |
| 358 | + | want := time.Now().Add(time.Minute + domain.CITimeoutSlack + domain.RepoTokenGrace) | |
| 359 | + | assert.WithinDuration(t, want, p.ExpiresAt(), 5*time.Second) | |
| 360 | + | }) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // The retry of a poll gets the token of the first call, and a job token of the | |
| 364 | + | // protocol never doubles as the repository token. | |
| 365 | + | func TestJobTokenSurvivesAPollRetry(t *testing.T) { | |
| 366 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 367 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 368 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |
| 369 | + | var first *pb.Job | |
| 370 | + | c.waitFor("a job", func() bool { | |
| 371 | + | j, err := runner.fetch("poll-1") | |
| 372 | + | require.NoError(t, err) | |
| 373 | + | first = j | |
| 374 | + | return j != nil | |
| 375 | + | }) | |
| 376 | + | again, err := runner.fetch("poll-1") | |
| 377 | + | require.NoError(t, err) | |
| 378 | + | require.NotNil(t, again) | |
| 379 | + | assert.Equal(t, first.GetJobToken(), again.GetJobToken()) | |
| 380 | + | assert.Equal(t, first.GetGithubToken(), again.GetGithubToken()) | |
| 381 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(first.GetJobToken())) | |
| 382 | + | assert.Equal(t, http.StatusUnauthorized, status, "the runner's job token reads nothing") | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // The REST subset: GitHub's shapes for what checkout and github-script call, | |
| 386 | + | // GitHub's 404 for everything else. | |
| 387 | + | func TestGitHubRESTSubset(t *testing.T) { | |
| 388 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 389 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 390 | + | job := c.startJob(runner) | |
| 391 | + | tok := job.GetGithubToken() | |
| 392 | + | sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |
| 393 | + | notFound := `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | |
| 394 | + | ||
| 395 | + | // Private: the token reads it, anonymous sees nothing. | |
| 396 | + | status, body, hdr := c.get("/-/github/api/repos/owner/proj", basic(tok)) | |
| 397 | + | require.Equal(t, http.StatusOK, status, body) | |
| 398 | + | assert.Contains(t, hdr.Get("Content-Type"), "application/json") | |
| 399 | + | var repo map[string]any | |
| 400 | + | require.NoError(t, json.Unmarshal([]byte(body), &repo)) | |
| 401 | + | assert.Equal(t, "proj", repo["name"]) | |
| 402 | + | assert.Equal(t, "owner/proj", repo["full_name"]) | |
| 403 | + | assert.Equal(t, true, repo["private"]) | |
| 404 | + | assert.Equal(t, "private", repo["visibility"]) | |
| 405 | + | assert.Equal(t, "main", repo["default_branch"]) | |
| 406 | + | assert.Equal(t, "http://example.test/owner/proj", repo["html_url"]) | |
| 407 | + | assert.Equal(t, "http://example.test/owner/proj.git", repo["clone_url"]) | |
| 408 | + | assert.Equal(t, "http://example.test/-/github/api/repos/owner/proj", repo["url"]) | |
| 409 | + | assert.NotZero(t, repo["id"]) | |
| 410 | + | assert.NotEmpty(t, repo["node_id"]) | |
| 411 | + | owner, _ := repo["owner"].(map[string]any) | |
| 412 | + | assert.Equal(t, "owner", owner["login"]) | |
| 413 | + | assert.Equal(t, "User", owner["type"]) | |
| 414 | + | assert.Equal(t, false, repo["fork"]) | |
| 415 | + | assert.Equal(t, false, repo["archived"]) | |
| 416 | + | assert.Equal(t, false, repo["has_issues"] == nil, "present") | |
| 417 | + | assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["created_at"], "REST dates are ISO 8601") | |
| 418 | + | assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["pushed_at"]) | |
| 419 | + | perms, _ := repo["permissions"].(map[string]any) | |
| 420 | + | assert.Equal(t, true, perms["pull"]) | |
| 421 | + | assert.Equal(t, false, perms["push"], "the job token cannot write") | |
| 422 | + | assert.Equal(t, false, perms["admin"]) | |
| 423 | + | assert.NotContains(t, repo, "master_branch", "the push event has it, the REST object does not") | |
| 424 | + | ||
| 425 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", "") | |
| 426 | + | assert.Equal(t, http.StatusNotFound, status, "a private repository does not show itself") | |
| 427 | + | assert.JSONEq(t, notFound, body) | |
| 428 | + | status, _, _ = c.get("/-/github/api/repos/Owner/PROJ", basic(tok)) | |
| 429 | + | assert.Equal(t, http.StatusOK, status, "names are case-insensitive as on GitHub") | |
| 430 | + | status, _, _ = c.get("/-/github/api/repos/other/proj", basic(tok)) | |
| 431 | + | assert.Equal(t, http.StatusNotFound, status, "another owner") | |
| 432 | + | ||
| 433 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", basic(tok)) | |
| 434 | + | assert.Equal(t, http.StatusOK, status) | |
| 435 | + | assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body) | |
| 436 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", "") | |
| 437 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 438 | + | ||
| 439 | + | // The tarball: a gzip tar with one top-level directory, the contents of the | |
| 440 | + | // commit, for a branch, a tag and a full id alike. | |
| 441 | + | c.mustGit(c.wt, "tag", "v1") | |
| 442 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1") | |
| 443 | + | for _, ref := range []string{"main", "v1", sha, ""} { | |
| 444 | + | status, body, hdr := c.get("/-/github/api/repos/owner/proj/tarball/"+ref, basic(tok)) | |
| 445 | + | require.Equal(t, http.StatusOK, status, ref) | |
| 446 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz") | |
| 447 | + | files := untar(t, body) | |
| 448 | + | assert.Equal(t, "hello\n", files["owner-proj-"+sha[:7]+"/README"], ref) | |
| 449 | + | } | |
| 450 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj/tarball/nope", basic(tok)) | |
| 451 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 452 | + | assert.JSONEq(t, notFound, body) | |
| 453 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "") | |
| 454 | + | assert.Equal(t, http.StatusNotFound, status, "private and anonymous") | |
| 455 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |
| 456 | + | assert.Equal(t, http.StatusOK, status, "any principal that reads the repository") | |
| 457 | + | ||
| 458 | + | // A public repository answers anonymous callers. | |
| 459 | + | vis := domain.VisibilityPublic | |
| 460 | + | _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis}) | |
| 461 | + | require.NoError(t, err) | |
| 462 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", "") | |
| 463 | + | require.Equal(t, http.StatusOK, status) | |
| 464 | + | assert.Contains(t, body, `"visibility":"public"`) | |
| 465 | + | assert.NotContains(t, body, `"permissions"`, "an anonymous caller has none") | |
| 466 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "") | |
| 467 | + | assert.Equal(t, http.StatusOK, status) | |
| 468 | + | ||
| 469 | + | // Everything else is GitHub's 404, whatever the method. | |
| 470 | + | for _, p := range []string{ | |
| 471 | + | "/-/github/api", "/-/github/api/", "/-/github/api/user", "/-/github/api/graphql", | |
| 472 | + | "/-/github/api/repos/owner/proj/issues", "/-/github/api/repos/owner/proj/git/refs/heads/main", | |
| 473 | + | "/-/github/api/repos/owner/proj/zipball/main", "/-/github/api/repos/owner/proj/actions/runs", | |
| 474 | + | "/-/github/api/repos/owner", "/-/github/api/rate_limit", "/-/github/api/repos/owner/proj/contents/README", | |
| 475 | + | } { | |
| 476 | + | status, body, hdr := c.get(p, basic(tok)) | |
| 477 | + | assert.Equal(t, http.StatusNotFound, status, p) | |
| 478 | + | assert.JSONEq(t, notFound, body, p) | |
| 479 | + | assert.Contains(t, hdr.Get("Content-Type"), "application/json", p) | |
| 480 | + | } | |
| 481 | + | req, err := http.NewRequest(http.MethodPost, c.url("/-/github/api/repos/owner/proj"), strings.NewReader("{}")) | |
| 482 | + | require.NoError(t, err) | |
| 483 | + | resp, err := http.DefaultClient.Do(req) | |
| 484 | + | require.NoError(t, err) | |
| 485 | + | _ = resp.Body.Close() | |
| 486 | + | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |
| 487 | + | ||
| 488 | + | // A bad credential is refused as GitHub does. | |
| 489 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", basic("ermitegh_"+strings.Repeat("A", 43))) | |
| 490 | + | assert.Equal(t, http.StatusUnauthorized, status) | |
| 491 | + | assert.Contains(t, body, "Bad credentials") | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | func untar(t *testing.T, gz string) map[string]string { | |
| 495 | + | t.Helper() | |
| 496 | + | zr, err := gzip.NewReader(strings.NewReader(gz)) | |
| 497 | + | require.NoError(t, err) | |
| 498 | + | tr := tar.NewReader(zr) | |
| 499 | + | files := map[string]string{} | |
| 500 | + | for { | |
| 501 | + | h, err := tr.Next() | |
| 502 | + | if err == io.EOF { | |
| 503 | + | break | |
| 504 | + | } | |
| 505 | + | require.NoError(t, err) | |
| 506 | + | if h.Typeflag != tar.TypeReg { | |
| 507 | + | continue | |
| 508 | + | } | |
| 509 | + | var b bytes.Buffer | |
| 510 | + | _, err = io.Copy(&b, tr) | |
| 511 | + | require.NoError(t, err) | |
| 512 | + | files[h.Name] = b.String() | |
| 513 | + | } | |
| 514 | + | return files | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | func (c *ciHarness) resolve(runner *fakeRunner, job *pb.Job, uses ...string) []*pb.ResolvedAction { | |
| 518 | + | c.t.Helper() | |
| 519 | + | res, err := c.resolveErr(runner, job, uses...) | |
| 520 | + | require.NoError(c.t, err) | |
| 521 | + | return res | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func (c *ciHarness) resolveErr(runner *fakeRunner, job *pb.Job, uses ...string) ([]*pb.ResolvedAction, error) { | |
| 525 | + | refs := make([]*pb.ActionRef, 0, len(uses)) | |
| 526 | + | for _, u := range uses { | |
| 527 | + | refs = append(refs, &pb.ActionRef{Uses: u}) | |
| 528 | + | } | |
| 529 | + | res, err := runner.jobAPI(job).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: job.GetJobId(), Refs: refs})) | |
| 530 | + | if err != nil { | |
| 531 | + | return nil, err | |
| 532 | + | } | |
| 533 | + | return res.Msg.GetActions(), nil | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | const ( | |
| 537 | + | shaMain = "1111111111111111111111111111111111111111" | |
| 538 | + | shaTag = "2222222222222222222222222222222222222222" | |
| 539 | + | shaTagObj = "3333333333333333333333333333333333333333" | |
| 540 | + | shaPeeled = "4444444444444444444444444444444444444444" | |
| 541 | + | shaOther = "5555555555555555555555555555555555555555" | |
| 542 | + | ) | |
| 543 | + | ||
| 544 | + | // AC2, AC4: github.com actions are resolved with ls-remote (tag before branch, | |
| 545 | + | // the peeled commit of an annotated tag, a full id as it is), ermite | |
| 546 | + | // repositories win, and nothing reaches the network but through the fake. | |
| 547 | + | func TestResolveActionsFromGitHub(t *testing.T) { | |
| 548 | + | remote := newFakeRemote(t) | |
| 549 | + | remote.set("actions/checkout", | |
| 550 | + | ports.RemoteRef{Name: "refs/heads/main", ID: shaMain}, | |
| 551 | + | ports.RemoteRef{Name: "refs/tags/v4", ID: shaTagObj, Peeled: shaPeeled}, | |
| 552 | + | ports.RemoteRef{Name: "refs/tags/v3", ID: shaTag}, | |
| 553 | + | ports.RemoteRef{Name: "refs/heads/v3", ID: shaOther}, // a tag and a branch of the same name: the tag | |
| 554 | + | ) | |
| 555 | + | c := newExecHarness(t, remote, nil) | |
| 556 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 557 | + | job := c.startJob(runner) | |
| 558 | + | c.heartbeat(runner, job) | |
| 559 | + | ||
| 560 | + | got := c.resolve(runner, job, | |
| 561 | + | "actions/checkout@v4", "actions/checkout@main", "actions/checkout@v3", "Actions/Checkout/sub/dir@v4", | |
| 562 | + | "actions/checkout@"+strings.ToUpper(shaOther), "nobody/unknown-action@"+shaOther, "actions/checkout@v4", | |
| 563 | + | ) | |
| 564 | + | require.Len(t, got, 7) | |
| 565 | + | assert.Equal(t, "actions/checkout@v4", got[0].GetRef()) | |
| 566 | + | assert.Equal(t, "actions/checkout", got[0].GetNameWithOwner()) | |
| 567 | + | assert.Equal(t, shaPeeled, got[0].GetResolvedSha(), "an annotated tag gives its commit") | |
| 568 | + | assert.Equal(t, "https://codeload.github.com/actions/checkout/tar.gz/"+shaPeeled, got[0].GetTarballUrl()) | |
| 569 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got[0].GetSource()) | |
| 570 | + | assert.Empty(t, got[0].GetToken(), "no token goes to github.com") | |
| 571 | + | assert.Empty(t, got[0].GetError()) | |
| 572 | + | assert.Equal(t, shaMain, got[1].GetResolvedSha(), "a branch") | |
| 573 | + | assert.Equal(t, shaTag, got[2].GetResolvedSha(), "a tag wins over a branch of the same name") | |
| 574 | + | assert.Equal(t, "Actions/Checkout/sub/dir@v4", got[3].GetRef(), "the reference is returned as written") | |
| 575 | + | assert.Equal(t, "Actions/Checkout", got[3].GetNameWithOwner()) | |
| 576 | + | assert.Equal(t, "sub/dir", got[3].GetPath()) | |
| 577 | + | assert.Equal(t, shaPeeled, got[3].GetResolvedSha(), "owner and repository are case-insensitive") | |
| 578 | + | assert.Equal(t, strings.ToLower(shaOther), got[4].GetResolvedSha(), "a full commit id is not checked") | |
| 579 | + | assert.Equal(t, strings.ToLower(shaOther), got[5].GetResolvedSha(), "not even for a repository ls-remote does not know") | |
| 580 | + | assert.Equal(t, got[0].GetResolvedSha(), got[6].GetResolvedSha(), "duplicates are answered one by one") | |
| 581 | + | assert.Equal(t, 1, remote.count(), "one ls-remote per repository and call") | |
| 582 | + | ||
| 583 | + | // GitHub's words for what cannot be resolved; the call itself succeeds. | |
| 584 | + | bad := c.resolve(runner, job, "actions/checkout@nope", "nobody/missing@v1", "noslash@v1", "a/b", "a/b@", "a/b@c@d") | |
| 585 | + | assert.Equal(t, "Unable to resolve action `actions/checkout@nope`, unable to find version `nope`", bad[0].GetError()) | |
| 586 | + | assert.Equal(t, "Unable to resolve action `nobody/missing@v1`, repository not found", bad[1].GetError()) | |
| 587 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'", bad[2].GetError()) | |
| 588 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'a/b'", bad[3].GetError()) | |
| 589 | + | assert.NotEmpty(t, bad[4].GetError()) | |
| 590 | + | assert.NotEmpty(t, bad[5].GetError()) | |
| 591 | + | for _, b := range bad { | |
| 592 | + | assert.Empty(t, b.GetResolvedSha(), b.GetRef()) | |
| 593 | + | assert.Empty(t, b.GetTarballUrl(), b.GetRef()) | |
| 594 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, b.GetSource(), b.GetRef()) | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | // github.com unreachable is an error of the call, not of one action. | |
| 598 | + | remote.err["flaky/action"] = domain.ErrUnavailable | |
| 599 | + | _, err := c.resolveErr(runner, job, "flaky/action@v1") | |
| 600 | + | assert.Equal(t, connect.CodeUnavailable, ciErrCode(err)) | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | func TestResolveActionsRejectsWhatTheRunnerHandles(t *testing.T) { | |
| 604 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 605 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 606 | + | job := c.startJob(runner) | |
| 607 | + | c.heartbeat(runner, job) | |
| 608 | + | for _, u := range []string{"./local", "./.github/actions/x", "docker://alpine:3", "actions/checkout@v4"} { | |
| 609 | + | _, err := c.resolveErr(runner, job, "actions/checkout@"+shaMain, u) | |
| 610 | + | if u == "actions/checkout@v4" { | |
| 611 | + | assert.NoError(t, err) | |
| 612 | + | continue | |
| 613 | + | } | |
| 614 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), u) | |
| 615 | + | } | |
| 616 | + | many := make([]string, 101) | |
| 617 | + | for i := range many { | |
| 618 | + | many[i] = "a/b@" + shaMain | |
| 619 | + | } | |
| 620 | + | _, err := c.resolveErr(runner, job, many...) | |
| 621 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "at most 100") | |
| 622 | + | many = many[:100] | |
| 623 | + | got, err := c.resolveErr(runner, job, many...) | |
| 624 | + | require.NoError(t, err) | |
| 625 | + | assert.Len(t, got, 100) | |
| 626 | + | got, err = c.resolveErr(runner, job) | |
| 627 | + | require.NoError(t, err) | |
| 628 | + | assert.Empty(t, got) | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | // AC2: another job's token, a repository token, an ended job and a bad token | |
| 632 | + | // are refused. | |
| 633 | + | func TestResolveActionsAuthentication(t *testing.T) { | |
| 634 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |
| 635 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 636 | + | job := c.startJob(runner) | |
| 637 | + | c.heartbeat(runner, job) | |
| 638 | + | call := func(secret, id string) error { | |
| 639 | + | _, err := c.api(secret).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: id, Refs: []*pb.ActionRef{{Uses: "a/b@" + shaMain}}})) | |
| 640 | + | return err | |
| 641 | + | } | |
| 642 | + | assert.NoError(t, call(job.GetJobToken(), job.GetJobId())) | |
| 643 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(job.GetGithubToken(), job.GetJobId())), "the repository token is not a job token") | |
| 644 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(runner.secret, job.GetJobId())), "nor the runner token") | |
| 645 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("ermitejob_"+strings.Repeat("A", 43), job.GetJobId()))) | |
| 646 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("", job.GetJobId()))) | |
| 647 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(call(job.GetJobToken(), "7-0-0")), "a token of another job") | |
| 648 | + | ||
| 649 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 650 | + | err := call(job.GetJobToken(), job.GetJobId()) | |
| 651 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "an ended job") | |
| 652 | + | var ce *connect.Error | |
| 653 | + | require.ErrorAs(t, err, &ce) | |
| 654 | + | assert.NotEmpty(t, ce.Details(), "with a JobEnded detail") | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | // AC4: an action of a repository of ermite wins over github.com, is downloaded | |
| 658 | + | // with a per-job read token for that repository only, and the same ref gives the | |
| 659 | + | // same commit to every job of the attempt (and a new one to a re-run). | |
| 660 | + | func TestResolveActionsFromErmite(t *testing.T) { | |
| 661 | + | remote := newFakeRemote(t) | |
| 662 | + | remote.set("owner/myaction", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite | |
| 663 | + | c := newExecHarness(t, remote, nil) | |
| 664 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js") | |
| 665 | + | tag := c.bareGit("myaction", "rev-parse", "HEAD") | |
| 666 | + | c.mustGit(c.repoDir("myaction"), "tag", "v1") | |
| 667 | + | c.mustGit(c.repoDir("myaction"), "tag", "-a", "-m", "annotated", "v2") | |
| 668 | + | c.mustGit(c.repoDir("myaction"), "branch", "dev") | |
| 669 | + | c.seed("hidden", domain.VisibilityPrivate, "secret.txt") | |
| 670 | + | ||
| 671 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 672 | + | c.push("main", map[string]string{".github/workflows/m.yml": "on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"}, "") | |
| 673 | + | a, b := runner.await(), runner.await() | |
| 674 | + | c.heartbeat(runner, a) | |
| 675 | + | c.heartbeat(runner, b) | |
| 676 | + | ||
| 677 | + | got := c.resolve(runner, a, "owner/myaction@v1", "owner/MyAction/sub@v2", "owner/myaction@dev", "owner/myaction@"+tag, "owner/myaction@gone", "owner/nothere@v1") | |
| 678 | + | require.Len(t, got, 6) | |
| 679 | + | for i := range 4 { | |
| 680 | + | assert.Empty(t, got[i].GetError(), got[i].GetRef()) | |
| 681 | + | assert.Equal(t, tag, got[i].GetResolvedSha(), got[i].GetRef()) | |
| 682 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[i].GetSource(), got[i].GetRef()) | |
| 683 | + | assert.True(t, domain.IsRepoToken(got[i].GetToken()), got[i].GetRef()) | |
| 684 | + | assert.Equal(t, "http://example.test/-/github/api/repos/owner/myaction/tarball/"+tag, got[i].GetTarballUrl()) | |
| 685 | + | } | |
| 686 | + | assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner()) | |
| 687 | + | assert.Equal(t, "sub", got[1].GetPath()) | |
| 688 | + | assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per repository and job") | |
| 689 | + | assert.Contains(t, got[4].GetError(), "unable to find version `gone`") | |
| 690 | + | assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all") | |
| 691 | + | assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`") | |
| 692 | + | assert.True(t, remote.asked("owner/nothere"), "an owner repository that ermite lacks falls through to github.com") | |
| 693 | + | ||
| 694 | + | // The archive token downloads that repository, whatever the private | |
| 695 | + | // repository of the run's token reaches, and nothing else. | |
| 696 | + | arch := got[0].GetToken() | |
| 697 | + | status, body, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch)) | |
| 698 | + | require.Equal(t, http.StatusOK, status) | |
| 699 | + | assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"]) | |
| 700 | + | status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch)) | |
| 701 | + | assert.Equal(t, http.StatusOK, status, "and clones it") | |
| 702 | + | status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch)) | |
| 703 | + | assert.Equal(t, http.StatusForbidden, status, "never writes") | |
| 704 | + | status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch)) | |
| 705 | + | assert.Equal(t, http.StatusNotFound, status, "another private repository") | |
| 706 | + | status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch)) | |
| 707 | + | assert.Equal(t, http.StatusNotFound, status, "not even the repository of the run") | |
| 708 | + | status, _, _ = c.get("/-/github/api/repos/owner/hidden", basic(arch)) | |
| 709 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 710 | + | // The job's own token does not read the action repository: this is why the | |
| 711 | + | // archive has a token of its own. | |
| 712 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(a.GetGithubToken())) | |
| 713 | + | assert.Equal(t, http.StatusNotFound, status) | |
| 714 | + | ||
| 715 | + | // Per job: the same commit, another token. | |
| 716 | + | other := c.resolve(runner, b, "owner/myaction@v1") | |
| 717 | + | assert.Equal(t, tag, other[0].GetResolvedSha()) | |
| 718 | + | assert.NotEqual(t, arch, other[0].GetToken(), "tokens are not shared between jobs") | |
| 719 | + | ||
| 720 | + | // Stable for the attempt: the branch moves, the answer does not. | |
| 721 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "scratch") | |
| 722 | + | c.writeFile(c.wt, "later.txt", []byte("later")) | |
| 723 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/myaction.git"), "scratch:refs/heads/dev", "-f") | |
| 724 | + | moved := c.bareGit("myaction", "rev-parse", "dev") | |
| 725 | + | require.NotEqual(t, tag, moved) | |
| 726 | + | again := c.resolve(runner, b, "owner/myaction@dev") | |
| 727 | + | assert.Equal(t, tag, again[0].GetResolvedSha(), "the first resolution of the attempt stands") | |
| 728 | + | ||
| 729 | + | // The token goes with its job. | |
| 730 | + | runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 731 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch)) | |
| 732 | + | assert.Equal(t, http.StatusUnauthorized, status, "revoked with the job") | |
| 733 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(other[0].GetToken())) | |
| 734 | + | assert.Equal(t, http.StatusOK, status, "the other job's token lives on") | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | func ptrRef(name, id string) ports.RemoteRef { return ports.RemoteRef{Name: name, ID: id} } | |
| 738 | + | ||
| 739 | + | // A re-run resolves again, and github.com is asked once for a repository in | |
| 740 | + | // each attempt. | |
| 741 | + | func TestResolveActionsAreCachedPerAttempt(t *testing.T) { | |
| 742 | + | remote := newFakeRemote(t) | |
| 743 | + | remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaMain)) | |
| 744 | + | c := newExecHarness(t, remote, nil) | |
| 745 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 746 | + | job := c.startJob(runner) | |
| 747 | + | c.heartbeat(runner, job) | |
| 748 | + | ||
| 749 | + | assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha()) | |
| 750 | + | remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaOther)) | |
| 751 | + | assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha(), "stable within the attempt") | |
| 752 | + | assert.Equal(t, 1, remote.count(), "the second call is answered from the attempt's resolution") | |
| 753 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 754 | + | ||
| 755 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |
| 756 | + | require.NoError(t, err) | |
| 757 | + | job2 := runner.await() | |
| 758 | + | assert.Equal(t, int32(2), job2.GetAttempt()) | |
| 759 | + | c.heartbeat(runner, job2) | |
| 760 | + | assert.Equal(t, shaOther, c.resolve(runner, job2, "actions/setup-go@v5")[0].GetResolvedSha(), "a re-run resolves again") | |
| 761 | + | assert.NotEqual(t, job.GetGithubToken(), job2.GetGithubToken()) | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | // AC2: no secret reaches the logs of the server: the repository token, the | |
| 765 | + | // archive tokens and the job token, whatever happened with them. | |
| 766 | + | func TestRepositoryTokensAreNeverLogged(t *testing.T) { | |
| 767 | + | remote := newFakeRemote(t) | |
| 768 | + | c := newExecHarness(t, remote, nil) | |
| 769 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |
| 770 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 771 | + | job := c.startJob(runner) | |
| 772 | + | c.heartbeat(runner, job) | |
| 773 | + | tok := job.GetGithubToken() | |
| 774 | + | ||
| 775 | + | arch := c.resolve(runner, job, "owner/myaction@main")[0] | |
| 776 | + | require.NotEmpty(t, arch.GetToken()) | |
| 777 | + | _, err := c.clone(tok, c.url("/owner/proj")) | |
| 778 | + | require.NoError(t, err) | |
| 779 | + | c.get("/-/github/api/repos/owner/proj", basic(tok)) | |
| 780 | + | c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+tok) | |
| 781 | + | c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok)) | |
| 782 | + | c.get("/owner/secret/info/refs?service=git-upload-pack", basic("ermitegh_"+strings.Repeat("Z", 43))) // a wrong one | |
| 783 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 784 | + | c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok)) | |
| 785 | + | ||
| 786 | + | logs := c.logs.String() | |
| 787 | + | require.NotEmpty(t, logs) | |
| 788 | + | for _, secret := range []string{tok, arch.GetToken(), job.GetJobToken(), base64.StdEncoding.EncodeToString([]byte("x-access-token:" + tok))} { | |
| 789 | + | assert.NotContains(t, logs, secret) | |
| 790 | + | } | |
| 791 | + | assert.NotContains(t, logs, strings.Repeat("Z", 43)) | |
| 792 | + | } | |
| @@ -0,0 +1,792 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "archive/tar" | |||
| 7 | + | "bytes" | |||
| 8 | + | "compress/gzip" | |||
| 9 | + | "context" | |||
| 10 | + | "encoding/base64" | |||
| 11 | + | "encoding/json" | |||
| 12 | + | "io" | |||
| 13 | + | "net/http" | |||
| 14 | + | "path/filepath" | |||
| 15 | + | "slices" | |||
| 16 | + | "strings" | |||
| 17 | + | "sync" | |||
| 18 | + | "testing" | |||
| 19 | + | "time" | |||
| 20 | + | ||||
| 21 | + | "connectrpc.com/connect" | |||
| 22 | + | "github.com/stretchr/testify/assert" | |||
| 23 | + | "github.com/stretchr/testify/mock" | |||
| 24 | + | "github.com/stretchr/testify/require" | |||
| 25 | + | ||||
| 26 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 27 | + | "bnema.dev/ermite/internal/domain" | |||
| 28 | + | "bnema.dev/ermite/internal/ports" | |||
| 29 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 30 | + | "bnema.dev/ermite/internal/usecase" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // CI-2 execution (phase E1): the job repository token, the git URLs of the | |||
| 34 | + | // owner, the REST subset and the resolution of actions. | |||
| 35 | + | ||||
| 36 | + | // fakeRemote stands in for github.com: no test reaches the network. It is the | |||
| 37 | + | // generated mock of ports.GitRemote answering from a table the test edits. | |||
| 38 | + | type fakeRemote struct { | |||
| 39 | + | m *mocks.GitRemote | |||
| 40 | + | mu sync.Mutex | |||
| 41 | + | refs map[string][]ports.RemoteRef | |||
| 42 | + | err map[string]error | |||
| 43 | + | calls []string | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func newFakeRemote(t *testing.T) *fakeRemote { | |||
| 47 | + | t.Helper() | |||
| 48 | + | f := &fakeRemote{m: mocks.NewGitRemote(t), refs: map[string][]ports.RemoteRef{}, err: map[string]error{}} | |||
| 49 | + | f.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 50 | + | func(_ context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |||
| 51 | + | f.mu.Lock() | |||
| 52 | + | defer f.mu.Unlock() | |||
| 53 | + | key := strings.ToLower(owner + "/" + repo) | |||
| 54 | + | f.calls = append(f.calls, key) | |||
| 55 | + | if err := f.err[key]; err != nil { | |||
| 56 | + | return nil, err | |||
| 57 | + | } | |||
| 58 | + | refs, ok := f.refs[key] | |||
| 59 | + | if !ok { | |||
| 60 | + | return nil, domain.ErrNotFound | |||
| 61 | + | } | |||
| 62 | + | return refs, nil | |||
| 63 | + | }).Maybe() | |||
| 64 | + | return f | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (f *fakeRemote) set(repo string, refs ...ports.RemoteRef) { | |||
| 68 | + | f.mu.Lock() | |||
| 69 | + | defer f.mu.Unlock() | |||
| 70 | + | f.refs[repo] = refs | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (f *fakeRemote) asked(key string) bool { | |||
| 74 | + | f.mu.Lock() | |||
| 75 | + | defer f.mu.Unlock() | |||
| 76 | + | return slices.Contains(f.calls, key) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (f *fakeRemote) count() int { | |||
| 80 | + | f.mu.Lock() | |||
| 81 | + | defer f.mu.Unlock() | |||
| 82 | + | return len(f.calls) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // newExecHarness is newCIHarness with a fake github.com. | |||
| 86 | + | func newExecHarness(t *testing.T, remote *fakeRemote, tweak func(*usecase.CIConfig)) *ciHarness { | |||
| 87 | + | t.Helper() | |||
| 88 | + | h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci, o.remoteLister = tweak, remote.m }) | |||
| 89 | + | c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"} | |||
| 90 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate}) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | c.wt = h.workTree("main") | |||
| 93 | + | return c | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // startJob pushes a workflow and returns the job given to a runner. | |||
| 97 | + | func (c *ciHarness) startJob(runner *fakeRunner) *pb.Job { | |||
| 98 | + | c.t.Helper() | |||
| 99 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0], "README": "hello\n"}, "") | |||
| 100 | + | return runner.await() | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (c *ciHarness) heartbeat(runner *fakeRunner, job *pb.Job) { | |||
| 104 | + | c.t.Helper() | |||
| 105 | + | _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()})) | |||
| 106 | + | require.NoError(c.t, err) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // checkoutHeader is what actions/checkout sets in http.extraheader. | |||
| 110 | + | func checkoutHeader(token string) string { | |||
| 111 | + | return "AUTHORIZATION: basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // clone clones url as actions/checkout does: the token in http.extraheader. | |||
| 115 | + | func (c *ciHarness) clone(token, url string, args ...string) (string, error) { | |||
| 116 | + | dest := filepath.Join(c.t.TempDir(), "w") | |||
| 117 | + | a := []string{} | |||
| 118 | + | if token != "" { | |||
| 119 | + | a = append(a, "-c", "http.extraheader="+checkoutHeader(token)) | |||
| 120 | + | } | |||
| 121 | + | a = append(a, "clone", "-q") | |||
| 122 | + | a = append(a, args...) | |||
| 123 | + | a = append(a, url, dest) | |||
| 124 | + | return c.git(c.t.TempDir(), a...) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (c *ciHarness) get(path, authorization string) (int, string, http.Header) { | |||
| 128 | + | c.t.Helper() | |||
| 129 | + | req, err := http.NewRequest(http.MethodGet, c.url(path), nil) | |||
| 130 | + | require.NoError(c.t, err) | |||
| 131 | + | if authorization != "" { | |||
| 132 | + | req.Header.Set("Authorization", authorization) | |||
| 133 | + | } | |||
| 134 | + | resp, err := http.DefaultClient.Do(req) | |||
| 135 | + | require.NoError(c.t, err) | |||
| 136 | + | defer func() { _ = resp.Body.Close() }() | |||
| 137 | + | b, err := io.ReadAll(resp.Body) | |||
| 138 | + | require.NoError(c.t, err) | |||
| 139 | + | return resp.StatusCode, string(b), resp.Header | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func basic(token string) string { | |||
| 143 | + | return "Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // AC3: the job gets a repository token at its assignment, in the Job and not in | |||
| 147 | + | // the contexts; it clones its repository, public or private, over the URL | |||
| 148 | + | // github.repository gives, with and without .git, the way checkout does. | |||
| 149 | + | func TestJobTokenClonesTheRepositoryOfTheRun(t *testing.T) { | |||
| 150 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 151 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 152 | + | job := c.startJob(runner) | |||
| 153 | + | tok := job.GetGithubToken() | |||
| 154 | + | require.True(t, domain.IsRepoToken(tok), "ermitegh_ and 43 base64url characters") | |||
| 155 | + | assert.NotEqual(t, job.GetJobToken(), tok) | |||
| 156 | + | assert.NotContains(t, job.GetContexts(), tok, "the token does not sit in the contexts the runner stores") | |||
| 157 | + | assert.NotContains(t, job.GetWorkflowYaml(), tok) | |||
| 158 | + | c.heartbeat(runner, job) | |||
| 159 | + | ||||
| 160 | + | // Private: checkout's clone, shallow, without .git. | |||
| 161 | + | out, err := c.clone(tok, c.url("/owner/proj"), "--depth", "1") | |||
| 162 | + | require.NoError(t, err, out) | |||
| 163 | + | out, err = c.clone(tok, c.url("/owner/proj.git")) | |||
| 164 | + | require.NoError(t, err, out) | |||
| 165 | + | out, err = c.clone(tok, c.url("/proj.git")) | |||
| 166 | + | require.NoError(t, err, "the plain URL works too: "+out) | |||
| 167 | + | // Without a token a private repository asks for credentials. | |||
| 168 | + | _, err = c.clone("", c.url("/owner/proj")) | |||
| 169 | + | require.Error(t, err) | |||
| 170 | + | // Another owner has no repositories. | |||
| 171 | + | out, err = c.clone(tok, c.url("/someone/proj")) | |||
| 172 | + | require.Error(t, err, out) | |||
| 173 | + | ||||
| 174 | + | // The same token and the other schemes, on the wire. | |||
| 175 | + | for _, h := range []string{basic(tok), "Bearer " + tok, "token " + tok} { | |||
| 176 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", h) | |||
| 177 | + | assert.Equal(t, http.StatusOK, status, h[:12]) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Public: anyone clones, the token too. | |||
| 181 | + | vis := domain.VisibilityPublic | |||
| 182 | + | _, err = c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis}) | |||
| 183 | + | require.NoError(t, err) | |||
| 184 | + | out, err = c.clone("", c.url("/owner/proj")) | |||
| 185 | + | require.NoError(t, err, out) | |||
| 186 | + | out, err = c.clone(tok, c.url("/owner/proj")) | |||
| 187 | + | require.NoError(t, err, out) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (c *ciHarness) h(name string) domain.RepoName { return c.name(name) } | |||
| 191 | + | ||||
| 192 | + | // AC3: refused on another private repository, for push, and for the REST | |||
| 193 | + | // subset of another repository; never a stored token for the API. | |||
| 194 | + | func TestJobTokenReadsItsRepositoryOnly(t *testing.T) { | |||
| 195 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 196 | + | c.seed("secret", domain.VisibilityPrivate, "a.txt") | |||
| 197 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 198 | + | job := c.startJob(runner) | |||
| 199 | + | tok := job.GetGithubToken() | |||
| 200 | + | c.heartbeat(runner, job) | |||
| 201 | + | ||||
| 202 | + | out, err := c.clone(tok, c.url("/owner/secret")) | |||
| 203 | + | require.Error(t, err) | |||
| 204 | + | assert.Contains(t, out, "not found", "the other private repository does not show itself") | |||
| 205 | + | status, _, _ := c.get("/owner/secret/info/refs?service=git-upload-pack", basic(tok)) | |||
| 206 | + | assert.Equal(t, http.StatusNotFound, status) | |||
| 207 | + | status, _, _ = c.get("/-/github/api/repos/owner/secret", basic(tok)) | |||
| 208 | + | assert.Equal(t, http.StatusNotFound, status) | |||
| 209 | + | ||||
| 210 | + | // Push: the receive-pack advertisement and the push itself are 403. | |||
| 211 | + | status, _, _ = c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok)) | |||
| 212 | + | assert.Equal(t, http.StatusForbidden, status) | |||
| 213 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "x") | |||
| 214 | + | out, err = c.git(c.wt, "-c", "http.extraheader="+checkoutHeader(tok), "push", c.url("/owner/proj"), "x:refs/heads/x") | |||
| 215 | + | require.Error(t, err) | |||
| 216 | + | assert.Contains(t, out, "403") | |||
| 217 | + | assert.Empty(t, c.bareGit("proj", "branch", "--list", "x"), "nothing was pushed") | |||
| 218 | + | ||||
| 219 | + | // The API takes it as the principal it is (repo:read on one repository): it | |||
| 220 | + | // lists that repository and no other, and writes nothing. | |||
| 221 | + | list, err := c.api(tok).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{})) | |||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | require.Len(t, list.Msg.GetRepos(), 1) | |||
| 224 | + | assert.Equal(t, "proj", list.Msg.GetRepos()[0].GetName()) | |||
| 225 | + | _, err = c.api(tok).repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"})) | |||
| 226 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |||
| 227 | + | _, err = c.api(tok).token.CreateToken(c.ctx, connect.NewRequest(&pb.CreateTokenRequest{Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}})) | |||
| 228 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |||
| 229 | + | _, err = c.api(tok).token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |||
| 230 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err)) | |||
| 231 | + | _, err = c.api(tok).run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |||
| 232 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no ci:write") | |||
| 233 | + | ||||
| 234 | + | // A made-up token of the right shape is refused, and so are wrong ones. | |||
| 235 | + | for _, bad := range []string{"ermitegh_" + strings.Repeat("A", 43), "ermitegh_short", tok + "x"} { | |||
| 236 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(bad)) | |||
| 237 | + | assert.Equal(t, http.StatusUnauthorized, status, bad) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // The token is not a stored token: ListTokens does not show it, token:admin | |||
| 242 | + | // does not manage it, and the principal it gives is read-only on one repository. | |||
| 243 | + | func TestJobTokenIsNotAStoredToken(t *testing.T) { | |||
| 244 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 245 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 246 | + | job := c.startJob(runner) | |||
| 247 | + | tok := job.GetGithubToken() | |||
| 248 | + | ||||
| 249 | + | before, err := c.root.token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{})) | |||
| 250 | + | require.NoError(t, err) | |||
| 251 | + | assert.Empty(t, before.Msg.GetTokens(), "no stored token exists") | |||
| 252 | + | ||||
| 253 | + | p, err := c.forge.Auth.Authenticate(c.ctx, tok) | |||
| 254 | + | require.NoError(t, err) | |||
| 255 | + | assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, p.Scopes()) | |||
| 256 | + | assert.Equal(t, []domain.RepoName{c.h("proj")}, p.Repos()) | |||
| 257 | + | assert.False(t, p.ExpiresAt().IsZero()) | |||
| 258 | + | assert.False(t, p.HasScope(domain.ScopeTokenAdmin)) | |||
| 259 | + | assert.False(t, p.IsRoot()) | |||
| 260 | + | ||||
| 261 | + | // Revoking by its id (the "job:" id the principal shows) finds nothing. | |||
| 262 | + | _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: strings.TrimPrefix(p.TokenID(), "job:")})) | |||
| 263 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |||
| 264 | + | _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: p.TokenID()})) | |||
| 265 | + | assert.Equal(t, connect.CodeNotFound, ciErrCode(err)) | |||
| 266 | + | _, err = c.forge.Auth.Authenticate(c.ctx, tok) | |||
| 267 | + | require.NoError(t, err, "still valid") | |||
| 268 | + | require.NoError(t, c.forge.Auth.Revalidate(c.ctx, p)) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // AC3: refused after the job ends, however it ends. | |||
| 272 | + | func TestJobTokenEndsWithTheJob(t *testing.T) { | |||
| 273 | + | cloneStatus := func(c *ciHarness, tok string) int { | |||
| 274 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok)) | |||
| 275 | + | return status | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | t.Run("CompleteJob", func(t *testing.T) { | |||
| 279 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 280 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 281 | + | job := c.startJob(runner) | |||
| 282 | + | tok := job.GetGithubToken() | |||
| 283 | + | p, err := c.forge.Auth.Authenticate(c.ctx, tok) | |||
| 284 | + | require.NoError(t, err) | |||
| 285 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |||
| 286 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |||
| 287 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok)) | |||
| 288 | + | assert.ErrorIs(t, c.forge.Auth.Revalidate(c.ctx, p), domain.ErrUnauthenticated, "a long operation stops") | |||
| 289 | + | out, err := c.clone(tok, c.url("/owner/proj")) | |||
| 290 | + | require.Error(t, err, out) | |||
| 291 | + | }) | |||
| 292 | + | ||||
| 293 | + | t.Run("cancel", func(t *testing.T) { | |||
| 294 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |||
| 295 | + | cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |||
| 296 | + | }) | |||
| 297 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 298 | + | job := c.startJob(runner) | |||
| 299 | + | c.heartbeat(runner, job) | |||
| 300 | + | tok := job.GetGithubToken() | |||
| 301 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |||
| 302 | + | _, err := c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |||
| 303 | + | require.NoError(t, err) | |||
| 304 | + | // The runner is told to stop, and the job concludes when it answers or | |||
| 305 | + | // when the grace ends: either way the token goes with it. | |||
| 306 | + | c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |||
| 307 | + | assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, c.runs()[0].GetStatus()) | |||
| 308 | + | }) | |||
| 309 | + | ||||
| 310 | + | t.Run("lost runner", func(t *testing.T) { | |||
| 311 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |||
| 312 | + | cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |||
| 313 | + | }) | |||
| 314 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 315 | + | job := c.startJob(runner) | |||
| 316 | + | c.heartbeat(runner, job) | |||
| 317 | + | tok := job.GetGithubToken() | |||
| 318 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, tok)) | |||
| 319 | + | c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |||
| 320 | + | assert.Contains(t, c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError(), "lost communication") | |||
| 321 | + | }) | |||
| 322 | + | ||||
| 323 | + | t.Run("assignment not acknowledged", func(t *testing.T) { | |||
| 324 | + | c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) { | |||
| 325 | + | cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 200*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond | |||
| 326 | + | }) | |||
| 327 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 328 | + | job := c.startJob(runner) | |||
| 329 | + | tok := job.GetGithubToken() | |||
| 330 | + | c.waitFor("the assignment is taken back", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized }) | |||
| 331 | + | again := runner.await() | |||
| 332 | + | assert.NotEqual(t, tok, again.GetGithubToken(), "the next assignment has its own token") | |||
| 333 | + | assert.Equal(t, http.StatusOK, cloneStatus(c, again.GetGithubToken())) | |||
| 334 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok), "the old one stays revoked") | |||
| 335 | + | }) | |||
| 336 | + | ||||
| 337 | + | t.Run("repository deleted", func(t *testing.T) { | |||
| 338 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 339 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 340 | + | job := c.startJob(runner) | |||
| 341 | + | tok := job.GetGithubToken() | |||
| 342 | + | _, err := c.forge.Auth.Authenticate(c.ctx, tok) | |||
| 343 | + | require.NoError(t, err) | |||
| 344 | + | require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("proj"))) | |||
| 345 | + | _, err = c.forge.Auth.Authenticate(c.ctx, tok) | |||
| 346 | + | assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the repository, its runs and their tokens are gone") | |||
| 347 | + | assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok)) | |||
| 348 | + | }) | |||
| 349 | + | ||||
| 350 | + | t.Run("expiry", func(t *testing.T) { | |||
| 351 | + | // The deadline of the job plus five minutes: a job of one minute. | |||
| 352 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 353 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 354 | + | c.push("main", map[string]string{".github/workflows/t.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n steps:\n - run: echo\n"}, "") | |||
| 355 | + | job := runner.await() | |||
| 356 | + | p, err := c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken()) | |||
| 357 | + | require.NoError(t, err) | |||
| 358 | + | want := time.Now().Add(time.Minute + domain.CITimeoutSlack + domain.RepoTokenGrace) | |||
| 359 | + | assert.WithinDuration(t, want, p.ExpiresAt(), 5*time.Second) | |||
| 360 | + | }) | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | // The retry of a poll gets the token of the first call, and a job token of the | |||
| 364 | + | // protocol never doubles as the repository token. | |||
| 365 | + | func TestJobTokenSurvivesAPollRetry(t *testing.T) { | |||
| 366 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 367 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 368 | + | c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "") | |||
| 369 | + | var first *pb.Job | |||
| 370 | + | c.waitFor("a job", func() bool { | |||
| 371 | + | j, err := runner.fetch("poll-1") | |||
| 372 | + | require.NoError(t, err) | |||
| 373 | + | first = j | |||
| 374 | + | return j != nil | |||
| 375 | + | }) | |||
| 376 | + | again, err := runner.fetch("poll-1") | |||
| 377 | + | require.NoError(t, err) | |||
| 378 | + | require.NotNil(t, again) | |||
| 379 | + | assert.Equal(t, first.GetJobToken(), again.GetJobToken()) | |||
| 380 | + | assert.Equal(t, first.GetGithubToken(), again.GetGithubToken()) | |||
| 381 | + | status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(first.GetJobToken())) | |||
| 382 | + | assert.Equal(t, http.StatusUnauthorized, status, "the runner's job token reads nothing") | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | // The REST subset: GitHub's shapes for what checkout and github-script call, | |||
| 386 | + | // GitHub's 404 for everything else. | |||
| 387 | + | func TestGitHubRESTSubset(t *testing.T) { | |||
| 388 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 389 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 390 | + | job := c.startJob(runner) | |||
| 391 | + | tok := job.GetGithubToken() | |||
| 392 | + | sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")) | |||
| 393 | + | notFound := `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}` | |||
| 394 | + | ||||
| 395 | + | // Private: the token reads it, anonymous sees nothing. | |||
| 396 | + | status, body, hdr := c.get("/-/github/api/repos/owner/proj", basic(tok)) | |||
| 397 | + | require.Equal(t, http.StatusOK, status, body) | |||
| 398 | + | assert.Contains(t, hdr.Get("Content-Type"), "application/json") | |||
| 399 | + | var repo map[string]any | |||
| 400 | + | require.NoError(t, json.Unmarshal([]byte(body), &repo)) | |||
| 401 | + | assert.Equal(t, "proj", repo["name"]) | |||
| 402 | + | assert.Equal(t, "owner/proj", repo["full_name"]) | |||
| 403 | + | assert.Equal(t, true, repo["private"]) | |||
| 404 | + | assert.Equal(t, "private", repo["visibility"]) | |||
| 405 | + | assert.Equal(t, "main", repo["default_branch"]) | |||
| 406 | + | assert.Equal(t, "http://example.test/owner/proj", repo["html_url"]) | |||
| 407 | + | assert.Equal(t, "http://example.test/owner/proj.git", repo["clone_url"]) | |||
| 408 | + | assert.Equal(t, "http://example.test/-/github/api/repos/owner/proj", repo["url"]) | |||
| 409 | + | assert.NotZero(t, repo["id"]) | |||
| 410 | + | assert.NotEmpty(t, repo["node_id"]) | |||
| 411 | + | owner, _ := repo["owner"].(map[string]any) | |||
| 412 | + | assert.Equal(t, "owner", owner["login"]) | |||
| 413 | + | assert.Equal(t, "User", owner["type"]) | |||
| 414 | + | assert.Equal(t, false, repo["fork"]) | |||
| 415 | + | assert.Equal(t, false, repo["archived"]) | |||
| 416 | + | assert.Equal(t, false, repo["has_issues"] == nil, "present") | |||
| 417 | + | assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["created_at"], "REST dates are ISO 8601") | |||
| 418 | + | assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["pushed_at"]) | |||
| 419 | + | perms, _ := repo["permissions"].(map[string]any) | |||
| 420 | + | assert.Equal(t, true, perms["pull"]) | |||
| 421 | + | assert.Equal(t, false, perms["push"], "the job token cannot write") | |||
| 422 | + | assert.Equal(t, false, perms["admin"]) | |||
| 423 | + | assert.NotContains(t, repo, "master_branch", "the push event has it, the REST object does not") | |||
| 424 | + | ||||
| 425 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", "") | |||
| 426 | + | assert.Equal(t, http.StatusNotFound, status, "a private repository does not show itself") | |||
| 427 | + | assert.JSONEq(t, notFound, body) | |||
| 428 | + | status, _, _ = c.get("/-/github/api/repos/Owner/PROJ", basic(tok)) | |||
| 429 | + | assert.Equal(t, http.StatusOK, status, "names are case-insensitive as on GitHub") | |||
| 430 | + | status, _, _ = c.get("/-/github/api/repos/other/proj", basic(tok)) | |||
| 431 | + | assert.Equal(t, http.StatusNotFound, status, "another owner") | |||
| 432 | + | ||||
| 433 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", basic(tok)) | |||
| 434 | + | assert.Equal(t, http.StatusOK, status) | |||
| 435 | + | assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body) | |||
| 436 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", "") | |||
| 437 | + | assert.Equal(t, http.StatusNotFound, status) | |||
| 438 | + | ||||
| 439 | + | // The tarball: a gzip tar with one top-level directory, the contents of the | |||
| 440 | + | // commit, for a branch, a tag and a full id alike. | |||
| 441 | + | c.mustGit(c.wt, "tag", "v1") | |||
| 442 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1") | |||
| 443 | + | for _, ref := range []string{"main", "v1", sha, ""} { | |||
| 444 | + | status, body, hdr := c.get("/-/github/api/repos/owner/proj/tarball/"+ref, basic(tok)) | |||
| 445 | + | require.Equal(t, http.StatusOK, status, ref) | |||
| 446 | + | assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz") | |||
| 447 | + | files := untar(t, body) | |||
| 448 | + | assert.Equal(t, "hello\n", files["owner-proj-"+sha[:7]+"/README"], ref) | |||
| 449 | + | } | |||
| 450 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj/tarball/nope", basic(tok)) | |||
| 451 | + | assert.Equal(t, http.StatusNotFound, status) | |||
| 452 | + | assert.JSONEq(t, notFound, body) | |||
| 453 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "") | |||
| 454 | + | assert.Equal(t, http.StatusNotFound, status, "private and anonymous") | |||
| 455 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken) | |||
| 456 | + | assert.Equal(t, http.StatusOK, status, "any principal that reads the repository") | |||
| 457 | + | ||||
| 458 | + | // A public repository answers anonymous callers. | |||
| 459 | + | vis := domain.VisibilityPublic | |||
| 460 | + | _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis}) | |||
| 461 | + | require.NoError(t, err) | |||
| 462 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", "") | |||
| 463 | + | require.Equal(t, http.StatusOK, status) | |||
| 464 | + | assert.Contains(t, body, `"visibility":"public"`) | |||
| 465 | + | assert.NotContains(t, body, `"permissions"`, "an anonymous caller has none") | |||
| 466 | + | status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "") | |||
| 467 | + | assert.Equal(t, http.StatusOK, status) | |||
| 468 | + | ||||
| 469 | + | // Everything else is GitHub's 404, whatever the method. | |||
| 470 | + | for _, p := range []string{ | |||
| 471 | + | "/-/github/api", "/-/github/api/", "/-/github/api/user", "/-/github/api/graphql", | |||
| 472 | + | "/-/github/api/repos/owner/proj/issues", "/-/github/api/repos/owner/proj/git/refs/heads/main", | |||
| 473 | + | "/-/github/api/repos/owner/proj/zipball/main", "/-/github/api/repos/owner/proj/actions/runs", | |||
| 474 | + | "/-/github/api/repos/owner", "/-/github/api/rate_limit", "/-/github/api/repos/owner/proj/contents/README", | |||
| 475 | + | } { | |||
| 476 | + | status, body, hdr := c.get(p, basic(tok)) | |||
| 477 | + | assert.Equal(t, http.StatusNotFound, status, p) | |||
| 478 | + | assert.JSONEq(t, notFound, body, p) | |||
| 479 | + | assert.Contains(t, hdr.Get("Content-Type"), "application/json", p) | |||
| 480 | + | } | |||
| 481 | + | req, err := http.NewRequest(http.MethodPost, c.url("/-/github/api/repos/owner/proj"), strings.NewReader("{}")) | |||
| 482 | + | require.NoError(t, err) | |||
| 483 | + | resp, err := http.DefaultClient.Do(req) | |||
| 484 | + | require.NoError(t, err) | |||
| 485 | + | _ = resp.Body.Close() | |||
| 486 | + | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |||
| 487 | + | ||||
| 488 | + | // A bad credential is refused as GitHub does. | |||
| 489 | + | status, body, _ = c.get("/-/github/api/repos/owner/proj", basic("ermitegh_"+strings.Repeat("A", 43))) | |||
| 490 | + | assert.Equal(t, http.StatusUnauthorized, status) | |||
| 491 | + | assert.Contains(t, body, "Bad credentials") | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | func untar(t *testing.T, gz string) map[string]string { | |||
| 495 | + | t.Helper() | |||
| 496 | + | zr, err := gzip.NewReader(strings.NewReader(gz)) | |||
| 497 | + | require.NoError(t, err) | |||
| 498 | + | tr := tar.NewReader(zr) | |||
| 499 | + | files := map[string]string{} | |||
| 500 | + | for { | |||
| 501 | + | h, err := tr.Next() | |||
| 502 | + | if err == io.EOF { | |||
| 503 | + | break | |||
| 504 | + | } | |||
| 505 | + | require.NoError(t, err) | |||
| 506 | + | if h.Typeflag != tar.TypeReg { | |||
| 507 | + | continue | |||
| 508 | + | } | |||
| 509 | + | var b bytes.Buffer | |||
| 510 | + | _, err = io.Copy(&b, tr) | |||
| 511 | + | require.NoError(t, err) | |||
| 512 | + | files[h.Name] = b.String() | |||
| 513 | + | } | |||
| 514 | + | return files | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | func (c *ciHarness) resolve(runner *fakeRunner, job *pb.Job, uses ...string) []*pb.ResolvedAction { | |||
| 518 | + | c.t.Helper() | |||
| 519 | + | res, err := c.resolveErr(runner, job, uses...) | |||
| 520 | + | require.NoError(c.t, err) | |||
| 521 | + | return res | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | func (c *ciHarness) resolveErr(runner *fakeRunner, job *pb.Job, uses ...string) ([]*pb.ResolvedAction, error) { | |||
| 525 | + | refs := make([]*pb.ActionRef, 0, len(uses)) | |||
| 526 | + | for _, u := range uses { | |||
| 527 | + | refs = append(refs, &pb.ActionRef{Uses: u}) | |||
| 528 | + | } | |||
| 529 | + | res, err := runner.jobAPI(job).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: job.GetJobId(), Refs: refs})) | |||
| 530 | + | if err != nil { | |||
| 531 | + | return nil, err | |||
| 532 | + | } | |||
| 533 | + | return res.Msg.GetActions(), nil | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | const ( | |||
| 537 | + | shaMain = "1111111111111111111111111111111111111111" | |||
| 538 | + | shaTag = "2222222222222222222222222222222222222222" | |||
| 539 | + | shaTagObj = "3333333333333333333333333333333333333333" | |||
| 540 | + | shaPeeled = "4444444444444444444444444444444444444444" | |||
| 541 | + | shaOther = "5555555555555555555555555555555555555555" | |||
| 542 | + | ) | |||
| 543 | + | ||||
| 544 | + | // AC2, AC4: github.com actions are resolved with ls-remote (tag before branch, | |||
| 545 | + | // the peeled commit of an annotated tag, a full id as it is), ermite | |||
| 546 | + | // repositories win, and nothing reaches the network but through the fake. | |||
| 547 | + | func TestResolveActionsFromGitHub(t *testing.T) { | |||
| 548 | + | remote := newFakeRemote(t) | |||
| 549 | + | remote.set("actions/checkout", | |||
| 550 | + | ports.RemoteRef{Name: "refs/heads/main", ID: shaMain}, | |||
| 551 | + | ports.RemoteRef{Name: "refs/tags/v4", ID: shaTagObj, Peeled: shaPeeled}, | |||
| 552 | + | ports.RemoteRef{Name: "refs/tags/v3", ID: shaTag}, | |||
| 553 | + | ports.RemoteRef{Name: "refs/heads/v3", ID: shaOther}, // a tag and a branch of the same name: the tag | |||
| 554 | + | ) | |||
| 555 | + | c := newExecHarness(t, remote, nil) | |||
| 556 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 557 | + | job := c.startJob(runner) | |||
| 558 | + | c.heartbeat(runner, job) | |||
| 559 | + | ||||
| 560 | + | got := c.resolve(runner, job, | |||
| 561 | + | "actions/checkout@v4", "actions/checkout@main", "actions/checkout@v3", "Actions/Checkout/sub/dir@v4", | |||
| 562 | + | "actions/checkout@"+strings.ToUpper(shaOther), "nobody/unknown-action@"+shaOther, "actions/checkout@v4", | |||
| 563 | + | ) | |||
| 564 | + | require.Len(t, got, 7) | |||
| 565 | + | assert.Equal(t, "actions/checkout@v4", got[0].GetRef()) | |||
| 566 | + | assert.Equal(t, "actions/checkout", got[0].GetNameWithOwner()) | |||
| 567 | + | assert.Equal(t, shaPeeled, got[0].GetResolvedSha(), "an annotated tag gives its commit") | |||
| 568 | + | assert.Equal(t, "https://codeload.github.com/actions/checkout/tar.gz/"+shaPeeled, got[0].GetTarballUrl()) | |||
| 569 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got[0].GetSource()) | |||
| 570 | + | assert.Empty(t, got[0].GetToken(), "no token goes to github.com") | |||
| 571 | + | assert.Empty(t, got[0].GetError()) | |||
| 572 | + | assert.Equal(t, shaMain, got[1].GetResolvedSha(), "a branch") | |||
| 573 | + | assert.Equal(t, shaTag, got[2].GetResolvedSha(), "a tag wins over a branch of the same name") | |||
| 574 | + | assert.Equal(t, "Actions/Checkout/sub/dir@v4", got[3].GetRef(), "the reference is returned as written") | |||
| 575 | + | assert.Equal(t, "Actions/Checkout", got[3].GetNameWithOwner()) | |||
| 576 | + | assert.Equal(t, "sub/dir", got[3].GetPath()) | |||
| 577 | + | assert.Equal(t, shaPeeled, got[3].GetResolvedSha(), "owner and repository are case-insensitive") | |||
| 578 | + | assert.Equal(t, strings.ToLower(shaOther), got[4].GetResolvedSha(), "a full commit id is not checked") | |||
| 579 | + | assert.Equal(t, strings.ToLower(shaOther), got[5].GetResolvedSha(), "not even for a repository ls-remote does not know") | |||
| 580 | + | assert.Equal(t, got[0].GetResolvedSha(), got[6].GetResolvedSha(), "duplicates are answered one by one") | |||
| 581 | + | assert.Equal(t, 1, remote.count(), "one ls-remote per repository and call") | |||
| 582 | + | ||||
| 583 | + | // GitHub's words for what cannot be resolved; the call itself succeeds. | |||
| 584 | + | bad := c.resolve(runner, job, "actions/checkout@nope", "nobody/missing@v1", "noslash@v1", "a/b", "a/b@", "a/b@c@d") | |||
| 585 | + | assert.Equal(t, "Unable to resolve action `actions/checkout@nope`, unable to find version `nope`", bad[0].GetError()) | |||
| 586 | + | assert.Equal(t, "Unable to resolve action `nobody/missing@v1`, repository not found", bad[1].GetError()) | |||
| 587 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'", bad[2].GetError()) | |||
| 588 | + | assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'a/b'", bad[3].GetError()) | |||
| 589 | + | assert.NotEmpty(t, bad[4].GetError()) | |||
| 590 | + | assert.NotEmpty(t, bad[5].GetError()) | |||
| 591 | + | for _, b := range bad { | |||
| 592 | + | assert.Empty(t, b.GetResolvedSha(), b.GetRef()) | |||
| 593 | + | assert.Empty(t, b.GetTarballUrl(), b.GetRef()) | |||
| 594 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, b.GetSource(), b.GetRef()) | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | // github.com unreachable is an error of the call, not of one action. | |||
| 598 | + | remote.err["flaky/action"] = domain.ErrUnavailable | |||
| 599 | + | _, err := c.resolveErr(runner, job, "flaky/action@v1") | |||
| 600 | + | assert.Equal(t, connect.CodeUnavailable, ciErrCode(err)) | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | func TestResolveActionsRejectsWhatTheRunnerHandles(t *testing.T) { | |||
| 604 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 605 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 606 | + | job := c.startJob(runner) | |||
| 607 | + | c.heartbeat(runner, job) | |||
| 608 | + | for _, u := range []string{"./local", "./.github/actions/x", "docker://alpine:3", "actions/checkout@v4"} { | |||
| 609 | + | _, err := c.resolveErr(runner, job, "actions/checkout@"+shaMain, u) | |||
| 610 | + | if u == "actions/checkout@v4" { | |||
| 611 | + | assert.NoError(t, err) | |||
| 612 | + | continue | |||
| 613 | + | } | |||
| 614 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), u) | |||
| 615 | + | } | |||
| 616 | + | many := make([]string, 101) | |||
| 617 | + | for i := range many { | |||
| 618 | + | many[i] = "a/b@" + shaMain | |||
| 619 | + | } | |||
| 620 | + | _, err := c.resolveErr(runner, job, many...) | |||
| 621 | + | assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "at most 100") | |||
| 622 | + | many = many[:100] | |||
| 623 | + | got, err := c.resolveErr(runner, job, many...) | |||
| 624 | + | require.NoError(t, err) | |||
| 625 | + | assert.Len(t, got, 100) | |||
| 626 | + | got, err = c.resolveErr(runner, job) | |||
| 627 | + | require.NoError(t, err) | |||
| 628 | + | assert.Empty(t, got) | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | // AC2: another job's token, a repository token, an ended job and a bad token | |||
| 632 | + | // are refused. | |||
| 633 | + | func TestResolveActionsAuthentication(t *testing.T) { | |||
| 634 | + | c := newExecHarness(t, newFakeRemote(t), nil) | |||
| 635 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 636 | + | job := c.startJob(runner) | |||
| 637 | + | c.heartbeat(runner, job) | |||
| 638 | + | call := func(secret, id string) error { | |||
| 639 | + | _, err := c.api(secret).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: id, Refs: []*pb.ActionRef{{Uses: "a/b@" + shaMain}}})) | |||
| 640 | + | return err | |||
| 641 | + | } | |||
| 642 | + | assert.NoError(t, call(job.GetJobToken(), job.GetJobId())) | |||
| 643 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(job.GetGithubToken(), job.GetJobId())), "the repository token is not a job token") | |||
| 644 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(runner.secret, job.GetJobId())), "nor the runner token") | |||
| 645 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("ermitejob_"+strings.Repeat("A", 43), job.GetJobId()))) | |||
| 646 | + | assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("", job.GetJobId()))) | |||
| 647 | + | assert.Equal(t, connect.CodePermissionDenied, ciErrCode(call(job.GetJobToken(), "7-0-0")), "a token of another job") | |||
| 648 | + | ||||
| 649 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |||
| 650 | + | err := call(job.GetJobToken(), job.GetJobId()) | |||
| 651 | + | assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "an ended job") | |||
| 652 | + | var ce *connect.Error | |||
| 653 | + | require.ErrorAs(t, err, &ce) | |||
| 654 | + | assert.NotEmpty(t, ce.Details(), "with a JobEnded detail") | |||
| 655 | + | } | |||
| 656 | + | ||||
| 657 | + | // AC4: an action of a repository of ermite wins over github.com, is downloaded | |||
| 658 | + | // with a per-job read token for that repository only, and the same ref gives the | |||
| 659 | + | // same commit to every job of the attempt (and a new one to a re-run). | |||
| 660 | + | func TestResolveActionsFromErmite(t *testing.T) { | |||
| 661 | + | remote := newFakeRemote(t) | |||
| 662 | + | remote.set("owner/myaction", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite | |||
| 663 | + | c := newExecHarness(t, remote, nil) | |||
| 664 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js") | |||
| 665 | + | tag := c.bareGit("myaction", "rev-parse", "HEAD") | |||
| 666 | + | c.mustGit(c.repoDir("myaction"), "tag", "v1") | |||
| 667 | + | c.mustGit(c.repoDir("myaction"), "tag", "-a", "-m", "annotated", "v2") | |||
| 668 | + | c.mustGit(c.repoDir("myaction"), "branch", "dev") | |||
| 669 | + | c.seed("hidden", domain.VisibilityPrivate, "secret.txt") | |||
| 670 | + | ||||
| 671 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 672 | + | c.push("main", map[string]string{".github/workflows/m.yml": "on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"}, "") | |||
| 673 | + | a, b := runner.await(), runner.await() | |||
| 674 | + | c.heartbeat(runner, a) | |||
| 675 | + | c.heartbeat(runner, b) | |||
| 676 | + | ||||
| 677 | + | got := c.resolve(runner, a, "owner/myaction@v1", "owner/MyAction/sub@v2", "owner/myaction@dev", "owner/myaction@"+tag, "owner/myaction@gone", "owner/nothere@v1") | |||
| 678 | + | require.Len(t, got, 6) | |||
| 679 | + | for i := range 4 { | |||
| 680 | + | assert.Empty(t, got[i].GetError(), got[i].GetRef()) | |||
| 681 | + | assert.Equal(t, tag, got[i].GetResolvedSha(), got[i].GetRef()) | |||
| 682 | + | assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[i].GetSource(), got[i].GetRef()) | |||
| 683 | + | assert.True(t, domain.IsRepoToken(got[i].GetToken()), got[i].GetRef()) | |||
| 684 | + | assert.Equal(t, "http://example.test/-/github/api/repos/owner/myaction/tarball/"+tag, got[i].GetTarballUrl()) | |||
| 685 | + | } | |||
| 686 | + | assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner()) | |||
| 687 | + | assert.Equal(t, "sub", got[1].GetPath()) | |||
| 688 | + | assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per repository and job") | |||
| 689 | + | assert.Contains(t, got[4].GetError(), "unable to find version `gone`") | |||
| 690 | + | assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all") | |||
| 691 | + | assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`") | |||
| 692 | + | assert.True(t, remote.asked("owner/nothere"), "an owner repository that ermite lacks falls through to github.com") | |||
| 693 | + | ||||
| 694 | + | // The archive token downloads that repository, whatever the private | |||
| 695 | + | // repository of the run's token reaches, and nothing else. | |||
| 696 | + | arch := got[0].GetToken() | |||
| 697 | + | status, body, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch)) | |||
| 698 | + | require.Equal(t, http.StatusOK, status) | |||
| 699 | + | assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"]) | |||
| 700 | + | status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch)) | |||
| 701 | + | assert.Equal(t, http.StatusOK, status, "and clones it") | |||
| 702 | + | status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch)) | |||
| 703 | + | assert.Equal(t, http.StatusForbidden, status, "never writes") | |||
| 704 | + | status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch)) | |||
| 705 | + | assert.Equal(t, http.StatusNotFound, status, "another private repository") | |||
| 706 | + | status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch)) | |||
| 707 | + | assert.Equal(t, http.StatusNotFound, status, "not even the repository of the run") | |||
| 708 | + | status, _, _ = c.get("/-/github/api/repos/owner/hidden", basic(arch)) | |||
| 709 | + | assert.Equal(t, http.StatusNotFound, status) | |||
| 710 | + | // The job's own token does not read the action repository: this is why the | |||
| 711 | + | // archive has a token of its own. | |||
| 712 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(a.GetGithubToken())) | |||
| 713 | + | assert.Equal(t, http.StatusNotFound, status) | |||
| 714 | + | ||||
| 715 | + | // Per job: the same commit, another token. | |||
| 716 | + | other := c.resolve(runner, b, "owner/myaction@v1") | |||
| 717 | + | assert.Equal(t, tag, other[0].GetResolvedSha()) | |||
| 718 | + | assert.NotEqual(t, arch, other[0].GetToken(), "tokens are not shared between jobs") | |||
| 719 | + | ||||
| 720 | + | // Stable for the attempt: the branch moves, the answer does not. | |||
| 721 | + | c.mustGit(c.wt, "checkout", "-q", "-b", "scratch") | |||
| 722 | + | c.writeFile(c.wt, "later.txt", []byte("later")) | |||
| 723 | + | c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/myaction.git"), "scratch:refs/heads/dev", "-f") | |||
| 724 | + | moved := c.bareGit("myaction", "rev-parse", "dev") | |||
| 725 | + | require.NotEqual(t, tag, moved) | |||
| 726 | + | again := c.resolve(runner, b, "owner/myaction@dev") | |||
| 727 | + | assert.Equal(t, tag, again[0].GetResolvedSha(), "the first resolution of the attempt stands") | |||
| 728 | + | ||||
| 729 | + | // The token goes with its job. | |||
| 730 | + | runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |||
| 731 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch)) | |||
| 732 | + | assert.Equal(t, http.StatusUnauthorized, status, "revoked with the job") | |||
| 733 | + | status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(other[0].GetToken())) | |||
| 734 | + | assert.Equal(t, http.StatusOK, status, "the other job's token lives on") | |||
| 735 | + | } | |||
| 736 | + | ||||
| 737 | + | func ptrRef(name, id string) ports.RemoteRef { return ports.RemoteRef{Name: name, ID: id} } | |||
| 738 | + | ||||
| 739 | + | // A re-run resolves again, and github.com is asked once for a repository in | |||
| 740 | + | // each attempt. | |||
| 741 | + | func TestResolveActionsAreCachedPerAttempt(t *testing.T) { | |||
| 742 | + | remote := newFakeRemote(t) | |||
| 743 | + | remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaMain)) | |||
| 744 | + | c := newExecHarness(t, remote, nil) | |||
| 745 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 746 | + | job := c.startJob(runner) | |||
| 747 | + | c.heartbeat(runner, job) | |||
| 748 | + | ||||
| 749 | + | assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha()) | |||
| 750 | + | remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaOther)) | |||
| 751 | + | assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha(), "stable within the attempt") | |||
| 752 | + | assert.Equal(t, 1, remote.count(), "the second call is answered from the attempt's resolution") | |||
| 753 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |||
| 754 | + | ||||
| 755 | + | _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()})) | |||
| 756 | + | require.NoError(t, err) | |||
| 757 | + | job2 := runner.await() | |||
| 758 | + | assert.Equal(t, int32(2), job2.GetAttempt()) | |||
| 759 | + | c.heartbeat(runner, job2) | |||
| 760 | + | assert.Equal(t, shaOther, c.resolve(runner, job2, "actions/setup-go@v5")[0].GetResolvedSha(), "a re-run resolves again") | |||
| 761 | + | assert.NotEqual(t, job.GetGithubToken(), job2.GetGithubToken()) | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | // AC2: no secret reaches the logs of the server: the repository token, the | |||
| 765 | + | // archive tokens and the job token, whatever happened with them. | |||
| 766 | + | func TestRepositoryTokensAreNeverLogged(t *testing.T) { | |||
| 767 | + | remote := newFakeRemote(t) | |||
| 768 | + | c := newExecHarness(t, remote, nil) | |||
| 769 | + | c.seed("myaction", domain.VisibilityPrivate, "action.yml") | |||
| 770 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 771 | + | job := c.startJob(runner) | |||
| 772 | + | c.heartbeat(runner, job) | |||
| 773 | + | tok := job.GetGithubToken() | |||
| 774 | + | ||||
| 775 | + | arch := c.resolve(runner, job, "owner/myaction@main")[0] | |||
| 776 | + | require.NotEmpty(t, arch.GetToken()) | |||
| 777 | + | _, err := c.clone(tok, c.url("/owner/proj")) | |||
| 778 | + | require.NoError(t, err) | |||
| 779 | + | c.get("/-/github/api/repos/owner/proj", basic(tok)) | |||
| 780 | + | c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+tok) | |||
| 781 | + | c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok)) | |||
| 782 | + | c.get("/owner/secret/info/refs?service=git-upload-pack", basic("ermitegh_"+strings.Repeat("Z", 43))) // a wrong one | |||
| 783 | + | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |||
| 784 | + | c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok)) | |||
| 785 | + | ||||
| 786 | + | logs := c.logs.String() | |||
| 787 | + | require.NotEmpty(t, logs) | |||
| 788 | + | for _, secret := range []string{tok, arch.GetToken(), job.GetJobToken(), base64.StdEncoding.EncodeToString([]byte("x-access-token:" + tok))} { | |||
| 789 | + | assert.NotContains(t, logs, secret) | |||
| 790 | + | } | |||
| 791 | + | assert.NotContains(t, logs, strings.Repeat("Z", 43)) | |||
| 792 | + | } | |||
M internal/app/ci_parity_test.go +46 -6
| @@ -307,20 +307,50 @@ jobs: | |||
| 307 | 307 | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | |
| 308 | 308 | } | |
| 309 | 309 | ||
| 310 | - | // A step timeout stays refused as long as the runner has none. | |
| 311 | - | func TestCIStepTimeoutIsStillRefused(t *testing.T) { | |
| 310 | + | // CI-2: planning runs what CI-1 refused (uses, container, services, a step | |
| 311 | + | // timeout) and still refuses job-level uses, environment and concurrency. | |
| 312 | + | func TestCIPlanningAcceptsCI2Features(t *testing.T) { | |
| 312 | 313 | c := newCIHarness(t, nil) | |
| 313 | - | c.registerRunner("alpha", "ubuntu-latest") | |
| 314 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 314 | 315 | c.push("main", map[string]string{".github/workflows/s.yml": `on: push | |
| 315 | 316 | jobs: | |
| 316 | 317 | j: | |
| 317 | 318 | runs-on: ubuntu-latest | |
| 319 | + | container: alpine:3 | |
| 320 | + | services: | |
| 321 | + | db: | |
| 322 | + | image: postgres:16 | |
| 318 | 323 | steps: | |
| 324 | + | - uses: actions/checkout@v4 | |
| 319 | 325 | - timeout-minutes: 1 | |
| 320 | 326 | run: echo | |
| 321 | 327 | `}, "") | |
| 322 | - | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | |
| 323 | - | assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError()) | |
| 328 | + | job := runner.await() | |
| 329 | + | assert.Equal(t, "j", job.GetJobKey()) | |
| 330 | + | ||
| 331 | + | for _, tc := range []struct{ name, job, want string }{ | |
| 332 | + | {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"}, | |
| 333 | + | {"environment", "runs-on: ubuntu-latest\n environment: prod\n steps:\n - run: echo", "not supported yet: environment"}, | |
| 334 | + | {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"}, | |
| 335 | + | } { | |
| 336 | + | t.Run(tc.name, func(t *testing.T) { | |
| 337 | + | body := "on: push\njobs:\n r:\n " + tc.job + "\n" | |
| 338 | + | c.push("main", map[string]string{".github/workflows/" + tc.name + ".yml": body}, "") | |
| 339 | + | var got string | |
| 340 | + | c.waitFor(tc.name+" fails", func() bool { | |
| 341 | + | for _, r := range c.runs() { | |
| 342 | + | if r.GetWorkflowPath() == ".github/workflows/"+tc.name+".yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED { | |
| 343 | + | if jobs := c.getRun(r.GetId()).GetJobs(); len(jobs) == 1 { | |
| 344 | + | got = jobs[0].GetError() | |
| 345 | + | return true | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | return false | |
| 350 | + | }) | |
| 351 | + | assert.Equal(t, tc.want, got) | |
| 352 | + | }) | |
| 353 | + | } | |
| 324 | 354 | } | |
| 325 | 355 | ||
| 326 | 356 | // A failed job with continue-on-error does not fail the run, keeps its own | |
| @@ -416,12 +446,22 @@ func TestWebRedirectsOwnerURLs(t *testing.T) { | |||
| 416 | 446 | "/owner/proj": "/proj", | |
| 417 | 447 | "/owner/proj/commit/" + sha: "/proj/commit/" + sha, | |
| 418 | 448 | "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1", | |
| 419 | - | "/owner/proj.git/info/refs": "/proj.git/info/refs", | |
| 420 | 449 | } { | |
| 421 | 450 | res := b.get(path, nil) | |
| 422 | 451 | assert.Equal(t, http.StatusPermanentRedirect, res.status, path) | |
| 423 | 452 | assert.Equal(t, to, res.hdr.Get("Location"), path) | |
| 424 | 453 | } | |
| 454 | + | // The git URLs of the owner are served by git over HTTP, not redirected | |
| 455 | + | // (checkout clones <server>/<owner>/<repo>): the private repository asks | |
| 456 | + | // for credentials, a dumb request has no route. | |
| 457 | + | for path, want := range map[string]int{ | |
| 458 | + | "/owner/proj.git/info/refs?service=git-upload-pack": http.StatusUnauthorized, | |
| 459 | + | "/owner/proj/info/refs?service=git-upload-pack": http.StatusUnauthorized, | |
| 460 | + | "/other/proj/info/refs?service=git-upload-pack": http.StatusNotFound, | |
| 461 | + | "/owner/proj.git/info/refs": http.StatusNotFound, | |
| 462 | + | } { | |
| 463 | + | assert.Equal(t, want, b.get(path, nil).status, path) | |
| 464 | + | } | |
| 425 | 465 | assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner") | |
| 426 | 466 | assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page") | |
| 427 | 467 | assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status) | |
| @@ -307,20 +307,50 @@ jobs: | |||||
| 307 | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | 307 | runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x") | ||
| 308 | } | 308 | } | ||
| 309 | 309 | ||||
| 310 | - | // A step timeout stays refused as long as the runner has none. | 310 | + | // CI-2: planning runs what CI-1 refused (uses, container, services, a step |
| 311 | - | func TestCIStepTimeoutIsStillRefused(t *testing.T) { | 311 | + | // timeout) and still refuses job-level uses, environment and concurrency. |
| 312 | + | func TestCIPlanningAcceptsCI2Features(t *testing.T) { | |||
| 312 | c := newCIHarness(t, nil) | 313 | c := newCIHarness(t, nil) | ||
| 313 | - | c.registerRunner("alpha", "ubuntu-latest") | 314 | + | runner := c.registerRunner("alpha", "ubuntu-latest") |
| 314 | c.push("main", map[string]string{".github/workflows/s.yml": `on: push | 315 | c.push("main", map[string]string{".github/workflows/s.yml": `on: push | ||
| 315 | jobs: | 316 | jobs: | ||
| 316 | j: | 317 | j: | ||
| 317 | runs-on: ubuntu-latest | 318 | runs-on: ubuntu-latest | ||
| 319 | + | container: alpine:3 | |||
| 320 | + | services: | |||
| 321 | + | db: | |||
| 322 | + | image: postgres:16 | |||
| 318 | steps: | 323 | steps: | ||
| 324 | + | - uses: actions/checkout@v4 | |||
| 319 | - timeout-minutes: 1 | 325 | - timeout-minutes: 1 | ||
| 320 | run: echo | 326 | run: echo | ||
| 321 | `}, "") | 327 | `}, "") | ||
| 322 | - | c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED }) | 328 | + | job := runner.await() |
| 323 | - | assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError()) | 329 | + | assert.Equal(t, "j", job.GetJobKey()) |
| 330 | + | ||||
| 331 | + | for _, tc := range []struct{ name, job, want string }{ | |||
| 332 | + | {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"}, | |||
| 333 | + | {"environment", "runs-on: ubuntu-latest\n environment: prod\n steps:\n - run: echo", "not supported yet: environment"}, | |||
| 334 | + | {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"}, | |||
| 335 | + | } { | |||
| 336 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 337 | + | body := "on: push\njobs:\n r:\n " + tc.job + "\n" | |||
| 338 | + | c.push("main", map[string]string{".github/workflows/" + tc.name + ".yml": body}, "") | |||
| 339 | + | var got string | |||
| 340 | + | c.waitFor(tc.name+" fails", func() bool { | |||
| 341 | + | for _, r := range c.runs() { | |||
| 342 | + | if r.GetWorkflowPath() == ".github/workflows/"+tc.name+".yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED { | |||
| 343 | + | if jobs := c.getRun(r.GetId()).GetJobs(); len(jobs) == 1 { | |||
| 344 | + | got = jobs[0].GetError() | |||
| 345 | + | return true | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | } | |||
| 349 | + | return false | |||
| 350 | + | }) | |||
| 351 | + | assert.Equal(t, tc.want, got) | |||
| 352 | + | }) | |||
| 353 | + | } | |||
| 324 | } | 354 | } | ||
| 325 | 355 | ||||
| 326 | // A failed job with continue-on-error does not fail the run, keeps its own | 356 | // A failed job with continue-on-error does not fail the run, keeps its own | ||
| @@ -416,12 +446,22 @@ func TestWebRedirectsOwnerURLs(t *testing.T) { | |||||
| 416 | "/owner/proj": "/proj", | 446 | "/owner/proj": "/proj", | ||
| 417 | "/owner/proj/commit/" + sha: "/proj/commit/" + sha, | 447 | "/owner/proj/commit/" + sha: "/proj/commit/" + sha, | ||
| 418 | "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1", | 448 | "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1", | ||
| 419 | - | "/owner/proj.git/info/refs": "/proj.git/info/refs", | |||
| 420 | } { | 449 | } { | ||
| 421 | res := b.get(path, nil) | 450 | res := b.get(path, nil) | ||
| 422 | assert.Equal(t, http.StatusPermanentRedirect, res.status, path) | 451 | assert.Equal(t, http.StatusPermanentRedirect, res.status, path) | ||
| 423 | assert.Equal(t, to, res.hdr.Get("Location"), path) | 452 | assert.Equal(t, to, res.hdr.Get("Location"), path) | ||
| 424 | } | 453 | } | ||
| 454 | + | // The git URLs of the owner are served by git over HTTP, not redirected | |||
| 455 | + | // (checkout clones <server>/<owner>/<repo>): the private repository asks | |||
| 456 | + | // for credentials, a dumb request has no route. | |||
| 457 | + | for path, want := range map[string]int{ | |||
| 458 | + | "/owner/proj.git/info/refs?service=git-upload-pack": http.StatusUnauthorized, | |||
| 459 | + | "/owner/proj/info/refs?service=git-upload-pack": http.StatusUnauthorized, | |||
| 460 | + | "/other/proj/info/refs?service=git-upload-pack": http.StatusNotFound, | |||
| 461 | + | "/owner/proj.git/info/refs": http.StatusNotFound, | |||
| 462 | + | } { | |||
| 463 | + | assert.Equal(t, want, b.get(path, nil).status, path) | |||
| 464 | + | } | |||
| 425 | assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner") | 465 | assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner") | ||
| 426 | assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page") | 466 | assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page") | ||
| 427 | assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status) | 467 | assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status) | ||
M internal/app/forge.go +31 -3
| @@ -20,6 +20,8 @@ import ( | |||
| 20 | 20 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 21 | 21 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 22 | 22 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 23 | + | "bnema.dev/ermite/internal/adapters/githubapi" | |
| 24 | + | "bnema.dev/ermite/internal/adapters/gitremote" | |
| 23 | 25 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 24 | 26 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 25 | 27 | "bnema.dev/ermite/internal/adapters/rpc" | |
| @@ -50,11 +52,22 @@ type options struct { | |||
| 50 | 52 | eventBuffer int | |
| 51 | 53 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | |
| 52 | 54 | revalidateEvery time.Duration | |
| 55 | + | // remote replaces the lister of github.com refs (tests never reach the | |
| 56 | + | // network). | |
| 57 | + | remoteLister ports.GitRemote | |
| 53 | 58 | // ci overrides the settings of the CI engine (tests shorten its | |
| 54 | 59 | // durations). | |
| 55 | 60 | ci func(*usecase.CIConfig) | |
| 56 | 61 | } | |
| 57 | 62 | ||
| 63 | + | // remote returns the lister of github.com refs: the test's, else def. | |
| 64 | + | func (o options) remote(def ports.GitRemote) ports.GitRemote { | |
| 65 | + | if o.remoteLister != nil { | |
| 66 | + | return o.remoteLister | |
| 67 | + | } | |
| 68 | + | return def | |
| 69 | + | } | |
| 70 | + | ||
| 58 | 71 | // forge is the wired server: every adapter and use case, ready to serve. | |
| 59 | 72 | type forge struct { | |
| 60 | 73 | cfg config.Config | |
| @@ -161,7 +174,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 161 | 174 | Msg("repositories reconciled") | |
| 162 | 175 | ||
| 163 | 176 | f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions()) | |
| 164 | - | f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock) | |
| 177 | + | f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI()) | |
| 165 | 178 | f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay) | |
| 166 | 179 | f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth) | |
| 167 | 180 | ||
| @@ -199,7 +212,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 199 | 212 | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | |
| 200 | 213 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | |
| 201 | 214 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | |
| 202 | - | PollTimeout: cfg.CIPollTimeout, | |
| 215 | + | PollTimeout: cfg.CIPollTimeout, Archive: git, | |
| 216 | + | Remote: opts.remote(gitremote.New(gitremote.Config{ | |
| 217 | + | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | |
| 218 | + | })), | |
| 203 | 219 | } | |
| 204 | 220 | if opts.ci != nil { | |
| 205 | 221 | opts.ci(&ciCfg) | |
| @@ -279,15 +295,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 279 | 295 | Repos: f.Repos, | |
| 280 | 296 | Pushes: f.Pushes, | |
| 281 | 297 | Backend: git, | |
| 298 | + | Owner: cfg.Owner, | |
| 282 | 299 | Hooks: f.hooks, | |
| 283 | 300 | TrustedProxies: cfg.TrustedProxies, | |
| 284 | 301 | Limiter: limiter, | |
| 285 | 302 | MaxBodyBytes: maxBody, | |
| 286 | 303 | }) | |
| 287 | - | routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns)) | |
| 304 | + | routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(web.Patterns)) | |
| 288 | 305 | for _, p := range githttp.Patterns { | |
| 289 | 306 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 290 | 307 | } | |
| 308 | + | // The owner form of the clone URL (github.repository), which GitHub's | |
| 309 | + | // actions use. config.Owner follows the rules of repository names, so it is | |
| 310 | + | // a literal segment that never overlaps /-/. | |
| 311 | + | for _, p := range githttp.OwnerPatterns(cfg.Owner) { | |
| 312 | + | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 313 | + | } | |
| 314 | + | // The REST subset the actions call on github.api_url. | |
| 315 | + | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter}) | |
| 316 | + | for _, p := range githubapi.Patterns { | |
| 317 | + | routes = append(routes, httpserver.Route{Pattern: p, Handler: api}) | |
| 318 | + | } | |
| 291 | 319 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | |
| 292 | 320 | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) | |
| 293 | 321 | site, err := newWeb(cfg, f, limiter) | |
| @@ -20,6 +20,8 @@ import ( | |||||
| 20 | "bnema.dev/ermite/internal/adapters/eventbus" | 20 | "bnema.dev/ermite/internal/adapters/eventbus" | ||
| 21 | "bnema.dev/ermite/internal/adapters/gitcli" | 21 | "bnema.dev/ermite/internal/adapters/gitcli" | ||
| 22 | "bnema.dev/ermite/internal/adapters/githttp" | 22 | "bnema.dev/ermite/internal/adapters/githttp" | ||
| 23 | + | "bnema.dev/ermite/internal/adapters/githubapi" | |||
| 24 | + | "bnema.dev/ermite/internal/adapters/gitremote" | |||
| 23 | "bnema.dev/ermite/internal/adapters/httpserver" | 25 | "bnema.dev/ermite/internal/adapters/httpserver" | ||
| 24 | "bnema.dev/ermite/internal/adapters/kvstore" | 26 | "bnema.dev/ermite/internal/adapters/kvstore" | ||
| 25 | "bnema.dev/ermite/internal/adapters/rpc" | 27 | "bnema.dev/ermite/internal/adapters/rpc" | ||
| @@ -50,11 +52,22 @@ type options struct { | |||||
| 50 | eventBuffer int | 52 | eventBuffer int | ||
| 51 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | 53 | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | ||
| 52 | revalidateEvery time.Duration | 54 | revalidateEvery time.Duration | ||
| 55 | + | // remote replaces the lister of github.com refs (tests never reach the | |||
| 56 | + | // network). | |||
| 57 | + | remoteLister ports.GitRemote | |||
| 53 | // ci overrides the settings of the CI engine (tests shorten its | 58 | // ci overrides the settings of the CI engine (tests shorten its | ||
| 54 | // durations). | 59 | // durations). | ||
| 55 | ci func(*usecase.CIConfig) | 60 | ci func(*usecase.CIConfig) | ||
| 56 | } | 61 | } | ||
| 57 | 62 | ||||
| 63 | + | // remote returns the lister of github.com refs: the test's, else def. | |||
| 64 | + | func (o options) remote(def ports.GitRemote) ports.GitRemote { | |||
| 65 | + | if o.remoteLister != nil { | |||
| 66 | + | return o.remoteLister | |||
| 67 | + | } | |||
| 68 | + | return def | |||
| 69 | + | } | |||
| 70 | + | ||||
| 58 | // forge is the wired server: every adapter and use case, ready to serve. | 71 | // forge is the wired server: every adapter and use case, ready to serve. | ||
| 59 | type forge struct { | 72 | type forge struct { | ||
| 60 | cfg config.Config | 73 | cfg config.Config | ||
| @@ -161,7 +174,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 161 | Msg("repositories reconciled") | 174 | Msg("repositories reconciled") | ||
| 162 | 175 | ||||
| 163 | f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions()) | 176 | f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions()) | ||
| 164 | - | f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock) | 177 | + | f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI()) |
| 165 | f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay) | 178 | f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay) | ||
| 166 | f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth) | 179 | f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth) | ||
| 167 | 180 | ||||
| @@ -199,7 +212,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 199 | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | 212 | Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader, | ||
| 200 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | 213 | Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth, | ||
| 201 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | 214 | Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention, | ||
| 202 | - | PollTimeout: cfg.CIPollTimeout, | 215 | + | PollTimeout: cfg.CIPollTimeout, Archive: git, |
| 216 | + | Remote: opts.remote(gitremote.New(gitremote.Config{ | |||
| 217 | + | Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"), | |||
| 218 | + | })), | |||
| 203 | } | 219 | } | ||
| 204 | if opts.ci != nil { | 220 | if opts.ci != nil { | ||
| 205 | opts.ci(&ciCfg) | 221 | opts.ci(&ciCfg) | ||
| @@ -279,15 +295,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 279 | Repos: f.Repos, | 295 | Repos: f.Repos, | ||
| 280 | Pushes: f.Pushes, | 296 | Pushes: f.Pushes, | ||
| 281 | Backend: git, | 297 | Backend: git, | ||
| 298 | + | Owner: cfg.Owner, | |||
| 282 | Hooks: f.hooks, | 299 | Hooks: f.hooks, | ||
| 283 | TrustedProxies: cfg.TrustedProxies, | 300 | TrustedProxies: cfg.TrustedProxies, | ||
| 284 | Limiter: limiter, | 301 | Limiter: limiter, | ||
| 285 | MaxBodyBytes: maxBody, | 302 | MaxBodyBytes: maxBody, | ||
| 286 | }) | 303 | }) | ||
| 287 | - | routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns)) | 304 | + | routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(web.Patterns)) |
| 288 | for _, p := range githttp.Patterns { | 305 | for _, p := range githttp.Patterns { | ||
| 289 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | 306 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | ||
| 290 | } | 307 | } | ||
| 308 | + | // The owner form of the clone URL (github.repository), which GitHub's | |||
| 309 | + | // actions use. config.Owner follows the rules of repository names, so it is | |||
| 310 | + | // a literal segment that never overlaps /-/. | |||
| 311 | + | for _, p := range githttp.OwnerPatterns(cfg.Owner) { | |||
| 312 | + | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |||
| 313 | + | } | |||
| 314 | + | // The REST subset the actions call on github.api_url. | |||
| 315 | + | api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter}) | |||
| 316 | + | for _, p := range githubapi.Patterns { | |||
| 317 | + | routes = append(routes, httpserver.Route{Pattern: p, Handler: api}) | |||
| 318 | + | } | |||
| 291 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | 319 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | ||
| 292 | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) | 320 | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) | ||
| 293 | site, err := newWeb(cfg, f, limiter) | 321 | site, err := newWeb(cfg, f, limiter) | ||
A internal/usecase/ci_githubapi.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "io" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // The REST subset (ADR 0025, CI-2): what the actions that run on ermite call | |
| 17 | + | // on github.api_url. The access rule is the forge's: a repository is read by | |
| 18 | + | // anyone when public, and with repo:read when private, which the principal of a | |
| 19 | + | // job repository token holds on the repository of its run (and the archive | |
| 20 | + | // tokens on theirs). | |
| 21 | + | ||
| 22 | + | var _ ports.GitHubAPIService = (*CI)(nil) | |
| 23 | + | ||
| 24 | + | // githubRepo looks the repository up as GitHub names it, owner/repo. Another | |
| 25 | + | // owner, a name that is not one and a repository the caller cannot read are all | |
| 26 | + | // not found. | |
| 27 | + | func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) { | |
| 28 | + | if !strings.EqualFold(owner, c.cfg.Owner) { | |
| 29 | + | return domain.Repo{}, domain.ErrNotFound | |
| 30 | + | } | |
| 31 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |
| 32 | + | if err != nil { | |
| 33 | + | return domain.Repo{}, domain.ErrNotFound | |
| 34 | + | } | |
| 35 | + | return c.readableRepo(ctx, p, rn) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // GetRepo returns GET /repos/{owner}/{repo}. | |
| 39 | + | // | |
| 40 | + | // The object is the repository of the push event (repoObj: real values where | |
| 41 | + | // ermite has them, GitHub's for a repository without the feature elsewhere) | |
| 42 | + | // with the REST differences: dates are ISO 8601 strings, and the permissions of | |
| 43 | + | // the caller are given, as GitHub does for an authenticated request. | |
| 44 | + | func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string) ([]byte, error) { | |
| 45 | + | repo, err := c.githubRepo(ctx, p, owner, name) | |
| 46 | + | if err != nil { | |
| 47 | + | return nil, err | |
| 48 | + | } | |
| 49 | + | branch, err := c.cfg.Repos.HeadBranch(ctx, repo.Name) | |
| 50 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 51 | + | return nil, domain.ErrNotFound | |
| 52 | + | } | |
| 53 | + | if err != nil { | |
| 54 | + | return nil, err | |
| 55 | + | } | |
| 56 | + | if branch == "" { | |
| 57 | + | branch = "main" | |
| 58 | + | } | |
| 59 | + | u := c.urls(repo) | |
| 60 | + | created, pushed := repo.CreatedAt, repo.PushedAt | |
| 61 | + | obj := c.repoObj(repo, u, branch, created) | |
| 62 | + | obj["created_at"] = created.UTC().Format(timeLayout) | |
| 63 | + | if pushed.IsZero() { | |
| 64 | + | pushed = created | |
| 65 | + | } | |
| 66 | + | obj["pushed_at"] = pushed.UTC().Format(timeLayout) | |
| 67 | + | obj["updated_at"] = pushed.UTC().Format(timeLayout) | |
| 68 | + | delete(obj, "master_branch") // push event only | |
| 69 | + | delete(obj, "stargazers") | |
| 70 | + | delete(obj, "watchers") | |
| 71 | + | delete(obj, "forks") | |
| 72 | + | delete(obj, "open_issues") | |
| 73 | + | for k, v := range map[string]any{ | |
| 74 | + | "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash), | |
| 75 | + | "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward), | |
| 76 | + | "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, | |
| 77 | + | "use_squash_pr_title_as_default": false, "has_pull_requests": true, | |
| 78 | + | } { | |
| 79 | + | obj[k] = v | |
| 80 | + | } | |
| 81 | + | if !p.IsAnonymous() { | |
| 82 | + | write := canWrite(p, repo.Name) | |
| 83 | + | obj["permissions"] = map[string]bool{"admin": canAdmin(p, repo.Name), "maintain": write, "push": write, "triage": write, "pull": true} | |
| 84 | + | } | |
| 85 | + | return json.Marshal(obj) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // timeLayout is the ISO 8601 form of GitHub's REST dates. | |
| 89 | + | const timeLayout = "2006-01-02T15:04:05Z" | |
| 90 | + | ||
| 91 | + | // HashAlgorithm answers GET /repos/{owner}/{repo}/hash-algorithm: checkout asks | |
| 92 | + | // it before it runs git init. ermite repositories are SHA-1. | |
| 93 | + | func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name string) (string, error) { | |
| 94 | + | if _, err := c.githubRepo(ctx, p, owner, name); err != nil { | |
| 95 | + | return "", err | |
| 96 | + | } | |
| 97 | + | return "sha1", nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Tarball streams the archive of a ref as `git archive` makes it, in one | |
| 101 | + | // response (GitHub answers 302 to codeload: ermite serves it itself). The files | |
| 102 | + | // are in a directory <owner>-<repo>-<7 first characters of the commit>, as | |
| 103 | + | // GitHub names it. | |
| 104 | + | func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error { | |
| 105 | + | repo, err := c.githubRepo(ctx, p, owner, name) | |
| 106 | + | if err != nil { | |
| 107 | + | return err | |
| 108 | + | } | |
| 109 | + | rev, err := domain.ParseRevision(ref) | |
| 110 | + | if err != nil { | |
| 111 | + | return domain.ErrNotFound | |
| 112 | + | } | |
| 113 | + | res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev) | |
| 114 | + | if err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | dir := c.cfg.Owner + "-" + repo.Name.String() + "-" + shortID(res.CommitID.String())[:7] | |
| 118 | + | return c.cfg.Archive.Archive(ctx, repo.Name, res.CommitID, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }}) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // lazyWriter calls begin at the first write, so that nothing is sent before git | |
| 122 | + | // produced a byte. | |
| 123 | + | type lazyWriter struct { | |
| 124 | + | begin func() io.Writer | |
| 125 | + | w io.Writer | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (l *lazyWriter) Write(b []byte) (int, error) { | |
| 129 | + | if l.w == nil { | |
| 130 | + | l.w = l.begin() | |
| 131 | + | } | |
| 132 | + | return l.w.Write(b) | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "io" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // The REST subset (ADR 0025, CI-2): what the actions that run on ermite call | |||
| 17 | + | // on github.api_url. The access rule is the forge's: a repository is read by | |||
| 18 | + | // anyone when public, and with repo:read when private, which the principal of a | |||
| 19 | + | // job repository token holds on the repository of its run (and the archive | |||
| 20 | + | // tokens on theirs). | |||
| 21 | + | ||||
| 22 | + | var _ ports.GitHubAPIService = (*CI)(nil) | |||
| 23 | + | ||||
| 24 | + | // githubRepo looks the repository up as GitHub names it, owner/repo. Another | |||
| 25 | + | // owner, a name that is not one and a repository the caller cannot read are all | |||
| 26 | + | // not found. | |||
| 27 | + | func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) { | |||
| 28 | + | if !strings.EqualFold(owner, c.cfg.Owner) { | |||
| 29 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 30 | + | } | |||
| 31 | + | rn, err := domain.ParseRepoName(strings.ToLower(name)) | |||
| 32 | + | if err != nil { | |||
| 33 | + | return domain.Repo{}, domain.ErrNotFound | |||
| 34 | + | } | |||
| 35 | + | return c.readableRepo(ctx, p, rn) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // GetRepo returns GET /repos/{owner}/{repo}. | |||
| 39 | + | // | |||
| 40 | + | // The object is the repository of the push event (repoObj: real values where | |||
| 41 | + | // ermite has them, GitHub's for a repository without the feature elsewhere) | |||
| 42 | + | // with the REST differences: dates are ISO 8601 strings, and the permissions of | |||
| 43 | + | // the caller are given, as GitHub does for an authenticated request. | |||
| 44 | + | func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string) ([]byte, error) { | |||
| 45 | + | repo, err := c.githubRepo(ctx, p, owner, name) | |||
| 46 | + | if err != nil { | |||
| 47 | + | return nil, err | |||
| 48 | + | } | |||
| 49 | + | branch, err := c.cfg.Repos.HeadBranch(ctx, repo.Name) | |||
| 50 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 51 | + | return nil, domain.ErrNotFound | |||
| 52 | + | } | |||
| 53 | + | if err != nil { | |||
| 54 | + | return nil, err | |||
| 55 | + | } | |||
| 56 | + | if branch == "" { | |||
| 57 | + | branch = "main" | |||
| 58 | + | } | |||
| 59 | + | u := c.urls(repo) | |||
| 60 | + | created, pushed := repo.CreatedAt, repo.PushedAt | |||
| 61 | + | obj := c.repoObj(repo, u, branch, created) | |||
| 62 | + | obj["created_at"] = created.UTC().Format(timeLayout) | |||
| 63 | + | if pushed.IsZero() { | |||
| 64 | + | pushed = created | |||
| 65 | + | } | |||
| 66 | + | obj["pushed_at"] = pushed.UTC().Format(timeLayout) | |||
| 67 | + | obj["updated_at"] = pushed.UTC().Format(timeLayout) | |||
| 68 | + | delete(obj, "master_branch") // push event only | |||
| 69 | + | delete(obj, "stargazers") | |||
| 70 | + | delete(obj, "watchers") | |||
| 71 | + | delete(obj, "forks") | |||
| 72 | + | delete(obj, "open_issues") | |||
| 73 | + | for k, v := range map[string]any{ | |||
| 74 | + | "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash), | |||
| 75 | + | "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward), | |||
| 76 | + | "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, | |||
| 77 | + | "use_squash_pr_title_as_default": false, "has_pull_requests": true, | |||
| 78 | + | } { | |||
| 79 | + | obj[k] = v | |||
| 80 | + | } | |||
| 81 | + | if !p.IsAnonymous() { | |||
| 82 | + | write := canWrite(p, repo.Name) | |||
| 83 | + | obj["permissions"] = map[string]bool{"admin": canAdmin(p, repo.Name), "maintain": write, "push": write, "triage": write, "pull": true} | |||
| 84 | + | } | |||
| 85 | + | return json.Marshal(obj) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // timeLayout is the ISO 8601 form of GitHub's REST dates. | |||
| 89 | + | const timeLayout = "2006-01-02T15:04:05Z" | |||
| 90 | + | ||||
| 91 | + | // HashAlgorithm answers GET /repos/{owner}/{repo}/hash-algorithm: checkout asks | |||
| 92 | + | // it before it runs git init. ermite repositories are SHA-1. | |||
| 93 | + | func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name string) (string, error) { | |||
| 94 | + | if _, err := c.githubRepo(ctx, p, owner, name); err != nil { | |||
| 95 | + | return "", err | |||
| 96 | + | } | |||
| 97 | + | return "sha1", nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Tarball streams the archive of a ref as `git archive` makes it, in one | |||
| 101 | + | // response (GitHub answers 302 to codeload: ermite serves it itself). The files | |||
| 102 | + | // are in a directory <owner>-<repo>-<7 first characters of the commit>, as | |||
| 103 | + | // GitHub names it. | |||
| 104 | + | func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error { | |||
| 105 | + | repo, err := c.githubRepo(ctx, p, owner, name) | |||
| 106 | + | if err != nil { | |||
| 107 | + | return err | |||
| 108 | + | } | |||
| 109 | + | rev, err := domain.ParseRevision(ref) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return domain.ErrNotFound | |||
| 112 | + | } | |||
| 113 | + | res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | dir := c.cfg.Owner + "-" + repo.Name.String() + "-" + shortID(res.CommitID.String())[:7] | |||
| 118 | + | return c.cfg.Archive.Archive(ctx, repo.Name, res.CommitID, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }}) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // lazyWriter calls begin at the first write, so that nothing is sent before git | |||
| 122 | + | // produced a byte. | |||
| 123 | + | type lazyWriter struct { | |||
| 124 | + | begin func() io.Writer | |||
| 125 | + | w io.Writer | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (l *lazyWriter) Write(b []byte) (int, error) { | |||
| 129 | + | if l.w == nil { | |||
| 130 | + | l.w = l.begin() | |||
| 131 | + | } | |||
| 132 | + | return l.w.Write(b) | |||
| 133 | + | } | |||