diff --git a/action/README.md b/action/README.md index f8ebcb8e..6c2db0f2 100644 --- a/action/README.md +++ b/action/README.md @@ -134,18 +134,25 @@ jobs: flux tag artifact $OCI_REPO:$(git rev-parse --short HEAD) --tag staging ``` -Example workflow for publishing Kubernetes manifests bundled as OCI artifacts to Docker Hub: +### Push and sign Kubernetes manifests to container registries + +Example workflow for publishing Kubernetes manifests bundled as OCI artifacts +which are signed with Cosign and GitHub OIDC: ```yaml -name: push-artifact-production +name: push-sign-artifact on: push: - tags: - - '*' + branches: + - 'main' + +permissions: + packages: write # needed for ghcr.io access + id-token: write # needed for keyless signing env: - OCI_REPO: "oci://docker.io/my-org/app-config" + OCI_REPO: "oci://ghcr.io/my-org/manifests/${{ github.event.repository.name }}" jobs: kubernetes: @@ -155,23 +162,24 @@ jobs: uses: actions/checkout@v3 - name: Setup Flux CLI uses: fluxcd/flux2/action@main - - name: Login to Docker Hub + - name: Setup Cosign + uses: sigstore/cosign-installer@main + - name: Login to GHCR uses: docker/login-action@v2 with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Generate manifests - run: | - kustomize build ./manifests/production > ./deploy/app.yaml - - name: Push manifests - run: | - flux push artifact $OCI_REPO:$(git tag --points-at HEAD) \ - --path="./deploy" \ - --source="$(git config --get remote.origin.url)" \ - --revision="$(git tag --points-at HEAD)/$(git rev-parse HEAD)" - - name: Deploy manifests to production + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push and sign manifests run: | - flux tag artifact $OCI_REPO:$(git tag --points-at HEAD) --tag production + digest_url=$(flux push artifact \ + $OCI_REPO:$(git rev-parse --short HEAD) \ + --path="./manifests" \ + --source="$(git config --get remote.origin.url)" \ + --revision="$(git branch --show-current)/$(git rev-parse HEAD)" |\ + jq -r '. | .repository + "@" + .digest') + + cosign sign $digest_url ``` ### End-to-end testing diff --git a/cmd/flux/push_artifact.go b/cmd/flux/push_artifact.go index 66d6807b..6664c201 100644 --- a/cmd/flux/push_artifact.go +++ b/cmd/flux/push_artifact.go @@ -18,12 +18,15 @@ package main import ( "context" + "encoding/json" "fmt" "os" "github.com/fluxcd/flux2/internal/flags" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + reg "github.com/google/go-containerregistry/pkg/name" "github.com/spf13/cobra" + "sigs.k8s.io/yaml" oci "github.com/fluxcd/pkg/oci/client" ) @@ -40,6 +43,16 @@ The command can read the credentials from '~/.docker/config.json' but they can a --source="$(git config --get remote.origin.url)" \ --revision="$(git branch --show-current)/$(git rev-parse HEAD)" + # Push and sign artifact with cosign + digest_url = $(flux push artifact \ + oci://ghcr.io/org/config/app:$(git rev-parse --short HEAD) \ + --source="$(git config --get remote.origin.url)" \ + --revision="$(git branch --show-current)/$(git rev-parse HEAD)" \ + --path="./path/to/local/manifest.yaml" \ + --output json | \ + jq -r '. | .repository + "@" + .digest') + cosign sign $digest_url + # Push manifests passed into stdin to GHCR kustomize build . | flux push artifact oci://ghcr.io/org/config/app:$(git rev-parse --short HEAD) -p - \ --source="$(git config --get remote.origin.url)" \ @@ -85,6 +98,7 @@ type pushArtifactFlags struct { creds string provider flags.SourceOCIProvider ignorePaths []string + output string } var pushArtifactArgs = newPushArtifactFlags() @@ -102,6 +116,8 @@ func init() { pushArtifactCmd.Flags().StringVar(&pushArtifactArgs.creds, "creds", "", "credentials for OCI registry in the format [:] if --provider is generic") pushArtifactCmd.Flags().Var(&pushArtifactArgs.provider, "provider", pushArtifactArgs.provider.Description()) pushArtifactCmd.Flags().StringSliceVar(&pushArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format") + pushArtifactCmd.Flags().StringVarP(&pushArtifactArgs.output, "output", "o", "", + "the format in which the artifact digest should be printed, can be 'json' or 'yaml'") pushCmd.AddCommand(pushArtifactCmd) } @@ -172,14 +188,54 @@ func pushArtifactCmdRun(cmd *cobra.Command, args []string) error { } } - logger.Actionf("pushing artifact to %s", url) + if pushArtifactArgs.output == "" { + logger.Actionf("pushing artifact to %s", url) + } - digest, err := ociClient.Push(ctx, url, path, meta, pushArtifactArgs.ignorePaths) + digestURL, err := ociClient.Push(ctx, url, path, meta, pushArtifactArgs.ignorePaths) if err != nil { return fmt.Errorf("pushing artifact failed: %w", err) } - logger.Successf("artifact successfully pushed to %s", digest) + digest, err := reg.NewDigest(digestURL) + if err != nil { + return fmt.Errorf("artifact digest parsing failed: %w", err) + } + + tag, err := reg.NewTag(url) + if err != nil { + return fmt.Errorf("artifact tag parsing failed: %w", err) + } + + info := struct { + URL string `json:"url"` + Repository string `json:"repository"` + Tag string `json:"tag"` + Digest string `json:"digest"` + }{ + URL: fmt.Sprintf("oci://%s", digestURL), + Repository: digest.Repository.Name(), + Tag: tag.TagStr(), + Digest: digest.DigestStr(), + } + + switch pushArtifactArgs.output { + case "json": + marshalled, err := json.MarshalIndent(&info, "", " ") + if err != nil { + return fmt.Errorf("artifact digest JSON conversion failed: %w", err) + } + marshalled = append(marshalled, "\n"...) + rootCmd.Print(string(marshalled)) + case "yaml": + marshalled, err := yaml.Marshal(&info) + if err != nil { + return fmt.Errorf("artifact digest YAML conversion failed: %w", err) + } + rootCmd.Print(string(marshalled)) + default: + logger.Successf("artifact successfully pushed to %s", digestURL) + } return nil }