Skip to content
Draft
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
3 changes: 3 additions & 0 deletions cmd/kosli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ Paths the list already matches stay excluded whatever is later added there, so k
// the server is the authority on which types are actually accepted
validEnvTypesList = "K8S, ECS, S3, lambda, server, docker, azure-apps, cloud-run, logical"

validS3FingerprintSources = "content, metadata"

// single source of truth for the service account privilege list shown in
// flag help texts; the server is the authority on which privileges are
// actually accepted
Expand Down Expand Up @@ -261,6 +263,7 @@ Paths the list already matches stay excluded whatever is later added there, so k
bucketNameFlag = "The name of the S3 bucket."
downloadConcurrencyFlag = "[optional] The number of S3 objects to download at the same time when fingerprinting the bucket. Each object in flight may hold up to 40 MB of download buffers in memory, on top of the disk the --download-budget allows."
downloadBudgetFlag = "[optional] The maximum total size of the S3 objects downloading at the same time, which caps the temporary disk the snapshot uses. A bare number is megabytes; add K, M, G or T (optionally followed by B) to choose the unit, e.g. 512M or 8G. An object larger than the budget still downloads, on its own. Objects are downloaded to the OS temporary directory."
s3FingerprintSourceFlag = "[defaulted] Where each object's SHA256 comes from when fingerprinting the bucket. Valid sources are: [" + validS3FingerprintSources + "]. 'content' downloads every contributing object and hashes it. 'metadata' reads the SHA256 checksum S3 stores for each object instead, which skips the download but requires every contributing object to have been uploaded with a full-object SHA256 checksum. Both produce the same fingerprint and need the same permissions."
bucketPathsFlag = "[optional] The comma separated list of file and/or directory paths in the S3 bucket to include when fingerprinting. Paths match by literal prefix. Cannot be used together with --exclude or --exclude-regex."
bucketPathsRegexFlag = "[optional] The comma separated list of Go regular expressions matched against object keys in the S3 bucket to include when fingerprinting. Cannot be used together with --exclude or --exclude-regex."
excludeBucketPathsFlag = "[optional] The comma separated list of file and/or directory paths in the S3 bucket to exclude when fingerprinting. Paths match by literal prefix. Cannot be used together with --include or --include-regex."
Expand Down
34 changes: 33 additions & 1 deletion cmd/kosli/snapshotS3.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ In all cases, the content is reported as one artifact. If you wish to report sep
Object keys are never used as local file names: each object is downloaded to a temporary file, hashed and removed, and the fingerprint is computed from the keys and the content digests, so any key S3 accepts can be fingerprinted on any operating system.
Keys that cannot form a directory tree are rejected and fail the snapshot, naming every key involved: a key containing a ^..^ segment, two keys that resolve to the same path (such as ^a//b^ and ^a/b^), or an object whose key is also a prefix of other objects (such as ^a^ beside ^a/b^). A legitimate key of that shape can be left out with ^--exclude-regex^ (anchor and escape it, since the pattern is a regular expression matched against the whole key); when ^--include^ or ^--include-regex^ is set, exclude filters are ignored, so narrow the include filter instead.

By default each object's SHA256 comes from downloading the object and hashing it. ^--fingerprint-source metadata^ reads the SHA256 checksum S3 stores for the object instead, which skips the download, the temporary disk and the hashing. Everything else -- the keys, the ^.kosli_ignore^ rules, the way digests combine into the fingerprint -- is the same in both modes, so the fingerprint is identical and a snapshot matches the artifact you attested either way. Two conditions apply:
- Every contributing object must carry a full-object SHA256 checksum. S3 only stores one when the upload asked for it, for example ^aws s3api put-object --checksum-algorithm SHA256^. Objects without one fail the snapshot, all named in one run.
- A multipart upload gets a composite SHA256, which hashes the checksums of the parts rather than the object content, so it cannot serve as the object's fingerprint. Such an object can be collapsed into a single part in place with ^aws s3api copy-object --checksum-algorithm SHA256 --copy-source yourBucket/yourKey --bucket yourBucket --key yourKey^.
A root ^.kosli_ignore^ is still downloaded in this mode, because its rules decide which objects contribute; the objects it excludes are never fetched and need no checksum. Reading a checksum does not need fewer permissions than downloading: AWS requires ^s3:GetObject^ for both, and an SSE-KMS encrypted object additionally needs ^kms:GenerateDataKey^ and ^kms:Decrypt^ either way.

` + kosliIgnoreDescNoExclude

const snapshotS3Example = `
Expand Down Expand Up @@ -69,14 +74,29 @@ kosli snapshot s3 yourEnvironmentName \
--exclude-regex '.*\.png$' \
--api-token yourAPIToken \
--org yourOrgName

# report contents of an AWS S3 bucket without downloading the objects,
# using the SHA256 checksums S3 stores for them:
kosli snapshot s3 yourEnvironmentName \
--bucket yourBucketName \
--fingerprint-source metadata \
--api-token yourAPIToken \
--org yourOrgName
`

// fingerprint sources accepted by --fingerprint-source
const (
fingerprintSourceContent = "content"
fingerprintSourceMetadata = "metadata"
)

type snapshotS3Options struct {
bucket string
includePaths []string
includeRegex []string
excludePaths []string
excludeRegex []string
fingerprintSource string
downloadConcurrency int
downloadBudget string
downloadLimits aws.DownloadLimits
Expand Down Expand Up @@ -112,6 +132,12 @@ func newSnapshotS3Cmd(out io.Writer) *cobra.Command {
}
}

if o.fingerprintSource != fingerprintSourceContent && o.fingerprintSource != fingerprintSourceMetadata {
return ErrorBeforePrintingUsage(cmd, fmt.Sprintf(
"%s is not a valid fingerprint source. Valid sources are: [%s]",
o.fingerprintSource, validS3FingerprintSources))
}

return o.resolveDownloadLimits()
},
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -124,6 +150,7 @@ func newSnapshotS3Cmd(out io.Writer) *cobra.Command {
cmd.Flags().StringSliceVar(&o.includeRegex, "include-regex", []string{}, bucketPathsRegexFlag)
cmd.Flags().StringSliceVarP(&o.excludePaths, "exclude", "x", []string{}, excludeBucketPathsFlag)
cmd.Flags().StringSliceVar(&o.excludeRegex, "exclude-regex", []string{}, excludeBucketPathsRegexFlag)
cmd.Flags().StringVar(&o.fingerprintSource, "fingerprint-source", fingerprintSourceContent, s3FingerprintSourceFlag)
cmd.Flags().IntVar(&o.downloadConcurrency, "download-concurrency", aws.DefaultDownloadLimits.Concurrency, downloadConcurrencyFlag)
cmd.Flags().StringVar(&o.downloadBudget, "download-budget", defaultDownloadBudget, downloadBudgetFlag)
addAWSAuthFlags(cmd, o.awsStaticCreds)
Expand All @@ -149,7 +176,12 @@ func (o *snapshotS3Options) run(args []string) error {
return err
}

s3Data, err := o.awsStaticCreds.GetS3Data(o.bucket, o.includePaths, o.includeRegex, o.excludePaths, o.excludeRegex, o.downloadLimits, logger)
harvest := o.awsStaticCreds.GetS3Data
if o.fingerprintSource == fingerprintSourceMetadata {
harvest = o.awsStaticCreds.GetS3DataFromMetadata
}

s3Data, err := harvest(o.bucket, o.includePaths, o.includeRegex, o.excludePaths, o.excludeRegex, o.downloadLimits, logger)
if err != nil {
return err
}
Expand Down
40 changes: 36 additions & 4 deletions cmd/kosli/snapshotS3_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package main

import (
"crypto/sha256"
"encoding/base64"
"fmt"
"testing"

s3Types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/kosli-dev/cli/internal/aws"
"github.com/stretchr/testify/suite"
)
Expand Down Expand Up @@ -33,12 +36,19 @@ func (suite *SnapshotS3TestSuite) SetupTest() {
// Inject a fake S3 client so tests run without AWS credentials.
// The fake is seeded with the objects the test cases filter on.
bucketName := suite.bucketName
objects := map[string][]byte{
"README.md": []byte("# kosli cli public\n"),
"dummy/dummy_2/template.yml": []byte("key: value\n"),
}
// Only README.md carries a stored checksum, so the metadata cases cover both
// an object that can be fingerprinted from metadata and one that cannot.
readmeSum := sha256.Sum256(objects["README.md"])
aws.NewS3ClientFunc = func(_ *aws.AWSStaticCreds) (aws.S3API, error) {
return &aws.FakeS3Client{
Bucket: bucketName,
Objects: map[string][]byte{
"README.md": []byte("# kosli cli public\n"),
"dummy/dummy_2/template.yml": []byte("key: value\n"),
Bucket: bucketName,
Objects: objects,
Checksums: map[string]aws.FakeS3Checksum{
"README.md": {SHA256: base64.StdEncoding.EncodeToString(readmeSum[:]), Type: s3Types.ChecksumTypeFullObject},
},
}, nil
}
Expand Down Expand Up @@ -141,6 +151,28 @@ func (suite *SnapshotS3TestSuite) TestSnapshotS3Cmd() {
cmd: fmt.Sprintf(`snapshot s3 %s %s --bucket %s --download-budget 0`, suite.envName, suite.defaultKosliArguments, suite.bucketName),
golden: "Error: invalid --download-budget: size \"0\" must be at least 1 byte\n",
},
{
name: "--fingerprint-source metadata fingerprints from the stored checksum",
cmd: fmt.Sprintf(`snapshot s3 %s %s --bucket %s --include README.md --fingerprint-source metadata`, suite.envName, suite.defaultKosliArguments, suite.bucketName),
golden: "bucket kosli-cli-public was reported to environment snapshot-s3-env\n",
},
{
name: "--fingerprint-source content is the default behaviour",
cmd: fmt.Sprintf(`snapshot s3 %s %s --bucket %s --fingerprint-source content`, suite.envName, suite.defaultKosliArguments, suite.bucketName),
golden: "bucket kosli-cli-public was reported to environment snapshot-s3-env\n",
},
{
wantError: true,
name: "--fingerprint-source rejects an unknown value",
cmd: fmt.Sprintf(`snapshot s3 %s %s --bucket %s --fingerprint-source etag`, suite.envName, suite.defaultKosliArguments, suite.bucketName),
golden: "Error: etag is not a valid fingerprint source. Valid sources are: [content, metadata]\nUsage: kosli snapshot s3 ENVIRONMENT-NAME [flags]\n",
},
{
wantError: true,
name: "--fingerprint-source metadata fails on an object with no stored checksum",
cmd: fmt.Sprintf(`snapshot s3 %s %s --bucket %s --include dummy --fingerprint-source metadata`, suite.envName, suite.defaultKosliArguments, suite.bucketName),
golden: "Error: object key [dummy/dummy_2/template.yml] has no SHA256 checksum, so its fingerprint cannot be read from S3 metadata. Upload it with one: aws s3api put-object --bucket kosli-cli-public --key dummy/dummy_2/template.yml --body <file> --checksum-algorithm SHA256; or fingerprint by downloading the objects instead\n",
},
}

for _, t := range tests {
Expand Down
1 change: 1 addition & 0 deletions cmd/kosli/testdata/empty-flag-audit-coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,7 @@
"dry-run": "bool",
"exclude": "stringSlice",
"exclude-regex": "stringSlice",
"fingerprint-source": "string",
"include": "stringSlice",
"include-regex": "stringSlice"
},
Expand Down
Loading
Loading