Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions cmd/environment/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,10 +465,12 @@ func applyContractFields(target, declaration *yaml.Node) {

// contractOwnedOptional names the keys a coordinate contract owns whose ABSENCE is
// itself a statement (docs/coordinate-contract-conventions.md in infra-base): an absent
// registry.mirrors means "pull the upstream as the Dockerfile names it". Merging alone
// would keep a mirror the contract withdrew, and the builder would keep mapping to a
// repository that no longer exists.
var contractOwnedOptional = map[string][]string{"registry": {"mirrors"}}
// registry.mirrors means "pull the upstream as the Dockerfile names it", an absent
// registry.artifacts "publish bundles to registry.url", an absent registry.auth "log
// in to nothing". Merging alone would keep a value the contract withdrew, and the
// render would keep pulling from, publishing to or logging in to a registry the
// environment no longer holds.
var contractOwnedOptional = map[string][]string{"registry": {"mirrors", "artifacts", "auth"}}

// withdrawContractOwnedAbsences removes each contract-owned optional key the contract
// no longer carries, under a parent the contract does declare. A parent the contract
Expand Down
18 changes: 18 additions & 0 deletions cmd/environment/import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,24 @@ func TestImportRefusesANullRunnableScopeSelectionWithoutWriting(t *testing.T) {
}
}

func TestReimportWithdrawsArtifactsAndAuthTheContractNoLongerCarries(t *testing.T) {
dir := writeWorkspace(t, existingAzureWorkspace)
declared := strings.Replace(fixtureContract, `"registry": {"url": "x.azurecr.io"}`,
`"registry": {"url": "x.azurecr.io", "auth": "acr", "artifacts": "x.azurecr.io/bundles"}`, 1)
doImport(t, dir, importOptions{contractData: []byte(declared)})
if got := loadWorkspace(t, dir).FindEnvironment("azure").Registry; got.Auth != "acr" || got.Artifacts != "x.azurecr.io/bundles" {
t.Fatalf("auth and artifacts not imported: %+v", got)
}
doImport(t, dir, importOptions{contractData: []byte(fixtureContract)})
env := loadWorkspace(t, dir).FindEnvironment("azure")
if env.Registry.Auth != "" || env.Registry.Artifacts != "" || env.Registry.URL != "x.azurecr.io" {
t.Fatalf("a value the contract withdrew survived re-import: %+v", env.Registry)
}
if strings.Contains(readFile(t, filepath.Join(dir, resources.WorkspaceConfigurationName)), "bundles") {
t.Fatal("the withdrawn artifacts registry is still written in the workspace")
}
}

func TestReimportWithdrawsAMirrorTheContractNoLongerCarries(t *testing.T) {
dir := writeWorkspace(t, existingAzureWorkspace)
mirrored := strings.Replace(fixtureContract, `"registry": {"url": "x.azurecr.io"}`,
Expand Down
64 changes: 56 additions & 8 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1055,8 +1055,8 @@ it** — a mirror serves the same content, so the resolved digest is the same an
it is the digest, never the registry it came from, that the reuse key binds.

A mirror is **not** authenticated by `registry.auth`, which is the push side and
covers `registry.url` alone (and today is wired for `ecr` and `acr` only — `gar`
and `ghcr` are stubs). Both halves of a mirrored read use the host's Docker
covers `registry.url` alone (wired for `ecr`, `acr` and `ghcr`; `gar` is a
stub). Both halves of a mirrored read use the host's Docker
credential store instead: `docker buildx imagetools inspect` for the resolution,
and the container-driver builder for the pull. So log in to the mirror's
registry on the machine that renders before it renders — for an Artifact
Expand Down Expand Up @@ -1090,6 +1090,49 @@ premise — but it also means neither the build output nor the reuse key records
*which* registry answered, so a mirror serving something else would be
bound as if it were upstream.

**A cell pulls the environment's own images through a mirror too.** A mirror
declared for the host of `registry.url` itself makes the push and the pull two
registries: the build pushes to `registry.url`, and the rendered manifests (and
so the cell file) name the image under the mirror, with the pushed digest. This
is how one canonical private registry serves every cell: each cell reads it
through a same-cloud pull-through repository with its own workload identity,
and no registry credential is ever placed in a cell.

```yaml
environments:
- name: staging
registry:
url: ghcr.io/example # pushed to, once, by whoever renders
auth: ghcr # requires a ghcr.io url
mirrors:
ghcr.io: us-east1-docker.pkg.dev/example/ghcr # a remote repository of ghcr.io
artifacts: us-east1-docker.pkg.dev/example/bundles # frontend bundles
```

Here `ghcr.io/example/<module>/<service>:<version>` is pushed and
`us-east1-docker.pkg.dev/example/ghcr/example/<module>/<service>@sha256:…` is
deployed — the mirror's repository path is the upstream path, with the same
normalization a base image is mirrored with. Every registry read the build makes
(the released-version check, the reuse cache, the check that a kept image still
exists) uses the pushed reference. An image the recipe names under any other
repository is untouched. With no mirror for the `registry.url` host, nothing
changes: the image is pushed and pulled where `registry.url` names it.

`auth: ghcr` logs Docker in to `ghcr.io` with a token from `GITHUB_TOKEN`, else
`GH_TOKEN`, else `gh auth token`, as `GITHUB_ACTOR`, else the `gh` login; the
token reaches `docker login` on stdin and is never logged. An image pushed to
`ghcr.io` is labelled (and, multi-platform, its index annotated)
`org.opencontainers.image.source` with the workspace's GitHub repository, which
is what links a private package to the repository whose access it inherits. The
label is added after the image's identity is computed, so it never causes a
rebuild.

`registry.artifacts` is where frontend bundles are published and read instead
of `registry.url`. Bundles are OCI artifacts rather than images, and a
pull-through repository is not proven to serve them, so an environment whose
`registry.url` is mirrored names a registry its hosts read bundles from
directly. Absent, bundles go to `registry.url` as before.

`render` is a function of the workspace and needs no cluster: by default no
service's manifests are sent to a Kubernetes API. Pass `--validate-cluster` to
also dry-run each rendered service server-side (`kubectl apply --server-side
Expand Down Expand Up @@ -2275,10 +2318,11 @@ former spelling of `--coordinate-contract` for one release.
Declared fields replace their exact named values. Maps merge by explicit key;
omitted fields and unrelated entries remain intact. Explicit empty maps, empty
sequences and nulls clear the corresponding field where its schema permits.
One key is the contract's alone and its absence is a statement: `registry.mirrors`.
A contract that declares `registry` without `mirrors` withdraws any mirror an earlier
import wrote, so the builder stops mapping to a repository the environment no longer
holds.
Three keys are the contract's alone and their absence is a statement:
`registry.mirrors`, `registry.artifacts` and `registry.auth`. A contract that declares
`registry` without one of them withdraws the value an earlier import wrote, so the
render stops pulling from, publishing bundles to or logging in to a registry the
environment no longer holds.
There is no special `store` alias, single-database limit, default secret path or
ignored producer extension. Unknown fields and capabilities fail validation.

Expand Down Expand Up @@ -3919,8 +3963,12 @@ nothing is built — when that label equals this build's inputs. A published
image recording **other** inputs refuses the build: its version was not moved
with its code, and the fix is a new release, never a rebuild under the old
version. One recording none (pushed before builds recorded their inputs) is
built again. With a registry whose tags are immutable, each version then holds
exactly one build for good.
built again. Only the registry answering that the tag does not exist lets a
released version be built: any other failed read (an authorization refusal, a
timeout, an unreachable registry) stops the render, because building over it
would overwrite a released tag on any registry whose tags are mutable. With a
registry whose tags are immutable, each version then holds exactly one build
for good.

A build also reuses the image this workspace already built from the same inputs
instead of building it again. A configuration-only change — an edit to a value
Expand Down
73 changes: 71 additions & 2 deletions pkg/builder/registry_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
Expand All @@ -30,7 +32,10 @@ import (
// (<name>.azurecr.io).
// "gar" — Google Artifact Registry. Stubbed (returns ErrUnimplemented)
// until someone needs it.
// "ghcr" — GitHub Container Registry. Stubbed.
// "ghcr" — GitHub Container Registry. The token is GITHUB_TOKEN, else
// GH_TOKEN, else `gh auth token --hostname github.com`; the user
// is GITHUB_ACTOR, else `gh api user --jq .login`. The token is
// piped to `docker login ghcr.io --password-stdin`.
//
// Called from cli/cmd/build/service.go before flow.Build runs. Failures
// here are loud — bad to discover at push time after a long build.
Expand All @@ -48,7 +53,7 @@ func RegistryLogin(ctx context.Context, registryURL, authKind string) error {
case "gar", "gcr":
return fmt.Errorf("registry auth %q not yet wired — file an issue or add it in cli/pkg/builder/registry_auth.go", authKind)
case "ghcr":
return fmt.Errorf("registry auth %q not yet wired — set GITHUB_TOKEN and run `echo $GITHUB_TOKEN | docker login ghcr.io -u <user> --password-stdin` manually for now", authKind)
return ghcrLogin(ctx)
default:
return fmt.Errorf("unknown registry auth %q (supported: ecr, acr, gar, ghcr)", authKind)
}
Expand Down Expand Up @@ -173,3 +178,67 @@ func acrLogin(ctx context.Context, registryURL string) error {
w.Info("ACR login succeeded", wool.Field("host", host), wool.Field("name", name))
return nil
}

// ghcrHost is the registry the ghcr auth logs in to.
const ghcrHost = "ghcr.io"

// runRegistryCommand runs one credential command with stdin and returns its
// stdout; its stderr is folded into the error. A seam so a test needs neither
// gh nor docker.
var runRegistryCommand = func(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = stdin
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String()))
}
return stdout.Bytes(), nil
}

// lookupRegistryEnv reads the environment; a seam for tests.
var lookupRegistryEnv = os.Getenv

// ghcrLogin logs docker in to ghcr.io with the caller's GitHub identity: a CI
// token from the environment, or the GitHub CLI's login. The token only ever
// reaches docker on stdin and is never logged.
func ghcrLogin(ctx context.Context) error {
w := wool.Get(ctx).In("ghcrLogin")
token := firstNonEmpty(lookupRegistryEnv("GITHUB_TOKEN"), lookupRegistryEnv("GH_TOKEN"))
if token == "" {
out, err := runRegistryCommand(ctx, nil, "gh", "auth", "token", "--hostname", "github.com")
if err != nil {
return fmt.Errorf("registry auth ghcr: no GITHUB_TOKEN or GH_TOKEN, and `gh auth token` failed: %w", err)
}
token = strings.TrimSpace(string(out))
}
if token == "" {
return fmt.Errorf("registry auth ghcr: no GitHub token (set GITHUB_TOKEN or run `gh auth login`)")
}
user := lookupRegistryEnv("GITHUB_ACTOR")
if user == "" {
out, err := runRegistryCommand(ctx, nil, "gh", "api", "user", "--jq", ".login")
if err != nil {
return fmt.Errorf("registry auth ghcr: no GITHUB_ACTOR, and `gh api user` failed: %w", err)
}
user = strings.TrimSpace(string(out))
}
if user == "" {
return fmt.Errorf("registry auth ghcr: no GitHub user (set GITHUB_ACTOR or run `gh auth login`)")
}
if _, err := runRegistryCommand(ctx, strings.NewReader(token), "docker", "login", "--username", user, "--password-stdin", ghcrHost); err != nil {
return fmt.Errorf("docker login %s failed: %w", ghcrHost, err)
}
w.Info("GHCR login succeeded", wool.Field("host", ghcrHost), wool.Field("user", user))
return nil
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
87 changes: 86 additions & 1 deletion pkg/builder/registry_auth_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package builder

import "testing"
import (
"context"
"fmt"
"io"
"strings"
"testing"
)

func TestACRName(t *testing.T) {
cases := []struct {
Expand All @@ -26,3 +32,82 @@ func TestACRName(t *testing.T) {
}
}
}

type registryCall struct {
argv string
stdin string
}

// stubGHCR replaces the environment and every credential command: gh answers
// with ghToken and ghUser, docker login succeeds.
func stubGHCR(t *testing.T, env map[string]string, ghToken, ghUser string) *[]registryCall {
t.Helper()
previousRun, previousEnv := runRegistryCommand, lookupRegistryEnv
t.Cleanup(func() { runRegistryCommand, lookupRegistryEnv = previousRun, previousEnv })
lookupRegistryEnv = func(key string) string { return env[key] }
calls := &[]registryCall{}
runRegistryCommand = func(_ context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
call := registryCall{argv: name + " " + strings.Join(args, " ")}
if stdin != nil {
payload, _ := io.ReadAll(stdin)
call.stdin = string(payload)
}
*calls = append(*calls, call)
switch {
case strings.HasPrefix(call.argv, "gh auth token"):
return []byte(ghToken + "\n"), nil
case strings.HasPrefix(call.argv, "gh api user"):
return []byte(ghUser + "\n"), nil
case strings.HasPrefix(call.argv, "docker login"):
return nil, nil
}
return nil, fmt.Errorf("unexpected %s", call.argv)
}
return calls
}

func TestGHCRLoginReadsTheTokenFromCIThenTheGitHubCLI(t *testing.T) {
for _, tc := range []struct {
name string
env map[string]string
calls []string
user string
token string
}{
{"ci", map[string]string{"GITHUB_TOKEN": "env-token", "GH_TOKEN": "other", "GITHUB_ACTOR": "ci-bot"}, nil, "ci-bot", "env-token"},
{"gh token", map[string]string{"GH_TOKEN": "gh-env-token"}, []string{"gh api user --jq .login"}, "octocat", "gh-env-token"},
{"gh cli", map[string]string{}, []string{"gh auth token --hostname github.com", "gh api user --jq .login"}, "octocat", "cli-token"},
} {
t.Run(tc.name, func(t *testing.T) {
calls := stubGHCR(t, tc.env, "cli-token", "octocat")
if err := RegistryLogin(t.Context(), "ghcr.io/obin-ai", "ghcr"); err != nil {
t.Fatal(err)
}
var argv []string
for _, call := range *calls {
argv = append(argv, call.argv)
}
want := append(append([]string(nil), tc.calls...), "docker login --username "+tc.user+" --password-stdin ghcr.io")
if strings.Join(argv, "\n") != strings.Join(want, "\n") {
t.Fatalf("commands = %q, want %q", argv, want)
}
login := (*calls)[len(*calls)-1]
if login.stdin != tc.token || strings.Contains(login.argv, tc.token) {
t.Fatalf("the token must reach docker on stdin only: %+v", login)
}
})
}
}

func TestGHCRLoginWithoutATokenFailsWithoutLeakingOne(t *testing.T) {
stubGHCR(t, map[string]string{}, "", "octocat")
err := RegistryLogin(t.Context(), "ghcr.io/obin-ai", "ghcr")
if err == nil || !strings.Contains(err.Error(), "no GitHub token") {
t.Fatalf("want a missing-token refusal, got %v", err)
}
stubGHCR(t, map[string]string{"GITHUB_TOKEN": "secret-token"}, "", "")
err = RegistryLogin(t.Context(), "ghcr.io/obin-ai", "ghcr")
if err == nil || !strings.Contains(err.Error(), "no GitHub user") || strings.Contains(err.Error(), "secret-token") {
t.Fatalf("want a missing-user refusal naming no token, got %v", err)
}
}
Loading
Loading