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
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 <command> --help` for the full flag list.
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions cmd/flux-mirror/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package main
import (
"bytes"
"os"
"strings"
"testing"
"time"

Expand All @@ -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)

Expand All @@ -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}
Expand Down
44 changes: 29 additions & 15 deletions cmd/flux-mirror/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
40 changes: 30 additions & 10 deletions cmd/flux-mirror/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var dockerReg string

func ensureRegistry(t *testing.T) {
t.Helper()
testregistry.UseEmptyDockerConfig(t)
if dockerReg != "" {
return
}
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)

Expand All @@ -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))
}
Expand All @@ -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())
Expand All @@ -120,14 +140,14 @@ 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`))
}

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())
}
5 changes: 3 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading