diff --git a/.golangci.yml b/.golangci.yml index e85c026..c162e54 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -50,6 +50,7 @@ linters-settings: - strings/Builder.Write - strings/Builder.WriteRune - crypto/Hash.Write + - crypto/rand/Read # https://github.com/golang/go/issues/66821 - io/Discard.Write - os/Stderr.Write - os/Stdout.Write diff --git a/go.mod b/go.mod index 45e57fd..134eadb 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ -module github.com/tprasadtp/protonvpn-docker +module github.com/tprasadtp/knit go 1.22 require ( github.com/Masterminds/semver/v3 v3.2.1 github.com/google/go-containerregistry v0.19.1 - github.com/spf13/cobra v1.7.0 + github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 + github.com/tprasadtp/go-autotune v0.0.0-20240416215432-5a9494a44b9f golang.org/x/text v0.14.0 ) @@ -20,12 +22,11 @@ require ( github.com/klauspost/compress v1.16.5 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0-rc3 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sirupsen/logrus v1.9.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.8.4 // indirect github.com/vbatts/tar-split v0.11.3 // indirect - golang.org/x/sync v0.2.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 25f7e0e..1820024 100644 --- a/go.sum +++ b/go.sum @@ -3,32 +3,49 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0 github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k= github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o= +github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU= +github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docker/cli v24.0.0+incompatible h1:0+1VshNwBQzQAx9lOl+OYCTCEAD8fKs/qeXMx3O0wqM= github.com/docker/cli v24.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v26.0.1+incompatible h1:eZDuplk2jYqgUkNLDYwTBxqmY9cM3yHnmN6OIUEjL3U= +github.com/docker/cli v26.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v24.0.0+incompatible h1:z4bf8HvONXX9Tde5lGBMQ7yCJgNahmJumdrStZAbeY4= github.com/docker/docker v24.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v26.0.1+incompatible h1:t39Hm6lpXuXtgkF0dm1t9a5HkbUfdGy6XbWexmGr+hA= +github.com/docker/docker v26.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= +github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo= +github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-containerregistry v0.19.1 h1:yMQ62Al6/V0Z7CqIrrS1iYoA5/oQCm88DeNujc7C1KY= github.com/google/go-containerregistry v0.19.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -37,8 +54,10 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.1 h1:Ou41VVR3nMWWmTiEUnj0OlsgOSCUFgsPAOl6jRIcVtQ= github.com/sirupsen/logrus v1.9.1/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -50,17 +69,23 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tprasadtp/go-autotune v0.0.0-20240416215432-5a9494a44b9f h1:+VPTONK/5T4qTE82totZgwFQGHtvZ08d5dszad2XW7s= +github.com/tprasadtp/go-autotune v0.0.0-20240416215432-5a9494a44b9f/go.mod h1:uUQG+chdg9thdxWZ9Mi8b/GkO021tVrrZaBy6f5oHas= +github.com/tprasadtp/pkg v0.0.0-20231119010205-f198d9b7628f h1:oPNRw/tJnXmW4qH/4QLEsrBbHOVCj8CYbPktnEF7OW0= +github.com/tprasadtp/pkg v0.0.0-20231119010205-f198d9b7628f/go.mod h1:H70Nzda4f8BAgZo5+jBmlfr5tr9yF0cIu7oU4jThHa0= github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8= github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RVck= github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= +github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts= +github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk= golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= -golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= diff --git a/internal/builder/main.go b/internal/builder/main.go index cd48dab..b370794 100644 --- a/internal/builder/main.go +++ b/internal/builder/main.go @@ -8,7 +8,8 @@ import ( "os" "os/signal" - "github.com/tprasadtp/protonvpn-docker/internal/command" + _ "github.com/tprasadtp/go-autotune" + "github.com/tprasadtp/knit/internal/command" ) func main() { diff --git a/internal/command/common/flag.go b/internal/command/common/flag.go new file mode 100644 index 0000000..a6e92db --- /dev/null +++ b/internal/command/common/flag.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package common + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// AddTemplateFlags adds +// - String flag --template/-t +// - String flag --template-file/-T +// +// and marks both flags as MutuallyExclusive. +func AddTemplateFlags(cmd *cobra.Command, template, templateFile *string) { + cmd.Flags().StringVarP(template, "template", "t", "", "go template to render") + cmd.Flags().StringVarP(templateFile, "template-file", "T", "", "go template file to render") + cmd.MarkFlagsMutuallyExclusive("template", "template-file") +} + +// AddOutputFlagsWithAppend adds +// - String flag --output/-o for output file. +// - Bool flag --append/-a bool flag to indicate that output file should be appended, not overwritten. +// - Bool flag --append-at-newline bool flag to indicate that output should be written on a new line. +func AddOutputFlagsWithAppend(cmd *cobra.Command, output *string, append, lf *bool) { + cmd.Flags().StringVarP(output, "output", "o", "", "output file path") + cmd.Flags().BoolVarP(append, "append", "a", false, "append to output file") + cmd.Flags().BoolVar(lf, "append-at-newline", true, "write output on a newline") + cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName { + if name == "append-at-lf" { + name = "append-at-newline" + } + return pflag.NormalizedName(name) + }) +} diff --git a/internal/command/common/flag_enum.go b/internal/command/common/flag_enum.go new file mode 100644 index 0000000..da3598d --- /dev/null +++ b/internal/command/common/flag_enum.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package common + +import ( + "fmt" + "slices" + + "github.com/spf13/pflag" +) + +var _ pflag.Value = (*stringEnumFlag)(nil) + +// stringEnumFlag is a custom flag which restricts flag values to ones specified. +type stringEnumFlag struct { + allowed []string + value string +} + +func (e *stringEnumFlag) String() string { + return e.value +} + +func (e *stringEnumFlag) Type() string { + return "string" +} + +func (e *stringEnumFlag) Set(p string) error { + if !slices.Contains(e.allowed, p) { + return fmt.Errorf("") + } + e.value = p + return nil +} diff --git a/internal/command/root.go b/internal/command/root.go index 741c928..32acd1f 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -4,33 +4,51 @@ package command import ( - "context" "fmt" "github.com/spf13/cobra" - "github.com/tprasadtp/protonvpn-docker/internal/command/semver" + "github.com/tprasadtp/knit/internal/command/semver" + "github.com/tprasadtp/knit/internal/command/version" ) func RootCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "builder", - Short: "Builder for protonwire docker images", - Version: "dev", + Use: "knit", + Short: "A Toolkit for building docker images", + Version: version.Version(), DisableAutoGenTag: true, - SilenceUsage: true, CompletionOptions: cobra.CompletionOptions{ DisableDefaultCmd: true, }, } cmd.AddCommand(semver.NewCommand()) + cmd.AddCommand(version.NewVersionCmd()) + FixCobraBehavior(cmd) return cmd } -func RunRootCommand(ctx context.Context) error { - root := RootCommand() - err := root.ExecuteContext(ctx) - if err != nil { - return fmt.Errorf("cmd(root): %w", err) +// This is a workaround for cobra bug(s) which are unlikely to be fixed +// or have not been fixed yet. You provide root command to this function +// and it will fixup bugs in cobra and annoyances. +// Though backward incompatible changes are avoided, it cannot be guaranteed. +// - This will fix https://github.com/spf13/cobra/issues/706 +// which does not return an error on unknown sub-commands. +// This function fixes it by adding a RunE to the command. +func FixCobraBehavior(cmd *cobra.Command) { + // Iterate over all child commands + for _, cmd := range cmd.Commands() { + // Only apply fix if child command does not define Run or RunE. + if cmd.HasSubCommands() { + if cmd.Run == nil && cmd.RunE == nil { + cmd.RunE = func(c *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("please provide a valid sub-command for %s", c.Name()) + } + return fmt.Errorf("unknown sub-command %s for %s", args[0], c.Name()) + } + } + } + // Recursively run this function. + FixCobraBehavior(cmd) } - return nil } diff --git a/internal/command/semver/semver.go b/internal/command/semver/semver.go index 7a941a1..be09b4e 100644 --- a/internal/command/semver/semver.go +++ b/internal/command/semver/semver.go @@ -15,42 +15,82 @@ import ( "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" - "github.com/tprasadtp/protonvpn-docker/internal/sprout" + "github.com/tprasadtp/knit/internal/command/common" + "github.com/tprasadtp/knit/internal/shared" + "github.com/tprasadtp/knit/internal/sprout" ) type options struct { - template string - stdout io.Writer - append bool - prependLF bool - file string - prefix string + template string // template to render + version string // version to parse + templateFile string // read template to render from file + versionFile string // read version from file + stdout io.Writer // output io.writer + append bool // append to file + appendLF bool // append newline before writing to file + output string // output file + prefix string // prefix to remove + noTrimSpaces bool // do not trim spaces in version file or version } -func (o *options) Run(_ context.Context, args []string) error { - var buf bytes.Buffer +func (o *options) Run(_ context.Context, _ []string) error { var w io.Writer + var buf bytes.Buffer var tpl *template.Template + var input string + var templateStr string + + if o.versionFile != "" && o.version != "" { + return fmt.Errorf( + "cmd(semver): both version(%q) and version-file(%q) are defined", + o.version, o.versionFile, + ) + } - // Check if args are specified. - switch len(args) { - case 0: - return fmt.Errorf("cmd(semver): no VERSION specified") - case 1: - default: - return fmt.Errorf("cmd(semver): more than one version specified") + if o.templateFile != "" && o.template != "" { + return fmt.Errorf( + "cmd(semver): both template and template-file(%q) are defined", + o.templateFile, + ) + } + + // Try to read version from version file. + if o.versionFile != "" { + contents, err := shared.ReadSmallFile(o.versionFile, 1e3) + if err != nil { + return fmt.Errorf("cmd(semver): failed to read version file: %w", err) + } + input = string(contents) + } else { + input = o.version + } + + // Try to read template from template file. + if o.templateFile != "" { + contents, err := shared.ReadSmallFile(o.templateFile, 1e3) + if err != nil { + return fmt.Errorf("cmd(semver): failed to read template file: %w", err) + } + templateStr = string(contents) + } else { + templateStr = o.template + } + + // Trim whitespace unless disabled explicitly. + if !o.noTrimSpaces { + input = strings.TrimSpace(input) } // Validate version and strip prefix if any. - v, err := semver.NewVersion(strings.TrimPrefix(args[0], o.prefix)) + v, err := semver.NewVersion(strings.TrimPrefix(input, o.prefix)) if err != nil { - return fmt.Errorf("cmd(semver): invalid version(%q): %w", args[0], err) + return fmt.Errorf("cmd(semver): invalid version(%q): %w", input, err) } // Render the template into a buffer first. // This avoids modifying the output file if template is invalid or errors. - if o.template != "" { - tpl, err = template.New("semver").Funcs(sprout.FuncMap()).Parse(o.template) + if templateStr != "" { + tpl, err = template.New("semver").Funcs(sprout.FuncMap()).Parse(templateStr) if err != nil { return fmt.Errorf("cmd(semver): invalid template: %w", err) } @@ -62,7 +102,7 @@ func (o *options) Run(_ context.Context, args []string) error { buf.WriteString(v.String()) } - if o.file != "" && o.file != "-" { + if o.output != "" && o.output != "-" { var flag int if o.append { flag = os.O_CREATE | os.O_WRONLY | os.O_APPEND @@ -72,18 +112,18 @@ func (o *options) Run(_ context.Context, args []string) error { // Create a file if required. if append is not specified, existing file // will be truncated, if any. - file, err := os.OpenFile(o.file, flag, 0o644) + file, err := os.OpenFile(o.output, flag, 0o644) if err != nil { - return fmt.Errorf("cmd(semver): failed to open file(%s): %w", o.file, err) + return fmt.Errorf("cmd(semver): failed to open file(%s): %w", o.output, err) } defer file.Close() // Checks if file already contains a newline. // If appending is required simply use the template. - if o.append && o.prependLF { + if o.append && o.appendLF { stat, err := file.Stat() if err != nil { - return fmt.Errorf("cmd(semver): failed to stat file(%s): %w", o.file, err) + return fmt.Errorf("cmd(semver): failed to stat file(%s): %w", o.output, err) } if size := stat.Size(); size > 0 { x := make([]byte, 1) @@ -93,7 +133,7 @@ func (o *options) Run(_ context.Context, args []string) error { _, err = file.ReadAt(x, stat.Size()-2) } if err != nil { - return fmt.Errorf("cmd(semver): failed to read existing file(%s): %w", o.file, err) + return fmt.Errorf("cmd(semver): failed to read existing file(%s): %w", o.output, err) } if x[0] != '\n' { bufCopy := slices.Clone(buf.Bytes()) @@ -118,20 +158,43 @@ func (o *options) Run(_ context.Context, args []string) error { func NewCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "semver [OPTIONS] VERSION", + Use: "semver", Short: "Semantic version parser", Long: "Parses given semantic version and output in different formats.", - Args: cobra.ExactArgs(1), + Args: cobra.NoArgs, } opts := &options{ stdout: cmd.OutOrStdout(), } - cmd.Flags().StringVarP(&opts.file, "output", "o", "", "Output file path") - cmd.Flags().StringVarP(&opts.template, "template", "t", "", "Go template to render") - cmd.Flags().BoolVarP(&opts.append, "append", "a", false, "Append to output file") - cmd.Flags().BoolVar(&opts.append, "trim-prefix", false, "Trim the prefix if present") - cmd.Flags().BoolVar(&opts.append, "prepend-newline", false, "Prepend a newline to the file if required") + cmd.Flags().BoolVar(&opts.append, "prefix", false, "trim the prefix if present") + + // Version flags + cmd.Flags().StringVarP(&opts.version, "version", "v", "", "version to parse") + cmd.Flags().StringVar(&opts.versionFile, "version-file", "", "file to read semver from") + cmd.MarkFlagsOneRequired("version", "version-file") + _ = cmd.MarkFlagFilename("version-file") + + // Template flags + common.AddTemplateFlags(cmd, &opts.template, &opts.templateFile) + + // Append flags. This also aliases + common.AddOutputFlagsWithAppend(cmd, &opts.output, &opts.append, &opts.appendLF) + + // If --append-no-newline is enabled ensure that --append and --output are is marked as required. + // If --append is enabled, ensure --output is marked as required. + cmd.PreRunE = func(cmd *cobra.Command, _ []string) error { + if v, _ := cmd.Flags().GetBool("append-no-newline"); v { + _ = cmd.MarkFlagRequired("append") + _ = cmd.MarkFlagRequired("output") + } + + if v, _ := cmd.Flags().GetBool("append"); v { + _ = cmd.MarkFlagRequired("output") + } + return nil + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { return opts.Run(cmd.Context(), args) } diff --git a/internal/command/semver/semver_test.go b/internal/command/semver/semver_test.go index e740d17..0c797d2 100644 --- a/internal/command/semver/semver_test.go +++ b/internal/command/semver/semver_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/tprasadtp/protonvpn-docker/internal/testutils" + "github.com/tprasadtp/knit/internal/testutils" ) func TestCommand(t *testing.T) { diff --git a/internal/command/version/format.go b/internal/command/version/format.go new file mode 100644 index 0000000..eb5f041 --- /dev/null +++ b/internal/command/version/format.go @@ -0,0 +1,52 @@ +package version + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "text/template" + + "github.com/tprasadtp/knit/internal/version" +) + +// Renders given go template with version info to out. +func renderVersionTemplate(tpl string, out io.Writer) error { + if tpl == "" { + return errors.New("template is empty") + } + info := version.GetInfo() + vTemplate, err := template.New("version").Parse(tpl) + if err != nil { + return fmt.Errorf("template is invalid: %w", err) + } + return vTemplate.Execute(out, info) +} + +// asText writes text output to specified writer. +func asText(out io.Writer) error { + const template = `• Version : {{.Version}} +• GitCommit : {{.GitCommit}} +• BuildDate : {{.BuildDate}} +• GoVersion : {{.GoVersion}} +• Os : {{.Os}} +• Arch : {{.Arch}} +• Compiler : {{.Compiler}} +` + return renderVersionTemplate(template, out) +} + +// asJSON returns formatted JSON string to be printed. +func asJSON(out io.Writer) error { + b, err := json.MarshalIndent(version.GetInfo(), "", " ") + if err == nil { + _, err = out.Write(b) + } + return err +} + +// as Short returns just the version info. +func asShortText(out io.Writer) error { + _, err := out.Write([]byte(version.GetInfo().Version + "\n")) + return err +} diff --git a/internal/command/version/format_test.go b/internal/command/version/format_test.go new file mode 100644 index 0000000..43712aa --- /dev/null +++ b/internal/command/version/format_test.go @@ -0,0 +1,147 @@ +package version + +import ( + "bytes" + "encoding/json" + "runtime" + "testing" + + "github.com/tprasadtp/knit/internal/version" +) + +func TestTemplateValid(t *testing.T) { + buf := &bytes.Buffer{} + tests := []struct { + name string + template string + expect string + }{ + { + name: "Version", + template: "{{.Version}}", + expect: "v0.0.0+undefined", + }, + { + name: "GitCommit", + template: "{{.GitCommit}}", + expect: "", + }, + { + name: "BuildDate", + template: "{{.BuildDate}}", + expect: "1970-01-01T00:00+00:00", + }, + { + name: "Compiler", + template: "{{.Compiler}}", + expect: runtime.Compiler, + }, + { + name: "GoVersion", + template: "{{.GoVersion}}", + expect: runtime.Version(), + }, + { + name: "Os", + template: "{{.Os}}", + expect: runtime.GOOS, + }, + { + name: "Arch", + template: "{{.Arch}}", + expect: runtime.GOARCH, + }, + { + name: "NO_VARIABLES", + template: "NO_VARIABLES", + expect: "NO_VARIABLES", + }, + } + for _, tc := range tests { + buf.Reset() + t.Run(tc.name, func(t *testing.T) { + err := renderVersionTemplate(tc.template, buf) + if err != nil { + t.Errorf("expected no error: %q", err) + } + if buf.String() != tc.expect { + t.Errorf("output mismatch\nexpected(%q)\n(got)%q", tc.expect, buf.String()) + } + }) + } +} + +func TestTemplateInvalid(t *testing.T) { + buf := &bytes.Buffer{} + tests := []struct { + name string + template string + expect string + }{ + { + name: "Unclosed Bracket", + template: "{{.Version}", + }, + { + name: "No doT", + template: "{{GitCommit}}", + }, + { + name: "Empty", + template: "", + }, + { + name: "No such field in Info struct", + template: "{{.NoSuchStructField}}", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + buf.Reset() + err := renderVersionTemplate(tc.template, buf) + if err == nil { + t.Errorf("invalid template must error") + } + if buf.Len() != 0 { + t.Errorf("invalid template must not write anything to io.Writer") + } + }) + } +} + +func Test_asText(t *testing.T) { + buf := &bytes.Buffer{} + err := asText(buf) + if err != nil { + t.Errorf("asText must not error: %q", err) + } + if buf.String() == "" { + t.Errorf("asText must return non empty output") + } +} + +func Test_ShortText(t *testing.T) { + buf := &bytes.Buffer{} + err := asShortText(buf) + if err != nil { + t.Errorf("asText must not error: %q", err) + } + if buf.String() != version.GetInfo().Version+"\n" { + t.Errorf("asText must return just the version ending with newline") + } +} + +func Test_asJSON(t *testing.T) { + buf := &bytes.Buffer{} + err := asJSON(buf) + if err != nil { + t.Errorf("asJSON must not error: %q", err) + } + if buf.Len() == 0 { + t.Errorf("asJSON must return non empty output") + } + v := &version.Info{} + if unmarshalErr := json.Unmarshal(buf.Bytes(), v); unmarshalErr != nil { + t.Errorf("unmarshalling json ouput must not error, bit got %s", unmarshalErr) + } +} diff --git a/internal/command/version/version.go b/internal/command/version/version.go new file mode 100644 index 0000000..b31f3d9 --- /dev/null +++ b/internal/command/version/version.go @@ -0,0 +1,89 @@ +package version + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + "github.com/tprasadtp/knit/internal/command/common" + "github.com/tprasadtp/knit/internal/version" +) + +// Version command options. +type options struct { + format string + template string + templateFile string + output string + append bool + appendLF bool + stdout io.Writer +} + +func (o *options) Run(_ context.Context, _ []string) error { + return nil +} + +// RunE for version command. +func (o *options) RunE(cmd *cobra.Command, args []string) error { + if o.template != "" { + o.format = "template" + } + switch o.format { + case "text", "pretty", "simple", "": + return asText(cmd.OutOrStdout()) + case "short": + return asShortText(cmd.OutOrStdout()) + case "json": + return asJSON(cmd.OutOrStdout()) + case "template": + return renderVersionTemplate(o.template, cmd.OutOrStdout()) + default: + return fmt.Errorf("not a valid format - %s", o.format) + } +} + +func Version() string { + return version.GetInfo().Version +} + +// NewVersionCmd returns a version command with options to +// output in json and templated string format. +func NewVersionCmd() *cobra.Command { + o := &options{} + cmd := &cobra.Command{ + Use: "version", + Args: cobra.NoArgs, + Short: "Show version and build information", + RunE: o.RunE, + } + cmd.Long = `Show version and build information. + +When using the --template flag the following properties are +available to use in the template: + +- .Version contains the semantic version. +- .GitCommit is the git commit SHA1 hash. +- .BuildDate is build date. +- .GoVersion contains the version of Go that binary was compiled with. +- .Os is operating system (GOOS). +- .Arch is system architecture (GOARCH). +- .Compiler is the Go compiler used to build the binary. +` + + cmd.Flags().StringVar(&o.format, "format", "text", "output format") + common.AddTemplateFlags(cmd, &o.template, &o.templateFile) + //nolint: errcheck // ignore + cmd.RegisterFlagCompletionFunc( + "format", + func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{ + "text\tTextual format", + "json\tOutput as JSON", + }, + cobra.ShellCompDirectiveNoFileComp + }, + ) + return cmd +} diff --git a/internal/command/version/version_test.go b/internal/command/version/version_test.go new file mode 100644 index 0000000..9ef1ead --- /dev/null +++ b/internal/command/version/version_test.go @@ -0,0 +1,139 @@ +package version + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/tprasadtp/knit/internal/version" +) + +func TestVersionCmd_Template(t *testing.T) { + var stdout = new(bytes.Buffer) + var stderr = new(bytes.Buffer) + type testCase struct { + Name string + Args []string + Verifier func(t *testing.T, stdout, stderr *bytes.Buffer, err error) + } + tt := []testCase{ + { + Name: "version-short", + Args: []string{"version", "--format=short"}, + Verifier: func(t *testing.T, stdout, stderr *bytes.Buffer, err error) { + if stdout.String() != version.GetInfo().Version+"\n" { + t.Errorf("stdout: must return version ending with a newline, got=%s", stdout.String()) + } + if stderr.String() != "" { + t.Errorf("stdout: expected empty, got=%s", stderr.String()) + } + + if err != nil { + t.Errorf("must not return an error, got=%s", err) + } + }, + }, + { + Name: "version-template-1", + Args: []string{"version", "--template={{.Version}}"}, + Verifier: func(t *testing.T, stdout, stderr *bytes.Buffer, err error) { + if stdout.String() != version.GetInfo().Version { + t.Errorf("stdout: must return version, got=%s", stdout.String()) + } + if stderr.String() != "" { + t.Errorf("stdout: expected empty, got=%s", stderr.String()) + } + + if err != nil { + t.Errorf("must not return an error, got=%s", err) + } + }, + }, + { + Name: "version-format-text", + Args: []string{"version", "--format=text"}, + Verifier: func(t *testing.T, stdout, stderr *bytes.Buffer, err error) { + output := stdout.String() + contains := "• GitCommit" + if !strings.Contains(output, contains) { + t.Errorf("stdout: must contain %s, got=%s", contains, output) + } + if stderr.String() != "" { + t.Errorf("stdout: expected empty, got=%s", stderr.String()) + } + + if err != nil { + t.Errorf("must not return an error, got=%s", err) + } + }, + }, + { + Name: "version-format-json", + Args: []string{"version", "--format=json"}, + Verifier: func(t *testing.T, stdout, stderr *bytes.Buffer, err error) { + if jerr := json.Unmarshal(stdout.Bytes(), &version.Info{}); jerr != nil { + t.Errorf("stdout: must return json output, got=%s", jerr) + } + if stderr.String() != "" { + t.Errorf("stdout: expected empty, got=%s", stderr.String()) + } + + if err != nil { + t.Errorf("must not return an error, got=%s", err) + } + }, + }, + { + Name: "version-format-invalid", + Args: []string{"version", "--format=latin"}, + Verifier: func(t *testing.T, stdout, stderr *bytes.Buffer, err error) { + if err == nil { + t.Errorf("must return an error on invalid format") + } + }, + }, + { + Name: "version-template-invalid", + Args: []string{"version", "--template={{.NoSuchField}}"}, + Verifier: func(t *testing.T, stdout, stderr *bytes.Buffer, err error) { + if err == nil { + t.Errorf("must return an error on invalid template") + } + }, + }, + { + Name: "version-conflicting-flags", + Args: []string{"version", "--template={{.Version}}", "--format=json"}, + Verifier: func(t *testing.T, stdout, stderr *bytes.Buffer, err error) { + if err == nil { + t.Errorf("must return an error on conflicting flags") + } + }, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + root := &cobra.Command{ + Use: "blackhole-entropy", + Short: "Black Hole Entropy CLI", + Long: "CLI to seed system's PRNG with entropy from M87 Black Hole", + } + root.SetOut(stdout) + root.SetErr(stderr) + root.AddCommand(NewVersionCmd()) + root.SetArgs(tc.Args) + err := root.Execute() + + if tc.Verifier == nil { + t.Fatalf("no verifier specified!") + } else { + tc.Verifier(t, stdout, stderr, err) + } + }) + } +} diff --git a/internal/shared/file.go b/internal/shared/file.go new file mode 100644 index 0000000..eff513d --- /dev/null +++ b/internal/shared/file.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package shared + +import ( + "fmt" + "io" + "os" +) + +func ReadSmallFile(path string, max int64) ([]byte, error) { + vf, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("shared(file): failed to open file(%q): %w", path, err) + } + defer vf.Close() + stat, err := vf.Stat() + if err != nil { + return nil, fmt.Errorf("shared(file): failed to stat file(%q): %w", path, err) + } + + if max > 0 { + if stat.Size() > max { + return nil, fmt.Errorf("shared(file): file(%q) is too large(%dB)", path, stat.Size()) + } + } + + contents, err := io.ReadAll(vf) + if err != nil { + return nil, fmt.Errorf("shared(file): error reading file(%q): %w", path, err) + } + return contents, nil +} diff --git a/internal/shared/file_test.go b/internal/shared/file_test.go new file mode 100644 index 0000000..b192c57 --- /dev/null +++ b/internal/shared/file_test.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package shared + +import ( + "fmt" + "math/rand/v2" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/tprasadtp/knit/internal/testutils" +) + +func TestReadSmallFile(t *testing.T) { + tt := []struct { + name string + contents []byte + max int64 + ok bool + pre func(t *testing.T, path string) + }{ + { + name: "empty-file", + ok: true, + }, + { + name: "small-file", + contents: []byte("foo\nbar\n"), + ok: true, + }, + { + name: "small-file-max-negative", + contents: []byte("foo\nbar\n"), + max: -1, + ok: true, + }, + { + name: "small-file-larger-than-specified", + contents: []byte("foo\nbar\n"), + max: 1, + }, + { + name: "missing-file", + contents: []byte("foo\nbar\n"), + pre: func(t *testing.T, path string) { + err := os.Remove(path) + if err != nil { + t.Fatalf("Failed to remove file: %s", err) + } + }, + }, + } + + dir := t.TempDir() + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, fmt.Sprintf("%d.test", rand.Int())) + testutils.CreateFileWithContents(t, path, tc.contents) + + if tc.pre != nil { + tc.pre(t, path) + } + + contents, err := ReadSmallFile(path, tc.max) + if tc.ok { + if !slices.Equal(tc.contents, contents) { + t.Errorf("expected=%q, got=%q", tc.contents, contents) + } + if err != nil { + t.Errorf("expected no error, got %s", err) + } + } else { + if err == nil { + t.Errorf("expected an error, got nil") + } + if contents != nil { + t.Errorf("expected nil, got %s", contents) + } + } + }) + } +} diff --git a/internal/shared/template.go b/internal/shared/template.go new file mode 100644 index 0000000..6aa5f42 --- /dev/null +++ b/internal/shared/template.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package shared + +import ( + "fmt" + "io" + "io/fs" + "os" + "text/template" + + "github.com/tprasadtp/knit/internal/sprout" +) + +func RenderTemplateToFile(path, tpl string, append, lf bool, mode fs.FileMode, data any) error { + var flag int + + // Truncate the file if append is not specified. + if append { + flag = os.O_CREATE | os.O_RDWR | os.O_APPEND + } else { + flag = os.O_CREATE | os.O_WRONLY | os.O_TRUNC + } + + // If mode is not specified, default to read/write owner. + if mode == 0 { + mode = fs.FileMode(0o600) + } + + file, err := os.OpenFile(path, flag, mode) + if err != nil { + return fmt.Errorf("cmd(semver): failed to open file(%q): %w", path, err) + } + defer file.Close() + return RenderTemplate(file, tpl, data) +} + +func RenderTemplate(w io.Writer, tpl string, data any) error { + t, err := template.New("semver").Funcs(sprout.FuncMap()).Parse(tpl) + if err != nil { + return fmt.Errorf("shared(template): invalid template: %w", err) + } + err = t.Execute(w, data) + if err != nil { + return fmt.Errorf("shared(template): failed to render template: %w", err) + } + return nil +} diff --git a/internal/shared/template_test.go b/internal/shared/template_test.go new file mode 100644 index 0000000..3bd27bd --- /dev/null +++ b/internal/shared/template_test.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package shared + +import "testing" + +func TestRenderTemplateToFile(t *testing.T) { + tt := []struct { + name string + template string + data any + expect []byte + ok bool + }{} + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) {}) + } +} diff --git a/internal/testutils/test_context.go b/internal/testutils/context.go similarity index 100% rename from internal/testutils/test_context.go rename to internal/testutils/context.go diff --git a/internal/testutils/file.go b/internal/testutils/file.go new file mode 100644 index 0000000..a941171 --- /dev/null +++ b/internal/testutils/file.go @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package testutils + +import ( + "bytes" + "crypto/rand" + "io" + "io/fs" + "os" + "path/filepath" + "testing" +) + +// CreateFileWithJunk is a helper which creates file specified and fills it with junk bytes +// of size specified. If size > 100MB test errors. +func CreateFileWithJunk(t *testing.T, path string, size uint64) { + t.Helper() + + if size > 100e6 { + t.Fatalf("refusing to write > 100MB of juk bytes.") + } + + junk := make([]byte, size) + _, err := rand.Reader.Read(junk) + if err != nil { + t.Fatalf("failed to read random %d bytes: %s", size, err) + } + CreateFileWithContents(t, path, junk) +} + +// CreateFileWithContents is a helper which creates file specified and fills it with content +// specified. +func CreateFileWithContents[T string | []byte](t *testing.T, path string, contents T) { + t.Helper() + + if !filepath.IsAbs(path) { + t.Fatalf("path must be absolute: %s", path) + } + + temp, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY|os.O_TRUNC, os.FileMode(0o600)) + if err != nil { + t.Fatalf("failed to create file(%q): %s", path, err) + } + + _, err = temp.Write([]byte(contents)) + if err != nil { + t.Fatalf("failed write contents to file(%q): %s", path, err) + } + err = temp.Close() + if err != nil { + t.Fatalf("failed to close file(%q): %s", path, err) + } +} + +func AssertFileNotEmpty(t *testing.T, path string) { + file, err := os.Open(path) + if err != nil { + t.Errorf("failed to open file(%q): %s", path, err) + return + } + t.Cleanup(func() { + file.Close() + }) + stat, err := file.Stat() + if err != nil { + t.Errorf("failed to stat file(%q): %s", path, err) + return + } + + if stat.Size() <= 0 { + t.Errorf("expected non empty file: %q", path) + } +} + +func RequireFileNotEmpty(t *testing.T, path string) { + file, err := os.Open(path) + if err != nil { + t.Fatalf("failed to open file(%q): %s", path, err) + } + t.Cleanup(func() { + file.Close() + }) + stat, err := file.Stat() + if err != nil { + t.Fatalf("failed to stat file(%q): %s", path, err) + } + + if stat.Size() <= 0 { + t.Fatalf("expected non empty file: %q", path) + } +} + +func AssertFileMode(t *testing.T, path string, mode fs.FileMode) { + stat, err := os.Stat(path) + if err != nil { + t.Errorf("failed to stat file(%q): %s", path, err) + return + } + + if stat.Mode() != mode { + t.Errorf("expected file mode=%s(%d) got=%s(%d)", stat.Mode(), stat.Mode(), mode, mode) + } +} + +func RequireFileMode(t *testing.T, path string, mode fs.FileMode) { + stat, err := os.Stat(path) + if err != nil { + t.Fatalf("failed to stat file(%q): %s", path, err) + } + + if stat.Mode() != mode { + t.Fatalf("expected file mode=%s(%d) got=%s(%d)", stat.Mode(), stat.Mode(), mode, mode) + } +} + +func AssertFileContents(t *testing.T, path string, contents []byte) { + file, err := os.Open(path) + if err != nil { + t.Errorf("failed to open file(%q): %s", path, err) + return + } + t.Cleanup(func() { + file.Close() + }) + stat, err := file.Stat() + if err != nil { + t.Errorf("failed to stat file(%q): %s", path, err) + return + } + + if stat.Size() > 50e3 { + t.Errorf("file is too large to load in memory: %s", path) + return + } + + buf, err := io.ReadAll(file) + if err != nil { + t.Errorf("failed to read file(%q): %s", path, err) + return + } + + if !bytes.Equal(buf, contents) { + t.Errorf("expected=%v, got=%v", contents, buf) + return + } +} + +func RequireFileContents(t *testing.T, path string, contents []byte) { + file, err := os.Open(path) + if err != nil { + t.Fatalf("failed to open file(%q): %s", path, err) + } + t.Cleanup(func() { + file.Close() + }) + stat, err := file.Stat() + if err != nil { + t.Fatalf("failed to stat file(%q): %s", path, err) + } + + if stat.Size() > 50e3 { + t.Fatalf("file is too large to load in memory: %s", path) + } + + buf, err := io.ReadAll(file) + if err != nil { + t.Fatalf("failed to read file(%q): %s", path, err) + } + + if !bytes.Equal(buf, contents) { + t.Fatalf("expected=%v, got=%v", contents, buf) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..d0aee74 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +// Package version is a helper for processing +// VCS, build, and runtime information of the binary. +// +// You can inject them at build time via ld flags. +// If not already injected, this uses debug.ReadBuildInfo, +// to get version information if any. +package version + +import ( + "runtime" + "runtime/debug" + "sync" +) + +// Can override these at compile time. +var ( + // version is usually the git tag. MUST be semver compatible. + // + // You can override at build time using + // -X github.com/pkg/version.version = "your-desired-version" + version = "" + + // commit is git commit sha1 hash + // + // You can override at build time using + // -X github.com/pkg/version.commit = "a-commit-hash" + commit = "" + + // buildDate is build date. + // For reproducible builds, set this to source epoch or commit date. + // + // You can override at build time using + // -X github.com/pkg/version.buildDate = "build-date-in-format" + buildDate = "" + + // gitTreeState is git tree state. + // You can override at build time using + // -X github.com/pkg/version.gitTreeState = "clean" + gitTreeState = "" + + // once is sync.Once for getting build info from ReadBuildInfo. + once sync.Once +) + +// Info describes the build, revision and runtime information. +type Info struct { + // Version indicates which version of the binary is running. + // In most cases this should be semver compatible string. + // + // Because we use go modules, it MUST include a prefix "v". + // See [golang/go/issues/30146] as to why. + // + // [golang/go/issues/30146]: https://github.com/golang/go/issues/30146 + Version string `json:"version" yaml:"version"` + + // Commit indicates which git sha1 commit hash. + Commit string `json:"commit" yaml:"commit"` + + // GitTreeState + GitTreeState string `json:"gitTreeState" yaml:"gitTreeState"` + + // BuildDate date of the build. + // You can set this to CommitDate to get truly reproducible and verifiable builds. + BuildDate string `json:"buildDate" yaml:"buildDate"` + + // GoVersion version of Go runtime. + GoVersion string `json:"goVersion" yaml:"goVersion"` + + // OperatingSystem this is operating system in GOOS + Os string `json:"os" yaml:"os"` + + // Arch this is system Arch + Arch string `json:"platform" yaml:"arch"` + + // Compiler is Go compiler. + // This is useful in determining if binary was built using gccgo. + Compiler string `json:"compiler" yaml:"compiler"` +} + +// GetInfo returns version information. This usually relies on +// build tools injecting version info via ld flags. +func GetInfo() Info { + // Read from debug.ReadBuildInfo() if required. + once.Do(func() { + // only if commit or build date are not defined. + if commit == "" || buildDate == "" { + v, ok := debug.ReadBuildInfo() + if ok { + for _, item := range v.Settings { + switch item.Key { + case "vcs.revision": + if commit == "" { + commit = item.Value + } + case "vcs.time": + if buildDate == "" { + buildDate = item.Value + } + case "vcs.modified": + if gitTreeState == "" { + gitTreeState = item.Value + } + } + } + } + } + }) + + return Info{ + Version: version, + Commit: commit, + BuildDate: buildDate, + GitTreeState: gitTreeState, + GoVersion: runtime.Version(), + Os: runtime.GOOS, + Arch: runtime.GOARCH, + Compiler: runtime.Compiler, + } +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..fac5321 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package version + +import ( + "encoding/json" + "testing" +) + +func TestJSON(t *testing.T) { + v := GetInfo() + out, err := json.MarshalIndent(v, "", "\t") + if err != nil { + t.Error("Failed to marshal JSON") + } + if out == nil { + t.Error("JSON Marshal is empty") + } +} + +func TestGetWithOverride(t *testing.T) { + tests := []struct { + name string + version string + expect string + }{ + { + name: "with-prefix", + version: "v1.22.333+dev", + expect: "v1.22.333+dev", + }, + { + name: "without-prefix", + version: "1.22.333+dev", + expect: "1.22.333+dev", + }, + { + name: "non-semver", + version: "2022-01-31.2", + expect: "2022-01-31.2", + }, + { + name: "non-semver-with-prefix", + version: "v2022-01-31.2", + expect: "v2022-01-31.2", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + version = tc.version + got := GetInfo() + if got.Version != tc.expect { + t.Errorf("got=%v, expected=%v", got, tc.expect) + } + }) + } +} + +// disabled because of +// - https://github.com/golang/go/issues/33976, +// - https://github.com/golang/go/issues/52600 +// func TestGetWithoutOverride(t *testing.T) { +// info := GetInfo() +// if info.Version == "" { +// t.Errorf("GetInfo().Version s empty when it should be populated automatically") +// } +// } diff --git a/scripts/lf.go b/scripts/lf.go new file mode 100644 index 0000000..4243da2 --- /dev/null +++ b/scripts/lf.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright 2024 Prasad Tengse +// SPDX-License-Identifier: GPLv3-only + +package main + +import ( + "log" + "os" +) + +func main() { + file, err := os.OpenFile(os.Args[1], os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + log.Fatalln(err) + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + log.Fatalln(err) + } + + if stat.Size() > 0 { + b := make([]byte, 1) + _, err = file.ReadAt(b, stat.Size()-1) + if err != nil { + log.Fatalln(err) + } + if b[0] == '\n' { + log.Println("has new line") + } else { + log.Println("no-new line") + } + } else { + log.Println("empty file") + } +}