diff --git a/README.md b/README.md index a0be12a..38f07b5 100644 --- a/README.md +++ b/README.md @@ -90,19 +90,25 @@ artifacts: Run the sync: ```shell -flux-mirror sync -c flux-mirror.yaml +flux-mirror sync flux-mirror.yaml +``` + +You can also read the config from stdin: + +```shell +flux-mirror sync - < flux-mirror.yaml ``` Preview without writing: ```shell -flux-mirror sync -c flux-mirror.yaml --dry-run +flux-mirror sync flux-mirror.yaml --dry-run ``` Force a resync of drifted tags e.g. `latest`: ```shell -flux-mirror sync -c flux-mirror.yaml --overwrite +flux-mirror sync flux-mirror.yaml --overwrite ``` See [`examples/`](examples) for more configurations and @@ -122,13 +128,13 @@ real failures from drift, so a CI gate can react to each independently: The `--no-progress` flag suppresses the live spinner so log output stays clean in CI: ```shell -flux-mirror sync -c flux-mirror.yaml --no-progress +flux-mirror sync flux-mirror.yaml --no-progress ``` For downstream tooling, emit a structured report: ```shell -flux-mirror sync -c flux-mirror.yaml -o json | jq '.entries[].outcomes' +flux-mirror sync flux-mirror.yaml -o json | jq '.entries[].outcomes' ``` ### GitHub Actions @@ -164,7 +170,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Sync Kubernetes SIGs Charts - run: flux-mirror sync -c kubernetes-sigs.yaml --no-progress + run: flux-mirror sync kubernetes-sigs.yaml --no-progress ``` ### Docker @@ -176,7 +182,7 @@ docker run --rm \ -e DOCKER_CONFIG=/.docker \ -v "$PWD/flux-mirror.yaml:/config.yaml:ro" \ -v "$HOME/.docker/config.json:/.docker/config.json:ro" \ - ghcr.io/fluxcd/flux-mirror:latest sync -c /config.yaml --no-progress + ghcr.io/fluxcd/flux-mirror:latest sync /config.yaml --no-progress ``` ### Kubernetes @@ -191,7 +197,7 @@ destination registry credentials from a `Secret` created via | Command | Description | |-------------------------------|-------------------------------------------------------------------| -| `flux-mirror sync [-c PATH]` | Mirror Helm charts and OCI artifacts described by a YAML config. | +| `flux-mirror sync CONFIG|-` | Mirror Helm charts and OCI artifacts described by a YAML config. | | `flux-mirror version` | Print the CLI version. | Run `flux-mirror --help` for the full flag list. diff --git a/actions/setup/README.md b/actions/setup/README.md index 66bd4be..537b385 100644 --- a/actions/setup/README.md +++ b/actions/setup/README.md @@ -35,7 +35,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Sync - run: flux-mirror sync -c .flux-mirror.yaml --no-progress + run: flux-mirror sync .flux-mirror.yaml --no-progress ``` `flux-mirror sync` exits with `1` on a pull/push failure and `2` when drift is detected. diff --git a/cmd/flux-mirror/main_test.go b/cmd/flux-mirror/main_test.go index ffdce7b..e60ad7f 100644 --- a/cmd/flux-mirror/main_test.go +++ b/cmd/flux-mirror/main_test.go @@ -6,6 +6,7 @@ package main import ( "bytes" "os" + "strings" "testing" "time" @@ -27,11 +28,16 @@ func TestMain(m *testing.M) { // arguments to their default values after execution to // ensure test isolation. func executeCommand(args []string) (string, error) { + return executeCommandWithInput(args, "") +} + +func executeCommandWithInput(args []string, input string) (string, error) { defer resetCmdArgs() buf := new(bytes.Buffer) cmd := rootCmd cmd.SetArgs(args) + cmd.SetIn(strings.NewReader(input)) cmd.SetOut(buf) cmd.SetErr(buf) @@ -41,6 +47,7 @@ func executeCommand(args []string) (string, error) { func resetCmdArgs() { rootArgs.timeout = timeout + rootCmd.SetIn(os.Stdin) versionArgs = versionFlags{output: "text"} syncArgs = syncFlags{output: "text", concurrency: 4, retries: 3} diff --git a/cmd/flux-mirror/sync.go b/cmd/flux-mirror/sync.go index 9e7fabb..baacd01 100644 --- a/cmd/flux-mirror/sync.go +++ b/cmd/flux-mirror/sync.go @@ -29,7 +29,7 @@ const ( ) var syncCmd = &cobra.Command{ - Use: "sync", + Use: "sync [CONFIG|-]", Short: "Mirror Helm charts and OCI artifacts to a destination registry", Long: `Mirror Helm charts and OCI artifacts between registries based on a declarative YAML config (apiVersion: mirror.fluxcd.io/v1alpha1, kind: Config). @@ -41,22 +41,24 @@ Exit codes: 1 at least one tag job failed 2 no failures, but at least one tag drifted (overwrite=false)`, Example: ` # Run a sync against a config file - flux-mirror sync -c flux-mirror.yaml + flux-mirror sync flux-mirror.yaml # Pass the config via env var FLUX_MIRROR_CONFIG=flux-mirror.yaml flux-mirror sync + # Pass the config via stdin + flux-mirror sync - < flux-mirror.yaml + # Preview without writing to the destination - flux-mirror sync -c flux-mirror.yaml --dry-run -o yaml + flux-mirror sync flux-mirror.yaml --dry-run -o yaml # Force overwrite of every drifted tag - flux-mirror sync -c flux-mirror.yaml --overwrite`, - Args: cobra.NoArgs, + flux-mirror sync flux-mirror.yaml --overwrite`, + Args: cobra.MaximumNArgs(1), RunE: syncCmdRun, } type syncFlags struct { - config string output flags.Output concurrency int retries int @@ -79,8 +81,6 @@ var syncArgs = syncFlags{ } func init() { - syncCmd.Flags().StringVarP(&syncArgs.config, "config", "c", "", - "Path to the YAML config file (or set "+envConfig+").") syncCmd.Flags().VarP(&syncArgs.output, "output", "o", syncArgs.output.Description()) syncCmd.Flags().IntVar(&syncArgs.concurrency, "concurrency", syncArgs.concurrency, "Maximum number of copy operations to run in parallel per job") @@ -123,12 +123,12 @@ func init() { rootCmd.AddCommand(syncCmd) } -func syncCmdRun(cmd *cobra.Command, _ []string) error { - cfgPath, err := resolveConfigPath() +func syncCmdRun(cmd *cobra.Command, args []string) error { + cfgPath, err := resolveConfigPath(args) if err != nil { return err } - cfg, err := config.Load(cfgPath) + cfg, err := loadConfig(cmd, cfgPath) if err != nil { return err } @@ -309,12 +309,26 @@ func buildClientTransport() (http.RoundTripper, error) { return t, nil } -func resolveConfigPath() (string, error) { - if syncArgs.config != "" { - return syncArgs.config, nil +func resolveConfigPath(args []string) (string, error) { + if len(args) > 0 { + return args[0], nil } if env := os.Getenv(envConfig); env != "" { return env, nil } - return "", fmt.Errorf("config required: pass --config/-c or set %s", envConfig) + return "", fmt.Errorf("config required: pass the config path as the first argument, pass '-' for stdin, or set %s", envConfig) +} + +func loadConfig(cmd *cobra.Command, path string) (*config.Config, error) { + if path != "-" { + return config.Load(path) + } + cfg, err := config.Decode(cmd.InOrStdin()) + if err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil } diff --git a/cmd/flux-mirror/sync_test.go b/cmd/flux-mirror/sync_test.go index 4d92251..a1ef29c 100644 --- a/cmd/flux-mirror/sync_test.go +++ b/cmd/flux-mirror/sync_test.go @@ -20,6 +20,7 @@ var dockerReg string func ensureRegistry(t *testing.T) { t.Helper() + testregistry.UseEmptyDockerConfig(t) if dockerReg != "" { return } @@ -33,7 +34,14 @@ func ensureRegistry(t *testing.T) { func writeConfig(t *testing.T, src, dst string) string { t.Helper() g := NewWithT(t) - body := fmt.Sprintf(`apiVersion: mirror.fluxcd.io/v1alpha1 + body := configBody(src, dst) + path := filepath.Join(t.TempDir(), "config.yaml") + g.Expect(os.WriteFile(path, []byte(body), 0o600)).To(Succeed()) + return path +} + +func configBody(src, dst string) string { + return fmt.Sprintf(`apiVersion: mirror.fluxcd.io/v1alpha1 kind: Config artifacts: - source: %s @@ -42,9 +50,6 @@ artifacts: semver: ">=0.0.0" limit: 5 `, src, dst) - path := filepath.Join(t.TempDir(), "config.yaml") - g.Expect(os.WriteFile(path, []byte(body), 0o600)).To(Succeed()) - return path } func TestSync_NoConfigError(t *testing.T) { @@ -73,7 +78,22 @@ func TestSync_ConfigViaEnv(t *testing.T) { g.Expect(out).To(ContainSubstring(`"1.0.0"`)) } -func TestSync_FlagOverridesEnv(t *testing.T) { +func TestSync_ConfigViaStdin(t *testing.T) { + g := NewWithT(t) + ensureRegistry(t) + + src := dockerReg + "/stdin-src-" + testregistry.RandSuffix() + dst := dockerReg + "/stdin-dst-" + testregistry.RandSuffix() + testregistry.PushImage(t, src+":1.0.0") + t.Setenv("FLUX_MIRROR_CONFIG", "") + + out, err := executeCommandWithInput([]string{"sync", "-", "--insecure", "-o", "json"}, configBody(src, dst)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out).To(ContainSubstring(`"copied": [`)) + g.Expect(out).To(ContainSubstring(`"1.0.0"`)) +} + +func TestSync_ArgOverridesEnv(t *testing.T) { g := NewWithT(t) ensureRegistry(t) @@ -82,10 +102,10 @@ func TestSync_FlagOverridesEnv(t *testing.T) { testregistry.PushImage(t, src+":1.0.0") cfgPath := writeConfig(t, src, dst) - // Env points at a bogus path — the flag must win. + // Env points at a bogus path — the positional argument must win. t.Setenv("FLUX_MIRROR_CONFIG", "/nonexistent/path.yaml") - out, err := executeCommand([]string{"sync", "-c", cfgPath, "--insecure", "--verbose"}) + out, err := executeCommand([]string{"sync", cfgPath, "--insecure", "--verbose"}) g.Expect(err).ToNot(HaveOccurred()) g.Expect(out).To(ContainSubstring(src)) } @@ -102,7 +122,7 @@ func TestSync_DriftExitCode(t *testing.T) { cfgPath := writeConfig(t, src, dst) t.Setenv("FLUX_MIRROR_CONFIG", "") - _, err := executeCommand([]string{"sync", "-c", cfgPath, "--insecure"}) + _, err := executeCommand([]string{"sync", cfgPath, "--insecure"}) g.Expect(err).To(HaveOccurred()) var ec interface{ ExitCode() int } g.Expect(errors.As(err, &ec)).To(BeTrue()) @@ -120,7 +140,7 @@ func TestSync_DryRun(t *testing.T) { cfgPath := writeConfig(t, src, dst) t.Setenv("FLUX_MIRROR_CONFIG", "") - out, err := executeCommand([]string{"sync", "-c", cfgPath, "--insecure", "--dry-run", "-o", "yaml"}) + out, err := executeCommand([]string{"sync", cfgPath, "--insecure", "--dry-run", "-o", "yaml"}) g.Expect(err).ToNot(HaveOccurred()) g.Expect(out).To(MatchRegexp(`would-copy:\s*\n\s*- 1\.0\.0`)) } @@ -128,6 +148,6 @@ func TestSync_DryRun(t *testing.T) { func TestSync_BadOutputFormat(t *testing.T) { g := NewWithT(t) cfgPath := writeConfig(t, "ghcr.io/a/b", "ghcr.io/c/d") - _, err := executeCommand([]string{"sync", "-c", cfgPath, "-o", "xml"}) + _, err := executeCommand([]string{"sync", cfgPath, "-o", "xml"}) g.Expect(err).To(HaveOccurred()) } diff --git a/docs/config.md b/docs/config.md index 4f65b5d..1a53404 100644 --- a/docs/config.md +++ b/docs/config.md @@ -10,8 +10,9 @@ The config uses a Kubernetes-style `apiVersion`/`kind` fields. It is not a Kubernetes resource; it is a CLI config consumed by `flux-mirror`, the same way `kustomization.yaml` is consumed by `kustomize`. -`flux-mirror sync` reads the file path from `--config` / `-c`, falling back -to `FLUX_MIRROR_CONFIG`. The flag wins when both are set. +`flux-mirror sync` reads the file path from the first positional argument, +falling back to `FLUX_MIRROR_CONFIG`. Use `-` as the argument to read YAML +from stdin. The argument wins when both are set. ## Top-level fields diff --git a/docs/sync.md b/docs/sync.md index 4dfff92..fed260c 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -9,18 +9,19 @@ See the [config specification](./config.md) for the YAML schema. ## Synopsis ``` -flux-mirror sync [-c|--config PATH] [flags] +flux-mirror sync CONFIG|- [flags] ``` ## Configuration source The config file path is resolved in the following order: -1. `--config` / `-c PATH` flag. +1. First positional argument (`-` reads YAML from stdin). 2. `FLUX_MIRROR_CONFIG` environment variable. ```bash -flux-mirror sync -c examples/podinfo.yaml +flux-mirror sync examples/podinfo.yaml +flux-mirror sync - < examples/podinfo.yaml FLUX_MIRROR_CONFIG=examples/podinfo.yaml flux-mirror sync ``` @@ -37,7 +38,6 @@ Log in once with `docker login`, `oras login`, etc. and `flux-mirror` picks up t | Flag | Default | Description | |---------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `-c, --config PATH` | — | Path to the YAML config file. Falls back to `FLUX_MIRROR_CONFIG`. | | `-o, --output text\|yaml\|json` | `text` | Output format. `text` is human-friendly; `yaml` and `json` print the structured `Result` to stdout. | | `--concurrency N` | `4` | Maximum number of copy operations to run in parallel within a single config entry. Entries themselves are processed sequentially. | | `--retries N` | `3` | Maximum number of retry attempts per job, bounded by `--timeout`. | @@ -89,7 +89,7 @@ and registry-side warning is logged. Reach for this when diagnosing TLS, auth, m Suitable for piping into another tool. ```bash -flux-mirror sync -c config.yaml -o json | jq '.entries[].outcomes.copied' +flux-mirror sync config.yaml -o json | jq '.entries[].outcomes.copied' ``` ## Outcomes @@ -151,7 +151,7 @@ artifacts: ``` ```bash -flux-mirror sync -c config.yaml +flux-mirror sync config.yaml ``` ### Mirror a Helm chart with its image @@ -180,7 +180,7 @@ artifacts: ``` ```bash -flux-mirror sync -c config.yaml +flux-mirror sync config.yaml ``` The chart lands at `localhost:5050/charts/external-dns:` (the chart @@ -191,17 +191,23 @@ consuming `HelmRelease.spec.values` to point at the mirror. ### Preview without writing ```bash -flux-mirror sync -c config.yaml --dry-run -o yaml +flux-mirror sync config.yaml --dry-run -o yaml ``` ### Force-resync drifted tags ```bash -flux-mirror sync -c config.yaml --overwrite +flux-mirror sync config.yaml --overwrite ``` ### CI-friendly invocation ```bash -flux-mirror sync -c config.yaml --no-progress +flux-mirror sync config.yaml --no-progress +``` + +### Read config from stdin + +```bash +cat config.yaml | flux-mirror sync - ``` diff --git a/examples/cronjob.yaml b/examples/cronjob.yaml index 37d8812..762cc91 100644 --- a/examples/cronjob.yaml +++ b/examples/cronjob.yaml @@ -63,7 +63,7 @@ spec: imagePullPolicy: IfNotPresent args: - sync - - --config=/config/flux/mirror.yaml + - /config/flux/mirror.yaml - --no-progress - --output=text env: diff --git a/internal/charts/mirror_test.go b/internal/charts/mirror_test.go index 4ba0027..15e7d64 100644 --- a/internal/charts/mirror_test.go +++ b/internal/charts/mirror_test.go @@ -52,6 +52,7 @@ func discardLogger() *slog.Logger { func newHTTPHelmRepo(t *testing.T, versions ...string) string { t.Helper() + testregistry.UseEmptyDockerConfig(t) srv, err := helmtestserver.NewTempHelmServer() if err != nil { t.Fatalf("new helm server: %s", err) @@ -74,6 +75,7 @@ func newHTTPHelmRepo(t *testing.T, versions ...string) string { // pre-populate the destination get byte-equivalent state. func pushHelmFixture(t *testing.T, client *oci.Client, ref, version string) { t.Helper() + testregistry.UseEmptyDockerConfig(t) tgz := testregistry.PackageChart(t, fixtureChart, version) cfg := testregistry.ChartConfigJSON(t, tgz) if _, err := client.PushHelmChart(context.Background(), ref, cfg, tgz); err != nil { diff --git a/internal/helmrepo/oci_test.go b/internal/helmrepo/oci_test.go index 3d00a57..38a2dcc 100644 --- a/internal/helmrepo/oci_test.go +++ b/internal/helmrepo/oci_test.go @@ -34,6 +34,7 @@ func TestMain(m *testing.M) { // at chartRepo. The OCI tag is derived from version (`+` → `_`). func pushHelmFixture(t *testing.T, client *oci.Client, chartRepo, version string) { t.Helper() + testregistry.UseEmptyDockerConfig(t) tgz := testregistry.PackageChart(t, "testdata/podinfo", version) cfg := testregistry.ChartConfigJSON(t, tgz) ref := chartRepo + ":" + VersionToTag(version) diff --git a/internal/oci/helm_test.go b/internal/oci/helm_test.go index e3f04fc..d227a54 100644 --- a/internal/oci/helm_test.go +++ b/internal/oci/helm_test.go @@ -18,6 +18,7 @@ const helmFixture = "../helmrepo/testdata/podinfo" func helmChartBytes(t *testing.T, version string) (cfg, tgz []byte) { t.Helper() + testregistry.UseEmptyDockerConfig(t) tgz = testregistry.PackageChart(t, helmFixture, version) cfg = testregistry.ChartConfigJSON(t, tgz) return cfg, tgz @@ -80,6 +81,7 @@ func TestHelmChartLayerDigest(t *testing.T) { func TestHelmChartLayerDigest_Missing(t *testing.T) { g := NewWithT(t) + testregistry.UseEmptyDockerConfig(t) c := NewClient(Insecure()) missing := repo("helm-missing") + ":1.0.0" @@ -110,6 +112,7 @@ func TestIsHelmChart(t *testing.T) { func TestIsHelmChart_MissingTag(t *testing.T) { g := NewWithT(t) + testregistry.UseEmptyDockerConfig(t) c := NewClient(Insecure()) missing := repo("ishelm-missing") + ":nope" diff --git a/internal/testregistry/testregistry.go b/internal/testregistry/testregistry.go index 733164c..0d14628 100644 --- a/internal/testregistry/testregistry.go +++ b/internal/testregistry/testregistry.go @@ -13,6 +13,8 @@ import ( "io" "math/rand" "net" + "os" + "path/filepath" "strconv" "strings" "testing" @@ -72,6 +74,17 @@ func Start(ctx context.Context) (string, error) { return host, nil } +// UseEmptyDockerConfig points Docker-aware clients at an empty config for the +// duration of a test, so local credential helpers do not affect registry tests. +func UseEmptyDockerConfig(t testing.TB) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatalf("write temp Docker config: %s", err) + } + t.Setenv("DOCKER_CONFIG", dir) +} + var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890") // RandSuffix returns a 6-rune lowercase-alnum string. Use to give each test @@ -93,6 +106,7 @@ func Repo(addr, stem string) string { // returns the resulting digest. func PushImage(t testing.TB, ref string) string { t.Helper() + UseEmptyDockerConfig(t) img, err := random.Image(128, 1) if err != nil { t.Fatalf("random.Image: %s", err) @@ -111,6 +125,7 @@ func PushImage(t testing.TB, ref string) string { // the manifest-list digest. func PushIndex(t testing.TB, ref string) string { t.Helper() + UseEmptyDockerConfig(t) idx, err := random.Index(256, 1, 3) if err != nil { t.Fatalf("random.Index: %s", err) @@ -134,6 +149,7 @@ func PushIndex(t testing.TB, ref string) string { // distribution/v3 indexes the result via the OCI 1.1 referrers API. func PushReferrer(t testing.TB, repoAddr string, subject v1.Descriptor, artifactType string) v1.Hash { t.Helper() + UseEmptyDockerConfig(t) img, err := random.Image(64, 1) if err != nil { t.Fatalf("random.Image: %s", err)