diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 995b277..b3acb8c 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,6 +2,7 @@ \ No newline at end of file diff --git a/cmd/bifroest/common.go b/cmd/bifroest/common.go new file mode 100644 index 0000000..8906b48 --- /dev/null +++ b/cmd/bifroest/common.go @@ -0,0 +1,11 @@ +package main + +import goos "os" + +func workingDirectory() string { + v, err := goos.Getwd() + if err == nil { + return v + } + return "/" +} diff --git a/cmd/bifroest/exec.go b/cmd/bifroest/exec.go new file mode 100644 index 0000000..7e3eb52 --- /dev/null +++ b/cmd/bifroest/exec.go @@ -0,0 +1,101 @@ +package main + +import ( + goos "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/alecthomas/kingpin/v2" + + "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/sys" +) + +var _ = registerCommand(func(app *kingpin.Application) { + opts := execOpts{ + workingDirectory: workingDirectory(), + environment: sys.EnvVars{}, + } + + cmd := app.Command("exec", "Runs a given process with the given attributes (environment, working directory, ...)."). + Hidden(). + Action(func(*kingpin.ParseContext) error { + return doExec(&opts) + }) + cmd.Flag("workingDir", "Directory to start in."). + Short('d'). + Default(opts.workingDirectory). + PlaceHolder(""). + StringVar(&opts.workingDirectory) + cmd.Flag("executable", "Path to executable to be used. If not defined, first argument will be used."). + Short('p'). + Default(opts.path). + PlaceHolder(""). + StringVar(&opts.path) + cmd.Flag("env", "Environment variables to execute the process with."). + Short('e'). + StringMapVar(&opts.environment) + cmd.Arg("command", "Command to execute."). + Required(). + StringsVar(&opts.argv) + + registerExecCmdFlags(cmd, &opts) +}) + +func doExec(opts *execOpts) error { + fail := func(err error) error { + return err + } + failf := func(msg string, args ...any) error { + return fail(errors.System.Newf(msg, args...)) + } + + cmd := exec.Cmd{ + Dir: opts.workingDirectory, + SysProcAttr: &syscall.SysProcAttr{}, + Env: (sys.EnvVars(opts.environment)).Strings(), + Stderr: goos.Stderr, + Stdin: goos.Stdin, + Stdout: goos.Stdout, + Args: opts.argv, + Path: opts.path, + } + + if cmd.Path == "" && len(cmd.Args) > 0 { + cmd.Path = cmd.Args[0] + } + var err error + if cmd.Path, err = exec.LookPath(cmd.Path); err != nil { + return fail(err) + } + + if err := enrichExecCmd(&cmd, opts); err != nil { + return failf("cannot apply execution parameters to %v: :%w", cmd.Args, err) + } + + sigs := make(chan goos.Signal, 1) + defer close(sigs) + signal.Notify(sigs) + + go func() { + for { + sig, ok := <-sigs + if !ok { + return + } + _ = cmd.Process.Signal(sig) + } + }() + + err = cmd.Run() + var eErr *exec.ExitError + if errors.As(err, &eErr) { + goos.Exit(eErr.ExitCode()) + return nil + } else if err != nil { + return fail(err) + } else { + return nil + } +} diff --git a/cmd/bifroest/exec_unix.go b/cmd/bifroest/exec_unix.go new file mode 100644 index 0000000..38a7c7d --- /dev/null +++ b/cmd/bifroest/exec_unix.go @@ -0,0 +1,71 @@ +//go:build unix + +package main + +import ( + "os/exec" + "os/user" + "strconv" + "syscall" + + "github.com/alecthomas/kingpin/v2" + + "github.com/engity-com/bifroest/pkg/errors" +) + +type execOpts struct { + workingDirectory string + environment map[string]string + user, group string + path string + argv []string +} + +func registerExecCmdFlags(cmd *kingpin.CmdClause, opts *execOpts) { + cmd.Flag("user", "User the process should run with."). + Default(opts.user). + Short('u'). + StringVar(&opts.user) + cmd.Flag("group", "Group the process should run with."). + Default(opts.group). + Short('g'). + StringVar(&opts.group) +} + +func enrichExecCmd(cmd *exec.Cmd, with *execOpts) error { + if plainUser := with.user; plainUser != "" { + cmd.SysProcAttr.Credential = &syscall.Credential{} + + u, err := user.LookupId(plainUser) + var uuiErr *user.UnknownUserIdError + if errors.As(err, &uuiErr) { + u, err = user.Lookup(plainUser) + } + if err != nil { + return err + } + if v, err := strconv.ParseUint(u.Uid, 10, 32); err != nil { + return err + } else { + cmd.SysProcAttr.Credential.Uid = uint32(v) + } + + if plainGroup := with.group; plainGroup != "" { + g, err := user.LookupGroupId(plainGroup) + var ugiErr *user.UnknownGroupIdError + if errors.As(err, &ugiErr) { + g, err = user.LookupGroup(plainGroup) + } + if err != nil { + return err + } + if v, err := strconv.ParseUint(g.Gid, 10, 32); err != nil { + return err + } else { + cmd.SysProcAttr.Credential.Gid = uint32(v) + } + } + } + + return nil +} diff --git a/cmd/bifroest/exec_windows.go b/cmd/bifroest/exec_windows.go new file mode 100644 index 0000000..a0e826f --- /dev/null +++ b/cmd/bifroest/exec_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package main + +import ( + "os/exec" + + "github.com/alecthomas/kingpin/v2" +) + +type execOpts struct { + workingDirectory string + environment map[string]string + path string + argv []string +} + +func registerExecCmdFlags(_ *kingpin.CmdClause, _ *execOpts) { +} + +func enrichExecCmd(_ *exec.Cmd, _ *execOpts) error { + return nil +} diff --git a/cmd/bifroest/imp-init.go b/cmd/bifroest/imp-init.go new file mode 100644 index 0000000..4ba9a1e --- /dev/null +++ b/cmd/bifroest/imp-init.go @@ -0,0 +1,60 @@ +package main + +import ( + "io" + goos "os" + "path/filepath" + + "github.com/alecthomas/kingpin/v2" + log "github.com/echocat/slf4g" + + "github.com/engity-com/bifroest/pkg/common" + "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/imp" + "github.com/engity-com/bifroest/pkg/sys" +) + +var _ = registerCommand(func(app *kingpin.Application) { + targetPath := imp.DefaultInitPath + + cmd := app.Command("imp-init", "Prepares the environment to run Bifröst's imp inside."). + Hidden(). + Action(func(*kingpin.ParseContext) error { + return doImpInit(targetPath) + }) + cmd.Flag("targetPath", "Path to prepare."). + Default(targetPath). + PlaceHolder(""). + StringVar(&targetPath) +}) + +func doImpInit(targetPath string) (rErr error) { + log.WithAll(sys.VersionToMap(versionV)). + With("targetPath", targetPath). + Info("initialize target path for Engity's Bifröst imp...") + + self, err := goos.Executable() + if err != nil { + return errors.System.Newf("cannot detect own location: %w", err) + } + + sf, err := goos.Open(self) + if err != nil { + return errors.System.Newf("cannot open self (%s) for reading: %w", self, err) + } + defer common.IgnoreCloseError(sf) + + _ = goos.MkdirAll(targetPath, 0755) + targetFile := filepath.Join(targetPath, versionV.Os().AppendExtToFilename("bifroest")) + tf, err := goos.OpenFile(targetFile, goos.O_CREATE|goos.O_TRUNC|goos.O_WRONLY, 0755) + if err != nil { + return errors.System.Newf("cannot open target file (%s) for writing: %w", targetFile, err) + } + defer common.KeepCloseError(&rErr, tf) + + if _, err := io.Copy(tf, sf); err != nil { + return errors.System.Newf("cannot copy %s to %s: %w", self, targetFile, err) + } + + return nil +} diff --git a/cmd/bifroest/run.go b/cmd/bifroest/run.go index d4ef928..94df372 100644 --- a/cmd/bifroest/run.go +++ b/cmd/bifroest/run.go @@ -17,7 +17,7 @@ var _ = registerCommand(func(app *kingpin.Application) { configureRunCmd(app) }) -func doRunDefault(conf configuration.ConfigurationRef) error { +func doRunDefault(conf configuration.Ref) error { svc := service.Service{ Configuration: *conf.Get(), Version: versionV, diff --git a/cmd/bifroest/run_unix.go b/cmd/bifroest/run_unix.go index 764eac6..c6ae1a1 100644 --- a/cmd/bifroest/run_unix.go +++ b/cmd/bifroest/run_unix.go @@ -13,7 +13,7 @@ const ( ) func configureRunCmd(app *kingpin.Application) *kingpin.Application { - var conf configuration.ConfigurationRef + var conf configuration.Ref cmd := app.Command("run", "Runs the service."). Action(func(*kingpin.ParseContext) error { return doRun(conf) @@ -26,6 +26,6 @@ func configureRunCmd(app *kingpin.Application) *kingpin.Application { return app } -func doRun(conf configuration.ConfigurationRef) error { +func doRun(conf configuration.Ref) error { return doRunDefault(conf) } diff --git a/cmd/bifroest/run_windows.go b/cmd/bifroest/run_windows.go index 5274ef1..26614fa 100644 --- a/cmd/bifroest/run_windows.go +++ b/cmd/bifroest/run_windows.go @@ -21,7 +21,7 @@ const ( func configureRunCmd(app *kingpin.Application) *kingpin.Application { var ws windowsService - var conf configuration.ConfigurationRef + var conf configuration.Ref cmd := app.Command("run", "Runs the service."). Action(func(*kingpin.ParseContext) error { return doRun(conf, &ws) @@ -36,7 +36,7 @@ func configureRunCmd(app *kingpin.Application) *kingpin.Application { } -func doRun(conf configuration.ConfigurationRef, ws *windowsService) error { +func doRun(conf configuration.Ref, ws *windowsService) error { inService, err := svc.IsWindowsService() if err != nil { return errors.System.Newf("failed to determine if we are running in service: %w", err) diff --git a/cmd/bifroest/service_windows.go b/cmd/bifroest/service_windows.go index 7cbd87b..3a54613 100644 --- a/cmd/bifroest/service_windows.go +++ b/cmd/bifroest/service_windows.go @@ -27,7 +27,7 @@ const ( type windowsService struct { name string - conf configuration.ConfigurationRef + conf configuration.Ref logger *eventlog.Log } @@ -42,7 +42,7 @@ func (this *windowsService) registerFlagsAt(cmd *kingpin.CmdClause) *kingpin.Cmd var _ = registerCommand(func(app *kingpin.Application) { svcCmd := app.Command("service", "") - var conf configuration.ConfigurationRef + var conf configuration.Ref var svc windowsService common := func(cmd *kingpin.CmdClause) *kingpin.CmdClause { return svc.registerFlagsAt(cmd) @@ -123,7 +123,7 @@ func (this *windowsService) Execute(_ []string, r <-chan svc.ChangeRequest, chan return false, 0 } -func (this *windowsService) install(conf configuration.ConfigurationRef, start, retry bool) error { +func (this *windowsService) install(conf configuration.Ref, start, retry bool) error { if err := conf.MakeAbsolute(); err != nil { return err } diff --git a/cmd/bifroest/sftp-server.go b/cmd/bifroest/sftp-server.go index aeba05a..5111c0e 100644 --- a/cmd/bifroest/sftp-server.go +++ b/cmd/bifroest/sftp-server.go @@ -8,30 +8,23 @@ import ( "github.com/engity-com/bifroest/pkg/sftp" ) -var ( - workingDir = func() string { - v, err := goos.Getwd() - if err == nil { - return v - } - return "/" - }() -) - var _ = registerCommand(func(app *kingpin.Application) { + cwd := workingDirectory() + cmd := app.Command("sftp-server", "Run the sftp server instance used by this instance."). Hidden(). Action(func(*kingpin.ParseContext) error { - return doSftpServer() + return doSftpServer(cwd) }) - cmd.Flag("workingDir", "Directory to start in. Default: "+workingDir). + cmd.Flag("workingDir", "Directory to start in."). + Default(cwd). PlaceHolder(""). - StringVar(&workingDir) + StringVar(&cwd) }) -func doSftpServer() error { +func doSftpServer(cwd string) error { s := sftp.Server{ - WorkingDir: workingDir, + WorkingDir: cwd, } if err := s.Run(&stdpipe{}); err != nil { diff --git a/cmd/build/build-artifact.go b/cmd/build/build-artifact.go index e9e9be9..1c8a3bc 100644 --- a/cmd/build/build-artifact.go +++ b/cmd/build/build-artifact.go @@ -65,6 +65,7 @@ func (this *buildArtifact) Close() (rErr error) { defer this.lock.Unlock() for _, closer := range this.onClose { + //goland:noinspection GoDeferInLoop defer common.KeepError(&rErr, closer) } @@ -119,6 +120,7 @@ type buildArtifacts []*buildArtifact func (this buildArtifacts) Close() (rErr error) { for _, v := range this { + //goland:noinspection GoDeferInLoop defer common.KeepCloseError(&rErr, v) } return nil diff --git a/cmd/build/build-image.go b/cmd/build/build-image.go index 58c0d9d..ed47edb 100644 --- a/cmd/build/build-image.go +++ b/cmd/build/build-image.go @@ -230,6 +230,7 @@ func (this *buildImage) merge(ctx context.Context, as buildArtifacts) (_ buildAr if err != nil { return nil, err } + //goland:noinspection GoDeferInLoop defer common.IgnoreCloseErrorIfFalse(&success, a) if a != nil { result = append(result, a) diff --git a/cmd/build/dependencies-images-files.go b/cmd/build/dependencies-images-files.go index 72d7c07..07c3157 100644 --- a/cmd/build/dependencies-images-files.go +++ b/cmd/build/dependencies-images-files.go @@ -195,6 +195,7 @@ func (this *dependenciesImagesFiles) getFileFromLayer(layer v1.Layer, sourceFn, if err != nil { return failf("cannot create file %q: %w", targetFn, err) } + //goland:noinspection GoDeferInLoop defer common.KeepCloseError(&rErr, to) if _, err := io.Copy(to, tr); err != nil { diff --git a/contrib/systemd/bifroest-in-docker.service b/contrib/systemd/bifroest-in-docker.service index e14350a..a8db792 100644 --- a/contrib/systemd/bifroest-in-docker.service +++ b/contrib/systemd/bifroest-in-docker.service @@ -9,7 +9,7 @@ After=docker.service Environment=IMAGE=ghcr.io/engity-com/bifroest:latest # Comment this line out if you don't want always the latest version of Bifröst ExecStartPre=/usr/bin/docker pull ${IMAGE} -ExecStartPre=mkdir -p /var/lib/engity/bifroest +ExecStartPre=/usr/bin/mkdir -p /var/lib/engity/bifroest ExecStart=/usr/bin/docker run --rm --name bifroest -p 22:22 -v /var/run/docker.sock:/var/run/docker.sock -v /etc/engity/bifroest:/etc/engity/bifroest -v /var/lib/engity/bifroest:/var/lib/engity/bifroest ${IMAGE} run --log.colorMode=always Restart=always Type=simple diff --git a/docs/reference/authorization/htpasswd.md b/docs/reference/authorization/htpasswd.md index c93cc5e..3a610cb 100644 --- a/docs/reference/authorization/htpasswd.md +++ b/docs/reference/authorization/htpasswd.md @@ -5,7 +5,7 @@ description: How to authorize an user request via credentials stored in htpasswd # Htpasswd authorization -Authorizes an user request via credentials stored in [htpasswd format](#format). +Authorizes a user request via credentials stored in [htpasswd format](#format). ## Properties diff --git a/docs/reference/authorization/oidc.md b/docs/reference/authorization/oidc.md index 110e2bc..bbc937b 100644 --- a/docs/reference/authorization/oidc.md +++ b/docs/reference/authorization/oidc.md @@ -10,7 +10,7 @@ There is no need that the actual user exists in any way on the host machine of B This provides an easy way for SSO in all types of organizations, small or big. See [use cases for more details](../../usecases.md). -Currently the following flow of OpenID Connect is supported: +Currently, the following flow of OpenID Connect is supported: * [Device Auth](#device-auth) diff --git a/docs/reference/data-type.md b/docs/reference/data-type.md index cbefeb3..5683e1d 100644 --- a/docs/reference/data-type.md +++ b/docs/reference/data-type.md @@ -114,6 +114,7 @@ Can be one of: Can be one of: * `ifAbsend` * `always` +* `never` ## Regex Regular expression of [Go flavor](https://pkg.go.dev/regexp). You can play around with it at [regex.com](https://regex101.com/r/fRdVOl/1). diff --git a/docs/reference/environment/docker.md b/docs/reference/environment/docker.md index 2ff9f43..4d60810 100644 --- a/docs/reference/environment/docker.md +++ b/docs/reference/environment/docker.md @@ -123,7 +123,7 @@ Defines which volumes should be mounted into the container. Each entry is an ind This is the equivalent of `--mount` flag of Docker. See [Bind mounts documentation of Docker](https://docs.docker.com/engine/storage/volumes/) about the syntax of these entries. <> -List of Unix kernel capabilities to be added to the container. This enables a more fine grained version in contrast to give all capabilities to the container with [`privileged`](#property-privileged) = `true`. +List of Unix kernel capabilities to be added to the container. This enables a more fine-grained version in contrast to give all capabilities to the container with [`privileged`](#property-privileged) = `true`. Does only work on Unix based systems. diff --git a/docs/reference/environment/local.md b/docs/reference/environment/local.md index 2a76107..b9b2950 100644 --- a/docs/reference/environment/local.md +++ b/docs/reference/environment/local.md @@ -24,7 +24,7 @@ It can run as the Bifröst user itself, but can also [impersonate](https://en.wi Users have to fulfill the defined requirements ([`name`](#linux-property-name), [`displayName`](#linux-property-displayName), [`uid`](#linux-property-uid), [`group`](#linux-property-group), [`groups`](#linux-property-groups), [`shell`](#linux-property-shell), [`homeDir`](#linux-property-homeDir) and [`skel`](#linux-property-skel)). -If a user does not fulfill this requirement they are not eligible for the environment. The environment **can** creates a user ([`createIfAbsent`](#linux-property-createIfAbsent) = `true`) or even updates an existing one ([`updateIfDifferent`](#linux-property-updateIfDifferent) = `true`) to match this requirement. This does not make a lot of sense for [local users](../authorization/local.md); but for [users authorized via OIDC](../authorization/oidc.md) - which usually do not exist locally. +If a user does not fulfill this requirement they are not eligible for the environment. The environment **can** create a user ([`createIfAbsent`](#linux-property-createIfAbsent) = `true`) or even updates an existing one ([`updateIfDifferent`](#linux-property-updateIfDifferent) = `true`) to match this requirement. This does not make a lot of sense for [local users](../authorization/local.md); but for [users authorized via OIDC](../authorization/oidc.md) - which usually do not exist locally. See the evaluation matrix of [`createIfAbsent`](#linux-property-createIfAbsent-evaluation) and [`updateIfDifferent`](#linux-property-updateIfDifferent-evaluation) to see the actual reactions of the local environment per users requirement evaluation state. @@ -147,7 +147,7 @@ This property (together with [`updateIfDifferent`](#linux-property-updateIfDiffe <> If an existing user does not match the provided requirements (see below) and the property is `true`, this user is asked to match the requirements. -This property (together with [`createIfAbsent`](#linux-property-createIfAbsent)) should be `true` if you're using authorizations like [OIDC](../authorization/oidc.md), where the user is not expected to exist locally and you don't want to create each user individually. +This property (together with [`createIfAbsent`](#linux-property-createIfAbsent)) should be `true` if you're using authorizations like [OIDC](../authorization/oidc.md), where the user is not expected to exist locally, and you don't want to create each user individually. ##### Evaluation {: #linux-property-updateIfDifferent-evaluation} | [`updateIfDifferent`](#linux-property-updateIfDifferent) | = `false` | = `true` | diff --git a/docs/usecases.md b/docs/usecases.md index 7ca7f55..59b5a25 100644 --- a/docs/usecases.md +++ b/docs/usecases.md @@ -4,7 +4,7 @@ description: "Bifröst is very flexible in its configuration (see configuration --- # Use cases -Bifröst helps IT admins to administer servers much faster, more secure, with more options, and much more flexible than without using Bifröst. +Bifröst helps IT admins to administer servers much faster, more secure, with more options, and much more flexible than without using Bifröst. A big advantage of Bifröst is the simple and flexible configuration (see [configuration documentation](reference/configuration.md)). Below, you find some use-cases showing that Bifröst makes the difference: 1. [**Off**-board users within the legally binding 15 minutes timeframe of the organization](#offboard) @@ -51,7 +51,7 @@ As the users are always authorized by your [Identity Provider (IdP)](https://ope There is no need to access any of these services directly to remove/de-authorize these users. -If the [environments are configured accordingly](reference/environment/index.md) (default setting) all of the user's files and processes will be removed/killed automatically, too. +If the [environments are configured accordingly](reference/environment/index.md) (default setting) all the user's files and processes will be removed/killed automatically, too. ## On-board users within 15 minutes in the organization {: #onboard} @@ -82,7 +82,7 @@ There is no need to create them somewhere on the server itself. The [OIDC author There is no need to access any of these services directly to create/authorize these users. -If the [environments are configured accordingly](reference/environment/index.md) (default setting), all of the user's resources (like the home directory) will be created automatically. +If the [environments are configured accordingly](reference/environment/index.md) (default setting), all the user's resources (like the home directory) will be created automatically. ## Bastion Host / Jump Host {: #bastion} @@ -127,7 +127,7 @@ The following cases are usually used: ### Problem -1. Assume you have a SSH server. +1. Assume you have an SSH server. 2. Different users should be authorized differently. 3. Different users should run in different [environments](reference/environment/index.md) (one in a local environment with permission A, another with permission B, and a third user in a remote environment). diff --git a/go.mod b/go.mod index 3cccd65..c2837e1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/engity-com/bifroest go 1.23.0 require ( + dario.cat/mergo v1.0.1 github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 github.com/Masterminds/semver/v3 v3.3.0 github.com/Masterminds/sprig/v3 v3.3.0 @@ -20,7 +21,7 @@ require ( github.com/google/go-containerregistry v0.20.2 github.com/google/go-github/v65 v65.0.0 github.com/google/uuid v1.6.0 - github.com/gwatts/rootcerts v0.0.0-20240701182254-d67b2c3ed211 + github.com/gwatts/rootcerts v0.0.0-20241101182300-01a0ed5810ce github.com/mattn/go-zglob v0.0.6 github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a github.com/mr-tron/base58 v1.2.0 @@ -38,58 +39,96 @@ require ( golang.org/x/oauth2 v0.24.0 golang.org/x/sys v0.27.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.31.2 + k8s.io/apimachinery v0.31.2 + k8s.io/client-go v0.31.2 ) require ( - dario.cat/mergo v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/containerd/log v0.1.0 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/pprof v0.0.0-20241101162523-b92577c0c142 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/kr/fs v0.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/onsi/ginkgo/v2 v2.21.0 // indirect + github.com/onsi/gomega v1.35.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect github.com/tklauser/numcpus v0.9.0 // indirect github.com/vbatts/tar-split v0.11.6 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect - go.opentelemetry.io/otel v1.31.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect - go.opentelemetry.io/otel/metric v1.31.0 // indirect - go.opentelemetry.io/otel/sdk v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect - golang.org/x/sync v0.8.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect + go.opentelemetry.io/otel v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect + go.opentelemetry.io/otel/metric v1.32.0 // indirect + go.opentelemetry.io/otel/sdk v1.32.0 // indirect + go.opentelemetry.io/otel/trace v1.32.0 // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/term v0.26.0 // indirect + golang.org/x/text v0.20.0 // indirect + golang.org/x/time v0.8.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.3 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index ff78571..109a271 100644 --- a/go.sum +++ b/go.sum @@ -18,19 +18,22 @@ github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vS github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -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/containerd/stargz-snapshotter/estargz v0.16.1 h1:7YswwU6746cJBN3p3l65JRk3+NZL7bap9Y6E3YeYowk= +github.com/containerd/stargz-snapshotter/estargz v0.16.1/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ= @@ -51,12 +54,16 @@ github.com/echocat/slf4g v1.6.1 h1:nQt0ZivDOsn8dyf3BJaHj1hWTF9MXsoefPUoTbVBI18= github.com/echocat/slf4g v1.6.1/go.mod h1:YvF/d1TcPvT+/xiHStLHPI4xPT1GGeEmPczn2MSljNA= github.com/echocat/slf4g/native v1.6.1 h1:g8Y8abLWwFkSQit4JQUvPO1MqpHUiPFZZINjkvPzY50= github.com/echocat/slf4g/native v1.6.1/go.mod h1:ijM/ey57jUdK7k0uqbhfJJYK6K8DtbEp+HfyDMBO63E= +github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= +github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= @@ -69,9 +76,22 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo= @@ -80,14 +100,27 @@ github.com/google/go-github/v65 v65.0.0 h1:pQ7BmO3DZivvFk92geC0jB0q2m3gyn8vnYPgV github.com/google/go-github/v65 v65.0.0/go.mod h1:DvrqWo5hvsdhJvHd4WyVF9ttANN3BniqjP8uTFMNb60= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241101162523-b92577c0c142 h1:sAGdeJj0bnMgUNVeUpp6AYlVdCt3/GdI3pGRqsNSQLs= +github.com/google/pprof v0.0.0-20241101162523-b92577c0c142/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= -github.com/gwatts/rootcerts v0.0.0-20240701182254-d67b2c3ed211 h1:SWrkheqatqllDLJJ1VFDxwAauwXMpYyL0mhR4jjC5nU= -github.com/gwatts/rootcerts v0.0.0-20240701182254-d67b2c3ed211/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= +github.com/gwatts/rootcerts v0.0.0-20241101182300-01a0ed5810ce h1:twIwcQmJuNtw0zFr9S/SQ8PZPIj3+mdhtDSq1ocKXP8= +github.com/gwatts/rootcerts v0.0.0-20241101182300-01a0ed5810ce/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= @@ -103,6 +136,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A= github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= @@ -115,14 +150,29 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/msteinert/pam/v2 v2.0.0 h1:jnObb8MT6jvMbmrUQO5J/puTUjxy7Av+55zVJRJsCyE= github.com/msteinert/pam/v2 v2.0.0/go.mod h1:KT28NNIcDFf3PcBmNI2mIGO4zZJ+9RSs/At2PB3IDVc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 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 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -137,8 +187,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM= github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -151,10 +202,13 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +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= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -173,6 +227,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xtaci/smux v1.5.31 h1:3ha7sHtH46h85Iv7MfQogxasuRt1KPRhoFB3S4rmHgU= @@ -182,20 +238,20 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -217,8 +273,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -226,8 +282,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -260,31 +316,53 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= -google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= +google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +k8s.io/api v0.31.2 h1:3wLBbL5Uom/8Zy98GRPXpJ254nEFpl+hwndmk9RwmL0= +k8s.io/api v0.31.2/go.mod h1:bWmGvrGPssSK1ljmLzd3pwCQ9MgoTsRCuK35u6SygUk= +k8s.io/apimachinery v0.31.2 h1:i4vUt2hPK56W6mlT7Ry+AO8eEsyxMD1U44NR22CLTYw= +k8s.io/apimachinery v0.31.2/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.31.2 h1:Y2F4dxU5d3AQj+ybwSMqQnpZH9F30//1ObxOKlTI9yc= +k8s.io/client-go v0.31.2/go.mod h1:NPa74jSVR/+eez2dFsEIHNa+3o09vtNaWwWwb1qSxSs= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078 h1:jGnCPejIetjiy2gqaJ5V0NLwTpF4wbQ6cZIItJCSHno= +k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/structured-merge-diff/v4 v4.4.3 h1:sCP7Vv3xx/CWIuTPVN38lUPx0uw0lcLfzaiDa8Ja01A= +sigs.k8s.io/structured-merge-diff/v4 v4.4.3/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/imp/protocol/master.go b/internal/imp/protocol/master.go index 11721db..9390e9f 100644 --- a/internal/imp/protocol/master.go +++ b/internal/imp/protocol/master.go @@ -9,9 +9,9 @@ import ( "sync" "github.com/engity-com/bifroest/pkg/codec" + "github.com/engity-com/bifroest/pkg/common" "github.com/engity-com/bifroest/pkg/crypto" "github.com/engity-com/bifroest/pkg/errors" - "github.com/engity-com/bifroest/pkg/net" "github.com/engity-com/bifroest/pkg/session" ) @@ -28,10 +28,8 @@ func NewMaster(_ context.Context, masterPrivateKey crypto.PrivateKey) (*Master, if err != nil { return fail(err) } - result.tlsDialers.New = func() any { - return &tls.Dialer{ - Config: result.basicTlsConfigFor(cert, masterPrivateKey.ToSdk()), - } + result.tlsConfigs.New = func() any { + return result.basicTlsConfigFor(cert, masterPrivateKey.ToSdk()) } return result, nil @@ -40,13 +38,13 @@ func NewMaster(_ context.Context, masterPrivateKey crypto.PrivateKey) (*Master, type Master struct { PrivateKey crypto.PrivateKey - tlsDialers sync.Pool + tlsConfigs sync.Pool } type Ref interface { SessionId() session.Id PublicKey() crypto.PublicKey - EndpointAddr() net.HostPort + Dial(context.Context) (gonet.Conn, error) } func (this *Master) Open(_ context.Context, ref Ref) (*MasterSession, error) { @@ -57,17 +55,26 @@ func (this *Master) Open(_ context.Context, ref Ref) (*MasterSession, error) { } func (this *Master) DialContext(ctx context.Context, ref Ref) (gonet.Conn, error) { - dialer, releaser, err := this.tlsDialerFor(ref) + tlsConfig, releaser, err := this.tlsConfigFor(ref) if err != nil { return nil, err } defer releaser() - result, err := dialer.DialContext(ctx, "tcp", ref.EndpointAddr().String()) + success := false + rawConn, err := ref.Dial(ctx) if err != nil { return nil, err } - return result, nil + defer common.IgnoreCloseErrorIfFalse(&success, rawConn) + + conn := tls.Client(rawConn, tlsConfig) + if err := conn.HandshakeContext(ctx); err != nil { + return nil, err + } + + success = true + return conn, nil } func (this *Master) DialContextWithMsgPack(ctx context.Context, ref Ref) (codec.MsgPackConn, error) { @@ -82,23 +89,23 @@ func (this *Master) DialContextWithMsgPack(ctx context.Context, ref Ref) (codec. return codec.GetPooledMsgPackConn(conn), nil } -func (this *Master) tlsDialerFor(ref Ref) (_ *tls.Dialer, releaser func(), _ error) { - result := this.tlsDialers.Get().(*tls.Dialer) +func (this *Master) tlsConfigFor(ref Ref) (_ *tls.Config, releaser func(), _ error) { + result := this.tlsConfigs.Get().(*tls.Config) if pub := ref.PublicKey(); pub != nil { verifier, err := peerVerifierForPublicKey(pub) if err != nil { return nil, nil, err } - result.Config.VerifyPeerCertificate = verifier + result.VerifyPeerCertificate = verifier } else if sessionId := ref.SessionId(); !sessionId.IsZero() { - result.Config.VerifyPeerCertificate = peerVerifierForSessionId(sessionId) + result.VerifyPeerCertificate = peerVerifierForSessionId(sessionId) } else { return nil, nil, errors.System.Newf("the imp ref provider neither a publicKey nor a sessionId") } return result, func() { - result.Config.VerifyPeerCertificate = alwaysRejectPeerVerifier - this.tlsDialers.Put(result) + result.VerifyPeerCertificate = alwaysRejectPeerVerifier + this.tlsConfigs.Put(result) }, nil } diff --git a/pkg/alternatives/binaries.go b/pkg/alternatives/provider.go similarity index 71% rename from pkg/alternatives/binaries.go rename to pkg/alternatives/provider.go index b2bca94..a9cb6a4 100644 --- a/pkg/alternatives/binaries.go +++ b/pkg/alternatives/provider.go @@ -8,29 +8,45 @@ import ( goos "os" "path/filepath" + "github.com/docker/docker/errdefs" log "github.com/echocat/slf4g" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/daemon" "github.com/engity-com/bifroest/pkg/common" "github.com/engity-com/bifroest/pkg/configuration" "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/oci/images" "github.com/engity-com/bifroest/pkg/sys" ) type Provider interface { io.Closer FindBinaryFor(ctx context.Context, os sys.Os, arch sys.Arch) (string, error) + FindOciImageFor(ctx context.Context, os sys.Os, arch sys.Arch, opts FindOciImageOpts) (string, error) +} + +type FindOciImageOpts struct { + Local bool + Force bool } func NewProvider(_ context.Context, version sys.Version, conf *configuration.Alternatives) (Provider, error) { + ib, err := images.NewBuilder() + if err != nil { + return nil, err + } return &provider{ - conf: conf, - version: version, + conf: conf, + version: version, + imagesBuilder: ib, }, nil } type provider struct { - conf *configuration.Alternatives - version sys.Version + conf *configuration.Alternatives + version sys.Version + imagesBuilder *images.Builder Logger log.Logger } @@ -129,6 +145,69 @@ func (this *provider) FindBinaryFor(ctx context.Context, hostOs sys.Os, hostArch return fn, nil } +func (this *provider) FindOciImageFor(ctx context.Context, os sys.Os, arch sys.Arch, opts FindOciImageOpts) (_ string, rErr error) { + fail := func(err error) (string, error) { + return "", errors.System.Newf("cannot find oci image for %v/%v: %w", os, arch, err) + } + failf := func(msg string, args ...any) (string, error) { + return fail(errors.System.Newf(msg, args...)) + } + version := this.version.Version() + + if !opts.Local { + return "ghcr.io/engity-com/bifroest:generic-" + version, nil + } + + tag, err := name.NewTag("local/bifroest:generic-" + version) + if err != nil { + return fail(err) + } + + if !opts.Force { + if _, err := daemon.Image(tag, daemon.WithContext(ctx)); errdefs.IsNotFound(err) { + // This is Ok, we'll continue to build it... + } else if err != nil { + return fail(err) + } else { + return tag.String(), nil + } + } + + sourceBinaryLocation, err := this.FindBinaryFor(ctx, os, arch) + if err != nil { + return fail(err) + } + + targetBinaryLocation := sys.BifroestBinaryLocation(os) + if len(targetBinaryLocation) == 0 { + return failf("cannot resolve Bifröst's binary location for os %v", os) + } + + img, err := this.imagesBuilder.Build(ctx, images.BuildRequest{ + From: images.ImageMinimal, + Os: os, + Arch: arch, + BifroestBinarySource: sourceBinaryLocation, + EntryPoint: []string{targetBinaryLocation}, + Cmd: []string{}, + ExposedPorts: map[string]struct{}{ + "22/tcp": {}, + }, + AddDummyConfiguration: true, + AddSkeletonStructure: true, + }) + if err != nil { + return fail(err) + } + defer common.KeepCloseError(&rErr, img) + + if _, err := daemon.Write(tag, img, daemon.WithContext(ctx)); err != nil { + return fail(err) + } + + return tag.String(), nil +} + func (this *provider) alternativesLocationFor(os sys.Os, arch sys.Arch) (string, error) { fail := func(err error) (string, error) { return "", err diff --git a/pkg/authorization/facade-authorizer.go b/pkg/authorization/facade-authorizer.go index b3380b4..2b441c4 100644 --- a/pkg/authorization/facade-authorizer.go +++ b/pkg/authorization/facade-authorizer.go @@ -92,6 +92,7 @@ func (this *AuthorizerFacade) RestoreFromSession(ctx context.Context, sess sessi func (this *AuthorizerFacade) Close() (rErr error) { defer func() { this.entries = nil }() for _, candidate := range this.entries { + //goland:noinspection GoDeferInLoop defer common.KeepCloseError(&rErr, candidate) } return nil diff --git a/pkg/configuration/authorization-simple-entry.go b/pkg/configuration/authorization-simple-entry.go index 815b312..250c543 100644 --- a/pkg/configuration/authorization-simple-entry.go +++ b/pkg/configuration/authorization-simple-entry.go @@ -59,7 +59,7 @@ func (this *AuthorizationSimpleEntry) Validate() error { return err } if f := this.PasswordFile; !f.IsZero() { - if pw, err := this.PasswordFile.GetPassword(); pw.IsZero() { + if pw, err := this.PasswordFile.GetPassword(); pw == nil || pw.IsZero() { decoded, encoded, err := t.Generate(nil) if err != nil { return errors.System.Newf("cannot generate new password for file %v: %w", f, err) diff --git a/pkg/configuration/context-mode.go b/pkg/configuration/context-mode.go new file mode 100644 index 0000000..e6f5146 --- /dev/null +++ b/pkg/configuration/context-mode.go @@ -0,0 +1,90 @@ +package configuration + +import ( + "fmt" + + "github.com/engity-com/bifroest/pkg/errors" +) + +type ContextMode uint8 + +const ( + ContextModeOnline ContextMode = iota + ContextModeOffline + ContextModeDebug +) + +var ( + contextModeToName = map[ContextMode]string{ + ContextModeOnline: "online", + ContextModeOffline: "offline", + ContextModeDebug: "debug", + } + nameToContextMode = func(in map[ContextMode]string) map[string]ContextMode { + result := make(map[string]ContextMode, len(in)) + for k, v := range in { + result[v] = k + } + return result + }(contextModeToName) +) + +func (this ContextMode) IsZero() bool { + return false +} + +func (this ContextMode) MarshalText() (text []byte, err error) { + v, ok := contextModeToName[this] + if !ok { + return nil, errors.Config.Newf("illegal context-mode: %d", this) + } + return []byte(v), nil +} + +func (this ContextMode) String() string { + v, ok := contextModeToName[this] + if !ok { + return fmt.Sprintf("illegal-context-mode-%d", this) + } + return v +} + +func (this *ContextMode) UnmarshalText(text []byte) error { + v, ok := nameToContextMode[string(text)] + if !ok { + return errors.Config.Newf("illegal context-mode: %s", string(text)) + } + *this = v + return nil +} + +func (this *ContextMode) Set(text string) error { + return this.UnmarshalText([]byte(text)) +} + +func (this ContextMode) Validate() error { + _, err := this.MarshalText() + return err +} + +func (this ContextMode) IsEqualTo(other any) bool { + if other == nil { + return false + } + switch v := other.(type) { + case ContextMode: + return this.isEqualTo(&v) + case *ContextMode: + return this.isEqualTo(v) + default: + return false + } +} + +func (this ContextMode) isEqualTo(other *ContextMode) bool { + return this == *other +} + +func (this ContextMode) Clone() ContextMode { + return this +} diff --git a/pkg/configuration/environment-kubernetes.go b/pkg/configuration/environment-kubernetes.go new file mode 100644 index 0000000..01ce133 --- /dev/null +++ b/pkg/configuration/environment-kubernetes.go @@ -0,0 +1,245 @@ +package configuration + +import ( + "time" + + "gopkg.in/yaml.v3" + + "github.com/engity-com/bifroest/pkg/kubernetes" + "github.com/engity-com/bifroest/pkg/sys" + "github.com/engity-com/bifroest/pkg/template" +) + +var ( + DefaultEnvironmentKubernetesLoginAllowed = template.BoolOf(true) + + DefaultEnvironmentKubernetesConfig = kubernetes.MustNewKubeconfig("") + DefaultEnvironmentKubernetesContext = "" + + DefaultEnvironmentKubernetesName = template.MustNewString("bifroest-{{.session.id}}") + DefaultEnvironmentKubernetesNamespace = template.MustNewString("") + DefaultEnvironmentKubernetesOs = sys.OsLinux + DefaultEnvironmentKubernetesServiceAccount = template.MustNewString("") + DefaultEnvironmentKubernetesImage = template.MustNewString("alpine") + DefaultEnvironmentKubernetesImagePullPolicy = PullPolicyIfAbsent + DefaultEnvironmentKubernetesImagePullCredentials = template.MustNewString("") + DefaultEnvironmentKubernetesImageContextMode = ContextModeOnline + DefaultEnvironmentKubernetesReadyTimeout = template.DurationOf(5 * time.Minute) + + DefaultEnvironmentKubernetesCapabilities = template.MustNewStrings() + DefaultEnvironmentKubernetesPrivileged = template.BoolOf(false) + DefaultEnvironmentKubernetesDnsServers = template.MustNewStrings() + DefaultEnvironmentKubernetesDnsSearch = template.MustNewStrings() + DefaultEnvironmentKubernetesShellCommand = template.MustNewStrings() + DefaultEnvironmentKubernetesExecCommand = template.MustNewStrings() + DefaultEnvironmentKubernetesSftpCommand = template.MustNewStrings() + DefaultEnvironmentKubernetesDirectory = template.MustNewString("") + DefaultEnvironmentKubernetesUser = template.MustNewString("") + + DefaultEnvironmentKubernetesBanner = template.MustNewString("") + DefaultEnvironmentKubernetesPortForwardingAllowed = template.BoolOf(true) + + DefaultEnvironmentKubernetesCleanOrphan = template.BoolOf(true) + + _ = RegisterEnvironmentV(func() EnvironmentV { + return &EnvironmentKubernetes{} + }) +) + +type EnvironmentKubernetes struct { + LoginAllowed template.Bool `yaml:"loginAllowed,omitempty"` + + Config kubernetes.Kubeconfig `yaml:"config,omitempty"` + Context string `yaml:"context,omitempty"` + + Name template.String `yaml:"name"` + Namespace template.String `yaml:"namespace,omitempty"` + Os sys.Os `yaml:"os"` + ServiceAccount template.String `yaml:"serviceAccount,omitempty"` + Image template.String `yaml:"image"` + ImagePullPolicy PullPolicy `yaml:"imagePullPolicy,omitempty"` + ImagePullCredentials template.String `yaml:"imagePullCredentials,omitempty"` + ImageContextMode ContextMode `yaml:"imageContextMode,omitempty"` + ReadyTimeout template.Duration `yaml:"readyTimeout,omitempty"` + Capabilities template.Strings `yaml:"capabilities,omitempty"` + Privileged template.Bool `yaml:"privileged,omitempty"` + DnsServers template.Strings `yaml:"dnsServers,omitempty"` + DnsSearch template.Strings `yaml:"dnsSearch,omitempty"` + + ShellCommand template.Strings `yaml:"shellCommand,omitempty"` + ExecCommand template.Strings `yaml:"execCommand,omitempty"` + SftpCommand template.Strings `yaml:"sftpCommand,omitempty"` + Directory template.String `yaml:"directory"` + User template.String `yaml:"user,omitempty"` + + Banner template.String `yaml:"banner,omitempty"` + + PortForwardingAllowed template.Bool `yaml:"portForwardingAllowed,omitempty"` + + CleanOrphan template.Bool `yaml:"cleanOrphan,omitempty"` +} + +func (this *EnvironmentKubernetes) SetDefaults() error { + return setDefaults(this, + fixedDefault("loginAllowed", func(v *EnvironmentKubernetes) *template.Bool { return &v.LoginAllowed }, DefaultEnvironmentKubernetesLoginAllowed), + + fixedDefault("config", func(v *EnvironmentKubernetes) *kubernetes.Kubeconfig { return &v.Config }, DefaultEnvironmentKubernetesConfig), + fixedDefault("context", func(v *EnvironmentKubernetes) *string { return &v.Context }, DefaultEnvironmentKubernetesContext), + + fixedDefault("name", func(v *EnvironmentKubernetes) *template.String { return &v.Name }, DefaultEnvironmentKubernetesName), + fixedDefault("namespace", func(v *EnvironmentKubernetes) *template.String { return &v.Namespace }, DefaultEnvironmentKubernetesNamespace), + fixedDefault("os", func(v *EnvironmentKubernetes) *sys.Os { return &v.Os }, DefaultEnvironmentKubernetesOs), + fixedDefault("serviceAccount", func(v *EnvironmentKubernetes) *template.String { return &v.ServiceAccount }, DefaultEnvironmentKubernetesServiceAccount), + fixedDefault("image", func(v *EnvironmentKubernetes) *template.String { return &v.Image }, DefaultEnvironmentKubernetesImage), + fixedDefault("imagePullPolicy", func(v *EnvironmentKubernetes) *PullPolicy { return &v.ImagePullPolicy }, DefaultEnvironmentKubernetesImagePullPolicy), + fixedDefault("imagePullCredentials", func(v *EnvironmentKubernetes) *template.String { return &v.ImagePullCredentials }, DefaultEnvironmentKubernetesImagePullCredentials), + fixedDefault("imageContextMode", func(v *EnvironmentKubernetes) *ContextMode { return &v.ImageContextMode }, DefaultEnvironmentKubernetesImageContextMode), + fixedDefault("readyTimeout", func(v *EnvironmentKubernetes) *template.Duration { return &v.ReadyTimeout }, DefaultEnvironmentKubernetesReadyTimeout), + fixedDefault("capabilities", func(v *EnvironmentKubernetes) *template.Strings { return &v.Capabilities }, DefaultEnvironmentKubernetesCapabilities), + fixedDefault("privileged", func(v *EnvironmentKubernetes) *template.Bool { return &v.Privileged }, DefaultEnvironmentKubernetesPrivileged), + fixedDefault("dnsServers", func(v *EnvironmentKubernetes) *template.Strings { return &v.DnsServers }, DefaultEnvironmentKubernetesDnsServers), + fixedDefault("dnsSearch", func(v *EnvironmentKubernetes) *template.Strings { return &v.DnsSearch }, DefaultEnvironmentKubernetesDnsSearch), + + fixedDefault("shellCommand", func(v *EnvironmentKubernetes) *template.Strings { return &v.ShellCommand }, DefaultEnvironmentKubernetesShellCommand), + fixedDefault("execCommand", func(v *EnvironmentKubernetes) *template.Strings { return &v.ExecCommand }, DefaultEnvironmentKubernetesExecCommand), + fixedDefault("sftpCommand", func(v *EnvironmentKubernetes) *template.Strings { return &v.SftpCommand }, DefaultEnvironmentKubernetesSftpCommand), + fixedDefault("directory", func(v *EnvironmentKubernetes) *template.String { return &v.Directory }, DefaultEnvironmentKubernetesDirectory), + fixedDefault("user", func(v *EnvironmentKubernetes) *template.String { return &v.User }, DefaultEnvironmentKubernetesUser), + + fixedDefault("banner", func(v *EnvironmentKubernetes) *template.String { return &v.Banner }, DefaultEnvironmentKubernetesBanner), + + fixedDefault("portForwardingAllowed", func(v *EnvironmentKubernetes) *template.Bool { return &v.PortForwardingAllowed }, DefaultEnvironmentKubernetesPortForwardingAllowed), + + fixedDefault("cleanOrphan", func(v *EnvironmentKubernetes) *template.Bool { return &v.CleanOrphan }, DefaultEnvironmentKubernetesCleanOrphan), + ) +} + +func (this *EnvironmentKubernetes) Trim() error { + return trim(this, + noopTrim[EnvironmentKubernetes]("loginAllowed"), + + noopTrim[EnvironmentKubernetes]("config"), + noopTrim[EnvironmentKubernetes]("context"), + + noopTrim[EnvironmentKubernetes]("name"), + noopTrim[EnvironmentKubernetes]("namespace"), + noopTrim[EnvironmentKubernetes]("os"), + noopTrim[EnvironmentKubernetes]("serviceAccount"), + noopTrim[EnvironmentKubernetes]("image"), + noopTrim[EnvironmentKubernetes]("imagePullPolicy"), + noopTrim[EnvironmentKubernetes]("imagePullCredentials"), + noopTrim[EnvironmentKubernetes]("imageContextMode"), + noopTrim[EnvironmentKubernetes]("readyTimeout"), + noopTrim[EnvironmentKubernetes]("capabilities"), + noopTrim[EnvironmentKubernetes]("privileged"), + noopTrim[EnvironmentKubernetes]("dnsServers"), + noopTrim[EnvironmentKubernetes]("dnsSearch"), + noopTrim[EnvironmentKubernetes]("shellCommand"), + noopTrim[EnvironmentKubernetes]("execCommand"), + noopTrim[EnvironmentKubernetes]("sftpCommand"), + noopTrim[EnvironmentKubernetes]("directory"), + noopTrim[EnvironmentKubernetes]("user"), + + noopTrim[EnvironmentKubernetes]("banner"), + + noopTrim[EnvironmentKubernetes]("portForwardingAllowed"), + + noopTrim[EnvironmentKubernetes]("cleanOrphan"), + ) +} + +func (this *EnvironmentKubernetes) Validate() error { + return validate(this, + func(v *EnvironmentKubernetes) (string, validator) { return "loginAllowed", &v.LoginAllowed }, + + func(v *EnvironmentKubernetes) (string, validator) { return "config", &v.Config }, + noopValidate[EnvironmentKubernetes]("context"), + + func(v *EnvironmentKubernetes) (string, validator) { return "name", &v.Name }, + notZeroValidate("name", func(v *EnvironmentKubernetes) *template.String { return &v.Name }), + func(v *EnvironmentKubernetes) (string, validator) { return "namespace", &v.Namespace }, + func(v *EnvironmentKubernetes) (string, validator) { return "os", &v.Os }, + func(v *EnvironmentKubernetes) (string, validator) { return "serviceAccount", &v.ServiceAccount }, + func(v *EnvironmentKubernetes) (string, validator) { return "image", &v.Image }, + notZeroValidate("image", func(v *EnvironmentKubernetes) *template.String { return &v.Image }), + func(v *EnvironmentKubernetes) (string, validator) { return "imagePullPolicy", &v.ImagePullPolicy }, + func(v *EnvironmentKubernetes) (string, validator) { + return "imagePullCredentials", &v.ImagePullCredentials + }, + func(v *EnvironmentKubernetes) (string, validator) { return "imageContextMode", &v.ImageContextMode }, + func(v *EnvironmentKubernetes) (string, validator) { return "readyTimeout", &v.ReadyTimeout }, + func(v *EnvironmentKubernetes) (string, validator) { return "capabilities", &v.Capabilities }, + func(v *EnvironmentKubernetes) (string, validator) { return "privileged", &v.Privileged }, + func(v *EnvironmentKubernetes) (string, validator) { return "dnsServers", &v.DnsServers }, + func(v *EnvironmentKubernetes) (string, validator) { return "dnsSearch", &v.DnsSearch }, + func(v *EnvironmentKubernetes) (string, validator) { return "shellCommand", &v.ShellCommand }, + func(v *EnvironmentKubernetes) (string, validator) { return "execCommand", &v.ExecCommand }, + func(v *EnvironmentKubernetes) (string, validator) { return "sftpCommand", &v.SftpCommand }, + func(v *EnvironmentKubernetes) (string, validator) { return "directory", &v.Directory }, + func(v *EnvironmentKubernetes) (string, validator) { return "user", &v.User }, + + func(v *EnvironmentKubernetes) (string, validator) { return "banner", &v.Banner }, + + func(v *EnvironmentKubernetes) (string, validator) { + return "portForwardingAllowed", &v.PortForwardingAllowed + }, + + func(v *EnvironmentKubernetes) (string, validator) { return "cleanOrphan", &v.CleanOrphan }, + ) +} + +func (this *EnvironmentKubernetes) UnmarshalYAML(node *yaml.Node) error { + return unmarshalYAML(this, node, func(target *EnvironmentKubernetes, node *yaml.Node) error { + type raw EnvironmentKubernetes + return node.Decode((*raw)(target)) + }) +} + +func (this EnvironmentKubernetes) IsEqualTo(other any) bool { + if other == nil { + return false + } + switch v := other.(type) { + case EnvironmentKubernetes: + return this.isEqualTo(&v) + case *EnvironmentKubernetes: + return this.isEqualTo(v) + default: + return false + } +} + +func (this EnvironmentKubernetes) isEqualTo(other *EnvironmentKubernetes) bool { + return isEqual(&this.LoginAllowed, &other.LoginAllowed) && + isEqual(&this.Config, &other.Config) && + this.Context == other.Context && + isEqual(&this.Name, &other.Name) && + isEqual(&this.Namespace, &other.Namespace) && + isEqual(&this.Os, &other.Os) && + isEqual(&this.ServiceAccount, &other.ServiceAccount) && + isEqual(&this.Image, &other.Image) && + isEqual(&this.ImagePullPolicy, &other.ImagePullPolicy) && + isEqual(&this.ImagePullCredentials, &other.ImagePullCredentials) && + isEqual(&this.ImageContextMode, &other.ImageContextMode) && + isEqual(&this.ReadyTimeout, &other.ReadyTimeout) && + isEqual(&this.Capabilities, &other.Capabilities) && + isEqual(&this.Privileged, &other.Privileged) && + isEqual(&this.DnsServers, &other.DnsServers) && + isEqual(&this.DnsSearch, &other.DnsSearch) && + isEqual(&this.ShellCommand, &other.ShellCommand) && + isEqual(&this.ExecCommand, &other.ExecCommand) && + isEqual(&this.SftpCommand, &other.SftpCommand) && + isEqual(&this.Directory, &other.Directory) && + isEqual(&this.User, &other.User) && + isEqual(&this.Banner, &other.Banner) && + isEqual(&this.PortForwardingAllowed, &other.PortForwardingAllowed) && + isEqual(&this.CleanOrphan, &other.CleanOrphan) +} + +func (this EnvironmentKubernetes) Types() []string { + return []string{"kubernetes"} +} + +func (this EnvironmentKubernetes) FeatureFlags() []string { + return []string{"kubernetes"} +} diff --git a/pkg/configuration/flow-key.go b/pkg/configuration/flow-key.go index 5adddb6..18acc05 100644 --- a/pkg/configuration/flow-key.go +++ b/pkg/configuration/flow-key.go @@ -37,9 +37,9 @@ func (this FlowName) Validate() error { return fmt.Errorf("illegal flow key: empty") } for _, c := range string(this) { - if (c >= 'a' && 'z' <= c) || - (c >= 'A' && 'Z' <= c) || - (c >= '0' && '9' <= c) || + if (c >= 'a' && 'z' >= c) || + (c >= 'A' && 'Z' >= c) || + (c >= '0' && '9' >= c) || c == '-' || c == '.' { // Ok } else { diff --git a/pkg/configuration/pull-policy.go b/pkg/configuration/pull-policy.go index 2e07a9d..c784e38 100644 --- a/pkg/configuration/pull-policy.go +++ b/pkg/configuration/pull-policy.go @@ -11,12 +11,14 @@ type PullPolicy uint8 const ( PullPolicyIfAbsent PullPolicy = iota PullPolicyAlways + PullPolicyNever ) var ( pullPolicyToName = map[PullPolicy]string{ PullPolicyIfAbsent: "ifAbsent", PullPolicyAlways: "always", + PullPolicyNever: "never", } nameToPullPolicy = func(in map[PullPolicy]string) map[string]PullPolicy { result := make(map[string]PullPolicy, len(in)) diff --git a/pkg/configuration/configuration-ref.go b/pkg/configuration/ref.go similarity index 54% rename from pkg/configuration/configuration-ref.go rename to pkg/configuration/ref.go index 0b18036..a7ce5c4 100644 --- a/pkg/configuration/configuration-ref.go +++ b/pkg/configuration/ref.go @@ -6,25 +6,25 @@ import ( "github.com/engity-com/bifroest/pkg/errors" ) -type ConfigurationRef struct { +type Ref struct { v Configuration fn string } -func (this ConfigurationRef) IsZero() bool { +func (this Ref) IsZero() bool { return len(this.fn) == 0 } -func (this ConfigurationRef) MarshalText() (text []byte, err error) { +func (this Ref) MarshalText() (text []byte, err error) { return []byte(this.String()), nil } -func (this ConfigurationRef) String() string { +func (this Ref) String() string { return this.fn } -func (this *ConfigurationRef) UnmarshalText(text []byte) error { - buf := ConfigurationRef{ +func (this *Ref) UnmarshalText(text []byte) error { + buf := Ref{ fn: string(text), } @@ -38,19 +38,19 @@ func (this *ConfigurationRef) UnmarshalText(text []byte) error { return nil } -func (this *ConfigurationRef) Set(text string) error { +func (this *Ref) Set(text string) error { return this.UnmarshalText([]byte(text)) } -func (this *ConfigurationRef) Get() *Configuration { +func (this *Ref) Get() *Configuration { return &this.v } -func (this *ConfigurationRef) GetFilename() string { +func (this *Ref) GetFilename() string { return this.fn } -func (this *ConfigurationRef) MakeAbsolute() error { +func (this *Ref) MakeAbsolute() error { abs, err := filepath.Abs(this.fn) if err != nil { return errors.Config.Newf("canont make this configuration file reference absolute: %w", err) @@ -58,20 +58,20 @@ func (this *ConfigurationRef) MakeAbsolute() error { return this.Set(abs) } -func (this ConfigurationRef) IsEqualTo(other any) bool { +func (this Ref) IsEqualTo(other any) bool { if other == nil { return false } switch v := other.(type) { - case ConfigurationRef: + case Ref: return this.isEqualTo(&v) - case *ConfigurationRef: + case *Ref: return this.isEqualTo(v) default: return false } } -func (this ConfigurationRef) isEqualTo(other *ConfigurationRef) bool { +func (this Ref) isEqualTo(other *Ref) bool { return this.fn == other.fn } diff --git a/pkg/configuration/ssh.go b/pkg/configuration/ssh.go index 02b01cd..67be114 100644 --- a/pkg/configuration/ssh.go +++ b/pkg/configuration/ssh.go @@ -12,7 +12,7 @@ import ( var ( // DefaultSshAddresses is the default setting for Ssh.Addresses. - DefaultSshAddresses = []net.NetAddress{net.MustNewNetAddress(":22")} + DefaultSshAddresses = []net.Address{net.MustNewAddress(":22")} // DefaultSshIdleTimeout is the default setting for Ssh.IdleTimeout. DefaultSshIdleTimeout = common.DurationOf(10 * time.Minute) diff --git a/pkg/crypto/authorized-keys_test.go b/pkg/crypto/authorized-keys_test.go index 5c78a16..64249dc 100644 --- a/pkg/crypto/authorized-keys_test.go +++ b/pkg/crypto/authorized-keys_test.go @@ -15,6 +15,7 @@ import ( ) //nolint:golint,unused +//goland:noinspection ALL var ( dsa1Pub, dsa1Fn = mustSshPublicKey("dsa-1") ecdsa1Pub, ecdsa1Fn = mustSshPublicKey("ecdsa-1") diff --git a/pkg/environment/docker-repository.go b/pkg/environment/docker-repository.go index 8931db7..6139ae3 100644 --- a/pkg/environment/docker-repository.go +++ b/pkg/environment/docker-repository.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + gonet "net" "path/filepath" "strconv" "strings" @@ -40,9 +41,6 @@ var ( ) const ( - BifroestUnixBinaryMountTarget = `/usr/bin/bifroest` - BifroestWindowsBinaryMountTarget = `C:\Program Files\Engity\Bifroest\bifroest.exe` - DockerLabelPrefix = "org.engity.bifroest/" DockerLabelFlow = DockerLabelPrefix + "flow" DockerLabelSessionId = DockerLabelPrefix + "session-id" @@ -73,6 +71,8 @@ type DockerRepository struct { sessionIdMutex common.KeyedMutex[session.Id] activeInstances sync.Map + + rawDialer gonet.Dialer } func NewDockerRepository(ctx context.Context, flow configuration.FlowName, conf *configuration.EnvironmentDocker, ap alternatives.Provider, i imp.Imp) (*DockerRepository, error) { @@ -192,7 +192,7 @@ func (this *DockerRepository) createContainerBy(req Request, sess session.Sessio } success := false cr, err := this.apiClient.ContainerCreate(req.Context(), config, hostConfig, networkingConfig, nil, "") - if this.isNoSuchImageError(err) && this.conf.ImagePullPolicy != configuration.PullPolicyAlways { + if this.isNoSuchImageError(err) && this.conf.ImagePullPolicy != configuration.PullPolicyAlways && this.conf.ImagePullPolicy != configuration.PullPolicyNever { if err := this.pullImage(req, config.Image); err != nil { return failf(errors.System, "cannot pull container image %s: %w", config.Image, err) } @@ -351,15 +351,13 @@ func (this *DockerRepository) resolveContainerConfig(req Request, sess session.S return failf("cannot evaluate image: %w", err) } result.Entrypoint = strslice.StrSlice{} - switch this.hostOs { - case sys.OsWindows: - result.Cmd = []string{BifroestWindowsBinaryMountTarget} - case sys.OsLinux: - result.User = "root" - result.Cmd = []string{BifroestUnixBinaryMountTarget} - default: + result.Cmd = []string{sys.BifroestBinaryLocation(this.hostOs)} + if len(result.Cmd[0]) == 0 { return failf("cannot resolve target path for host %s/%s", this.hostOs, this.hostArch) } + if this.hostOs == sys.OsLinux { + result.User = "root" + } result.Cmd = append(result.Cmd, `imp`, `--log.colorMode=always`) if this.defaultLogLevelName != "" { @@ -434,13 +432,8 @@ func (this *DockerRepository) resolveHostConfig(req Request) (_ *container.HostC if err != nil { return failf("cannot resolve full imp binary path: %w", err) } - var targetPath string - switch this.hostOs { - case sys.OsWindows: - targetPath = BifroestWindowsBinaryMountTarget - case sys.OsLinux: - targetPath = BifroestUnixBinaryMountTarget - default: + targetPath := sys.BifroestBinaryLocation(this.hostOs) + if targetPath == "" { return failf("cannot resolve target path for host %s/%s", this.hostOs, this.hostArch) } result.Mounts = append(result.Mounts, mount.Mount{ @@ -514,12 +507,8 @@ func (this *DockerRepository) resolveEncodedSftpCommand(req Request) (string, er return failf("cannot evaluate sftpCommand: %w", err) } if len(v) == 0 { - switch this.hostOs { - case sys.OsWindows: - v = []string{BifroestWindowsBinaryMountTarget, `sftp-server`} - case sys.OsLinux: - v = []string{BifroestUnixBinaryMountTarget, `sftp-server`} - default: + v = []string{sys.BifroestBinaryLocation(this.hostOs), `sftp-server`} + if len(v[0]) == 0 { return failf("sftpCommand was not defined for docker environment and default cannot be resolved for %s/%s", this.hostOs, this.hostArch) } } diff --git a/pkg/environment/docker.go b/pkg/environment/docker.go index 64526b1..14db7eb 100644 --- a/pkg/environment/docker.go +++ b/pkg/environment/docker.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + gonet "net" "sync/atomic" "syscall" "time" @@ -53,8 +54,8 @@ func (this *docker) PublicKey() crypto.PublicKey { return nil } -func (this *docker) EndpointAddr() net.HostPort { - return this.impBinding +func (this *docker) Dial(ctx context.Context) (gonet.Conn, error) { + return this.repository.rawDialer.DialContext(ctx, "tcp", this.impBinding.String()) } func (this *DockerRepository) new(ctx context.Context, container *types.Container, logger log.Logger) (*docker, error) { diff --git a/pkg/environment/facade-repository.go b/pkg/environment/facade-repository.go index 667afe8..2d8e69f 100644 --- a/pkg/environment/facade-repository.go +++ b/pkg/environment/facade-repository.go @@ -74,6 +74,7 @@ func (this *RepositoryFacade) FindBySession(ctx context.Context, sess session.Se func (this *RepositoryFacade) Close() (rErr error) { for _, entity := range this.entries { + //goland:noinspection GoDeferInLoop defer common.KeepCloseError(&rErr, entity) } return nil diff --git a/pkg/environment/kubernetes-repository.go b/pkg/environment/kubernetes-repository.go new file mode 100644 index 0000000..96a7e41 --- /dev/null +++ b/pkg/environment/kubernetes-repository.go @@ -0,0 +1,903 @@ +package environment + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + "unsafe" + + "github.com/docker/docker/api/types/registry" + "github.com/docker/docker/api/types/strslice" + "github.com/docker/docker/errdefs" + "github.com/echocat/slf4g" + "github.com/echocat/slf4g/level" + glssh "github.com/gliderlabs/ssh" + v1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + v2 "k8s.io/client-go/kubernetes/typed/core/v1" + + "github.com/engity-com/bifroest/pkg/alternatives" + "github.com/engity-com/bifroest/pkg/common" + "github.com/engity-com/bifroest/pkg/configuration" + "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/imp" + bkp "github.com/engity-com/bifroest/pkg/kubernetes" + "github.com/engity-com/bifroest/pkg/session" + "github.com/engity-com/bifroest/pkg/sys" +) + +var ( + _ = RegisterRepository(NewKubernetesRepository) +) + +const ( + KubernetesLabelPrefix = "org.engity.bifroest/" + KubernetesLabelFlow = KubernetesLabelPrefix + "flow" + KubernetesLabelSessionId = KubernetesLabelPrefix + "session-id" + + KubernetesAnnotationPrefix = KubernetesLabelPrefix + KubernetesAnnotationCreatedRemoteUser = KubernetesAnnotationPrefix + "created-remote-user" + KubernetesAnnotationCreatedRemoteHost = KubernetesAnnotationPrefix + "created-remote-host" + KubernetesAnnotationShellCommand = KubernetesAnnotationPrefix + "shellCommand" + KubernetesAnnotationExecCommand = KubernetesAnnotationPrefix + "execCommand" + KubernetesAnnotationSftpCommand = KubernetesAnnotationPrefix + "sftpCommand" + KubernetesAnnotationUser = KubernetesAnnotationPrefix + "user" + KubernetesAnnotationDirectory = KubernetesAnnotationPrefix + "directory" + KubernetesAnnotationPortForwardingAllowed = KubernetesAnnotationPrefix + "portForwardingAllowed" +) + +type KubernetesRepository struct { + flow configuration.FlowName + conf *configuration.EnvironmentKubernetes + alternatives alternatives.Provider + imp imp.Imp + + client bkp.Client + + Logger log.Logger + defaultLogLevelName string + + sessionIdMutex common.KeyedMutex[session.Id] + activeInstances sync.Map +} + +func NewKubernetesRepository(_ context.Context, flow configuration.FlowName, conf *configuration.EnvironmentKubernetes, ap alternatives.Provider, i imp.Imp) (*KubernetesRepository, error) { + fail := func(err error) (*KubernetesRepository, error) { + return nil, err + } + failf := func(msg string, args ...any) (*KubernetesRepository, error) { + return fail(fmt.Errorf(msg, args...)) + } + + if conf == nil { + return failf("nil configuration") + } + + client, err := conf.Config.GetClient(conf.Context) + if err != nil { + return fail(err) + } + + result := KubernetesRepository{ + flow: flow, + conf: conf, + alternatives: ap, + imp: i, + client: client, + } + + lp := result.logger().GetProvider() + if la, ok := lp.(level.Aware); ok { + if lna, ok := lp.(level.NamesAware); ok { + lvl := la.GetLevel() + if result.defaultLogLevelName, err = lna.GetLevelNames().ToName(lvl); err != nil { + return failf("cannot transform to name of level %v: %w", lvl, err) + } + } + } + + return &result, nil +} + +func (this *KubernetesRepository) WillBeAccepted(ctx Context) (ok bool, err error) { + fail := func(err error) (bool, error) { + return false, err + } + + if ok, err = this.conf.LoginAllowed.Render(ctx); err != nil { + return fail(fmt.Errorf("cannot evaluate if user is allowed to login or not: %w", err)) + } + + return ok, nil +} + +func (this *KubernetesRepository) DoesSupportPty(Context, glssh.Pty) (bool, error) { + return true, nil +} + +func (this *KubernetesRepository) Ensure(req Request) (Environment, error) { + fail := func(err error) (Environment, error) { + return nil, err + } + failf := func(t errors.Type, msg string, args ...any) (Environment, error) { + return fail(errors.Newf(t, msg, args...)) + } + + if ok, err := this.WillBeAccepted(req); err != nil { + return fail(err) + } else if !ok { + return fail(ErrNotAcceptable) + } + + sess := req.Authorization().FindSession() + if sess == nil { + return failf(errors.System, "authorization without session") + } + + return this.findOrEnsureBySession(req.Context(), sess, nil, req, true) +} + +func (this *KubernetesRepository) createPodBy(req Request, sess session.Session) (*v1.Pod, error) { + fail := func(err error) (*v1.Pod, error) { + return nil, err + } + failf := func(t errors.Type, msg string, args ...any) (*v1.Pod, error) { + return fail(errors.Newf(t, msg, args...)) + } + + config, err := this.resolvePodConfig(req, sess) + if err != nil { + return fail(err) + } + + clientSet, err := this.client.ClientSet() + if err != nil { + return fail(err) + } + + client := clientSet.CoreV1().Pods(config.Namespace) + + created, err := client.Create(req.Context(), config, metav1.CreateOptions{ + FieldValidation: "Strict", + }) + if err != nil { + var status kerrors.APIStatus + if errors.As(err, &status) && status.Status().Code == 404 && status.Status().Details != nil && status.Status().Details.Kind == "namespaces" { + if err := this.createNamespace(req.Context(), config.Namespace); err != nil { + return failf(errors.System, "cannot create POD: namespace does not exist and can't be created: %w", err) + } + created, err = client.Create(req.Context(), config, metav1.CreateOptions{ + FieldValidation: "Strict", + }) + } + } + if err != nil { + return failf(errors.System, "cannot create POD: %w", err) + } + + watch, err := client.Watch(req.Context(), metav1.ListOptions{ + FieldSelector: "metadata.name=" + created.Name, + }) + if err != nil { + return failf(errors.System, "cannot watch POD %v/%v: %w", created.Namespace, created.Namespace, err) + } + defer watch.Stop() + + var readyTimeout time.Duration + if readyTimeout, err = this.conf.ReadyTimeout.Render(req); err != nil { + return fail(err) + } + + readyTimer := time.NewTimer(readyTimeout) + defer readyTimer.Stop() + for { + select { + case <-readyTimer.C: + // TODO! Report failed - timeout. + return nil, errors.System.Newf("pod %v/%v was still not ready after %v", created.Namespace, created.Name, readyTimeout) + case <-req.Context().Done(): + return nil, req.Context().Err() + case event := <-watch.ResultChan(): + if p, ok := event.Object.(*v1.Pod); ok { + switch p.Status.Phase { + case v1.PodPending: + // TODO! Report pending... + case v1.PodRunning: + // TODO! Report ready... + return p, nil + default: + // TODO! Report failed... + return nil, errors.System.Newf("pod %v/%v is unexpted state, see kubernetes logs for more details", p.Namespace, p.Name) + } + } + } + } +} + +func (this *KubernetesRepository) createNamespace(ctx context.Context, namespace string) error { + fail := func(err error) error { + return errors.System.Newf("cannot create namespace %q: %w", namespace, err) + } + + clientSet, err := this.client.ClientSet() + if err != nil { + return fail(err) + } + + var req v1.Namespace + req.Name = namespace + if _, err := clientSet.CoreV1().Namespaces().Create(ctx, &req, metav1.CreateOptions{ + FieldValidation: "Strict", + }); err != nil { + return fail(err) + } + + return nil +} + +func (this *KubernetesRepository) resolvePullCredentials(req Request, _ string) (string, error) { + fail := func(err error) (string, error) { + return "", errors.Config.Newf("cannot resolve image pull credentials: %w", err) + } + + plain, err := this.conf.ImagePullCredentials.Render(req) + if err != nil { + return fail(err) + } + + if plain == "" { + return "", nil + } + + if buf, err := registry.DecodeAuthConfig(plain); err == nil && (buf.Auth != "" || buf.Username != "" || buf.Password != "") { + // We can take it as it is, because it is in fully valid format. + return plain, nil + } + + var buf registry.AuthConfig + if err := json.Unmarshal([]byte(plain), &buf); err == nil && (buf.Auth != "" || buf.Username != "" || buf.Password != "") { + // Ok, is close to be perfect, just encode it base64 url based... + return base64.URLEncoding.EncodeToString([]byte(plain)), nil + } + + // Seems to be direct auth string... + buf.Auth = plain + result, err := registry.EncodeAuthConfig(buf) + if err != nil { + return fail(err) + } + return result, nil +} + +func (this *KubernetesRepository) resolvePodConfig(req Request, sess session.Session) (result *v1.Pod, err error) { + fail := func(err error) (*v1.Pod, error) { + return nil, err + } + failf := func(msg string, args ...any) (*v1.Pod, error) { + return fail(errors.Config.Newf(msg, args...)) + } + + result = &v1.Pod{} + + if result.Name, err = this.conf.Name.Render(req); err != nil { + return fail(err) + } + if result.Namespace, err = this.conf.Namespace.Render(req); err != nil { + return fail(err) + } + if result.Namespace == "" { + if cn := this.client.Namespace(); cn != "" { + result.Namespace = cn + } else { + result.Namespace = "bifroest" + } + } + + remote := req.Connection().Remote() + result.Labels = map[string]string{ + KubernetesLabelFlow: this.flow.String(), + KubernetesLabelSessionId: sess.Id().String(), + } + + result.Annotations = map[string]string{ + KubernetesAnnotationCreatedRemoteUser: remote.User(), + KubernetesAnnotationCreatedRemoteHost: remote.Host().String(), + } + if result.Annotations[KubernetesAnnotationShellCommand], err = this.resolveEncodedShellCommand(req); err != nil { + return fail(err) + } + if result.Annotations[KubernetesAnnotationExecCommand], err = this.resolveEncodedExecCommand(req); err != nil { + return fail(err) + } + if result.Annotations[KubernetesAnnotationSftpCommand], err = this.resolveEncodedSftpCommand(req); err != nil { + return fail(err) + } + if result.Annotations[KubernetesAnnotationUser], err = this.conf.User.Render(req); err != nil { + return failf("cannot evaluate user: %w", err) + } + if result.Annotations[KubernetesAnnotationDirectory], err = this.conf.Directory.Render(req); err != nil { + return failf("cannot evaluate directory: %w", err) + } + if v, err := this.conf.PortForwardingAllowed.Render(req); err != nil { + return failf("cannot evaluate portForwardingAllowed: %w", err) + } else if v { + result.Annotations[KubernetesAnnotationPortForwardingAllowed] = "true" + } + + if v, err := this.resolveContainerConfig(req, sess); err != nil { + return fail(err) + } else { + result.Spec.Containers = []v1.Container{v} + } + // TODO! Add other container configs? + + if v, err := this.resolveInitContainerConfig(req, sess); err != nil { + return fail(err) + } else { + result.Spec.InitContainers = []v1.Container{v} + } + // TODO! Add other init container configs? + + result.Spec.OS = &v1.PodOS{} + switch this.conf.Os { + case sys.OsLinux: + result.Spec.OS = &v1.PodOS{Name: v1.Linux} + case sys.OsWindows: + result.Spec.OS = &v1.PodOS{Name: v1.Windows} + default: + return failf("os %v is unsupported for kubernetes environments", this.conf.Os) + } + + // TODO imagePullSecrets + + if result.Spec.ServiceAccountName, err = this.conf.ServiceAccount.Render(req); err != nil { + return fail(err) + } + + result.Spec.RestartPolicy = v1.RestartPolicyNever + + result.Spec.Volumes = []v1.Volume{{ + Name: "imp", + VolumeSource: v1.VolumeSource{ + EmptyDir: &v1.EmptyDirVolumeSource{}, + }, + }} + // TODO! Add other volumens? + + result.Spec.DNSConfig = &v1.PodDNSConfig{} + if result.Spec.DNSConfig.Nameservers, err = this.conf.DnsServers.Render(req); err != nil { + return failf("cannot evaluate dnsServer: %w", err) + } + if result.Spec.DNSConfig.Searches, err = this.conf.DnsSearch.Render(req); err != nil { + return failf("cannot evaluate dnsSearch: %w", err) + } + + return result, nil +} + +func (this *KubernetesRepository) resolveInitContainerConfig(req Request, _ session.Session) (result v1.Container, err error) { + fail := func(err error) (v1.Container, error) { + return v1.Container{}, err + } + failf := func(msg string, args ...any) (v1.Container, error) { + return fail(errors.Config.Newf(msg, args...)) + } + + result.Name = "init" + if result.Image, err = this.alternatives.FindOciImageFor(req.Context(), this.conf.Os, sys.ArchAmd64, alternatives.FindOciImageOpts{ + Local: this.conf.ImageContextMode != configuration.ContextModeOnline, + Force: this.conf.ImageContextMode == configuration.ContextModeDebug, + }); err != nil { + return fail(err) + } + + var targetPath string + + switch this.conf.Os { + case sys.OsLinux: + targetPath = imp.DefaultInitPathUnix + case sys.OsWindows: + targetPath = imp.DefaultInitPathWindows + default: + return failf("os %v is unsupported for kubernetes environments", this.conf.Os) + } + + result.VolumeMounts = []v1.VolumeMount{{ + Name: "imp", + MountPath: targetPath, + }} + + result.Args = strslice.StrSlice{ + "imp-init", + "--targetPath=" + targetPath, + "--log.colorMode=always", + } + if this.defaultLogLevelName != "" { + result.Args = append(result.Args, `--log.level=`+this.defaultLogLevelName) + } + + return result, nil +} + +func (this *KubernetesRepository) resolveContainerConfig(req Request, sess session.Session) (result v1.Container, err error) { + fail := func(err error) (v1.Container, error) { + return v1.Container{}, err + } + failf := func(msg string, args ...any) (v1.Container, error) { + return fail(errors.Config.Newf(msg, args...)) + } + + result.Name = "bifroest" + + if result.Image, err = this.conf.Image.Render(req); err != nil { + return fail(err) + } + + switch this.conf.ImageContextMode { + case configuration.ContextModeDebug, configuration.ContextModeOffline: + result.ImagePullPolicy = v1.PullNever + default: + switch this.conf.ImagePullPolicy { + case configuration.PullPolicyIfAbsent: + result.ImagePullPolicy = v1.PullIfNotPresent + case configuration.PullPolicyAlways: + result.ImagePullPolicy = v1.PullAlways + case configuration.PullPolicyNever: + result.ImagePullPolicy = v1.PullNever + default: + return failf("image pull policy %v is not supported for kubernetes environments", this.conf.ImagePullPolicy) + } + } + + result.Ports = []v1.ContainerPort{{ + Name: "imp", + ContainerPort: 8683, + Protocol: "TCP", + }} + // TODO! Allow other ports? + + result.Command = strslice.StrSlice{} + result.SecurityContext = &v1.SecurityContext{} + result.Command = []string{sys.BifroestBinaryLocation(this.conf.Os)} + if len(result.Command[0]) == 0 { + return failf("cannot resolve target path for host %v", this.conf.Os) + } + if this.conf.Os == sys.OsLinux { + result.SecurityContext.RunAsUser = common.P[int64](0) + result.SecurityContext.RunAsGroup = common.P[int64](0) + } + + result.Args = []string{ + `imp`, + `--log.colorMode=always`, + } + if this.defaultLogLevelName != "" { + result.Args = append(result.Args, `--log.level=`+this.defaultLogLevelName) + } + + masterPub, err := this.imp.GetMasterPublicKey() + if err != nil { + return fail(err) + } + + result.Env = []v1.EnvVar{{ + Name: imp.EnvVarMasterPublicKey, + Value: base64.RawStdEncoding.EncodeToString(masterPub.Marshal()), + }, { + Name: session.EnvName, + Value: sess.Id().String(), + }} + + result.VolumeMounts = []v1.VolumeMount{{ + Name: "imp", + MountPath: result.Command[0], + SubPath: this.conf.Os.AppendExtToFilename("bifroest"), + ReadOnly: true, + }} + // TODO! Maybe add more volume mounts? + + result.LivenessProbe = &v1.Probe{ + ProbeHandler: v1.ProbeHandler{ + TCPSocket: &v1.TCPSocketAction{ + Port: intstr.FromInt32(imp.ServicePort), + }, + }, + PeriodSeconds: 5, + FailureThreshold: 1, + } + + result.StartupProbe = &v1.Probe{ + ProbeHandler: v1.ProbeHandler{ + TCPSocket: &v1.TCPSocketAction{ + Port: intstr.FromInt32(imp.ServicePort), + }, + }, + PeriodSeconds: 1, + FailureThreshold: 60, + } + + if vs, err := this.conf.Capabilities.Render(req); err != nil { + return failf("cannot evaluate capabilities: %w", err) + } else { + result.SecurityContext.Capabilities = &v1.Capabilities{Add: *(*[]v1.Capability)(unsafe.Pointer(&vs))} + } + if v, err := this.conf.Privileged.Render(req); err != nil { + return failf("cannot evaluate capabilities: %w", err) + } else { + result.SecurityContext.Privileged = common.P(v) + } + + return result, nil +} + +func (this *KubernetesRepository) resolveEncodedShellCommand(req Request) (string, error) { + failf := func(msg string, args ...any) (string, error) { + return "", errors.Config.Newf(msg, args...) + } + + v, err := this.conf.ShellCommand.Render(req) + if err != nil { + return failf("cannot evaluate shellCommand: %w", err) + } + if len(v) == 0 { + switch this.conf.Os { + case sys.OsWindows: + v = []string{`C:\WINDOWS\system32\cmd.exe`} + case sys.OsLinux: + v = []string{`/bin/sh`} + default: + return failf("shellCommand was not defined for kubernetes environment and default cannot be resolved for %v", this.conf.Os) + } + } + b, err := json.Marshal(v) + return string(b), err +} + +func (this *KubernetesRepository) resolveEncodedExecCommand(req Request) (string, error) { + failf := func(msg string, args ...any) (string, error) { + return "", errors.Config.Newf(msg, args...) + } + + v, err := this.conf.ExecCommand.Render(req) + if err != nil { + return failf("cannot evaluate execCommand: %w", err) + } + if len(v) == 0 { + switch this.conf.Os { + case sys.OsWindows: + v = []string{`C:\WINDOWS\system32\cmd.exe`, `/C`} + case sys.OsLinux: + v = []string{`/bin/sh`, `-c`} + default: + return failf("execCommand was not defined for kubernetes environment and default cannot be resolved for %v", this.conf.Os) + } + } + b, err := json.Marshal(v) + return string(b), err +} + +func (this *KubernetesRepository) resolveEncodedSftpCommand(req Request) (string, error) { + failf := func(msg string, args ...any) (string, error) { + return "", errors.Config.Newf(msg, args...) + } + + v, err := this.conf.SftpCommand.Render(req) + if err != nil { + return failf("cannot evaluate sftpCommand: %w", err) + } + if len(v) == 0 { + v = []string{sys.BifroestBinaryLocation(this.conf.Os), `sftp-server`} + if len(v[0]) == 0 { + return failf("sftpCommand was not defined for kubernetes environment and default cannot be resolved for %v", this.conf.Os) + } + } + b, err := json.Marshal(v) + return string(b), err +} + +func (this *KubernetesRepository) FindBySession(ctx context.Context, sess session.Session, opts *FindOpts) (Environment, error) { + return this.findOrEnsureBySession(ctx, sess, opts, nil, false) +} + +func (this *KubernetesRepository) findOrEnsureBySession(ctx context.Context, sess session.Session, opts *FindOpts, createUsing Request, retryAllowed bool) (Environment, error) { + fail := func(err error) (Environment, error) { + return nil, err + } + + sessId := sess.Id() + rUnlocker := this.sessionIdMutex.RLock(sessId) + rUnlock := func() { + if rUnlocker != nil { + rUnlocker() + } + rUnlocker = nil + } + defer rUnlock() + + ip, ok := this.activeInstances.Load(sessId) + if ok { + instance := ip.(*kubernetes) + instance.owners.Add(1) + return instance, nil + } + + existing, err := this.findPodBySession(ctx, sess) + if err != nil { + return nil, err + } + if existing == nil && createUsing == nil { + return fail(ErrNoSuchEnvironment) + } + rUnlock() + + defer this.sessionIdMutex.Lock(sessId)() + + ip, ok = this.activeInstances.Load(sessId) + if ok { + instance := ip.(*kubernetes) + instance.owners.Add(1) + return instance, nil + } + + if existing != nil && existing.Status.Phase != v1.PodPending && existing.Status.Phase != v1.PodRunning { + if opts.IsAutoCleanUpAllowed() || createUsing != nil { + if _, err := this.removePod(ctx, existing.Namespace, existing.Name); err != nil { + return fail(err) + } + } + if createUsing == nil { + return fail(ErrNoSuchEnvironment) + } + existing = nil + } + + if existing == nil { + existing, err = this.createPodBy(createUsing, sess) + if err != nil { + return fail(err) + } + } + + logger := this.logger(). + With("namespace", existing.Namespace). + With("name", existing.Name). + With("sessionId", sessId) + + removePodUnchecked := func() { + if _, err := this.removePod(ctx, existing.Namespace, existing.Name); err != nil { + logger. + WithError(err). + Warnf("cannot broken pod; need to be done manually") + } + } + + instance, err := this.new(ctx, existing, logger) + if err != nil { + if errors.Is(err, podContainsProblemsErr) || sys.IsNotExist(err) { + if createUsing != nil { + removePodUnchecked() + if !retryAllowed { + return fail(err) + } + return this.findOrEnsureBySession(ctx, sess, opts, createUsing, false) + } else if opts.IsAutoCleanUpAllowed() { + removePodUnchecked() + return fail(ErrNoSuchEnvironment) + } + } + return fail(err) + } + + this.activeInstances.Store(sessId, instance) + + return instance, nil +} + +func (this *KubernetesRepository) removePod(ctx context.Context, namespace, name string) (bool, error) { + fail := func(err error) (bool, error) { + return false, errors.System.Newf("cannot remove pod %v/%v: %w", namespace, name, err) + } + + clientSet, err := this.client.ClientSet() + if err != nil { + return fail(err) + } + client := clientSet.CoreV1().Pods(namespace) + + if err := client.Delete(ctx, name, metav1.DeleteOptions{}); errdefs.IsNotFound(err) { + return false, nil + } else if err != nil { + return fail(err) + } + + return true, nil +} + +func (this *KubernetesRepository) findPodBySession(ctx context.Context, sess session.Session) (*v1.Pod, error) { + fail := func(err error) (*v1.Pod, error) { + return nil, errors.System.Newf("cannot find pod by session %v: %w", sess, err) + } + + client, err := this.podsClient() + if err != nil { + return fail(err) + } + + candidates, err := client.List(ctx, metav1.ListOptions{ + LabelSelector: KubernetesLabelSessionId + "=" + sess.Id().String(), + Limit: 1, + }) + if err != nil { + return fail(err) + } + if len(candidates.Items) == 0 { + return nil, nil + } + + return &candidates.Items[0], nil +} + +func (this *KubernetesRepository) podsClient() (v2.PodInterface, error) { + clientSet, err := this.client.ClientSet() + if err != nil { + return nil, err + } + + if v := this.conf.Namespace; v.IsHardCoded() { + if !v.IsZero() { + return clientSet.CoreV1().Pods(v.String()), nil + } + if cv := this.client.Namespace(); cv != "" { + return clientSet.CoreV1().Pods(cv), nil + } + } + + // All namespaces fallback + return clientSet.CoreV1().Pods(""), nil +} + +func (this *KubernetesRepository) Close() error { + return nil +} + +func (this *KubernetesRepository) Cleanup(ctx context.Context, opts *CleanupOpts) error { + fail := func(err error) error { + return errors.System.Newf("cannot cleanup potential orhpan kubernetes containers: %w", err) + } + + l := opts.GetLogger(this.logger) + + client, err := this.podsClient() + if err != nil { + return fail(err) + } + + listOpts := metav1.ListOptions{ + LabelSelector: KubernetesLabelFlow, + } + for { + list, err := client.List(ctx, listOpts) + if err != nil { + return fail(err) + } + + for _, candidate := range list.Items { + cl := l.With("namespace", candidate.Namespace). + With("name", candidate.Name) + + var flow configuration.FlowName + if err := flow.Set(candidate.Labels[KubernetesLabelFlow]); err != nil || flow.IsZero() { + cl.WithError(err). + Warnf("pod does have an illegal %v label; this warn message will appear again until this is fixed; skipping...", KubernetesLabelFlow) + continue + } + + cl = cl.With("flow", flow) + + if flow.IsEqualTo(this.flow) { + cl.Debug("found pod that is owned by this flow environment; ignoring...") + continue + } + + globalHasFlow, err := opts.HasFlowOfName(flow) + if err != nil { + return fail(err) + } + + if globalHasFlow { + cl.Debug("found pod that is owned by another environment; ignoring...") + continue + } + + shouldBeCleaned, err := this.conf.CleanOrphan.Render(kubernetesPodContext{&candidate}) + if err != nil { + return fail(err) + } + + if !shouldBeCleaned { + cl.Debug("found pod that isn't owned by anybody, but should be kept; ignoring...") + continue + } + + if ok, err := this.removePod(ctx, candidate.Namespace, candidate.Name); err != nil { + cl.WithError(err). + Warn("cannot remove orphan pod; this message might continue appearing until manually fixed; skipping...") + continue + } else if ok { + cl.Info("orphan pod removed") + } + } + + if list.Continue == "" { + return nil + } + listOpts.Continue = list.Continue + } +} + +func (this *KubernetesRepository) logger() log.Logger { + if v := this.Logger; v != nil { + return v + } + return log.GetLogger("kubernetes-repository") +} + +func (this *KubernetesRepository) isNoSuchImageError(err error) bool { + for { + if err == nil { + return false + } + if msg := err.Error(); strings.HasPrefix(msg, "No such image:") { + return true + } + ue, ok := err.(interface{ Unwrap() error }) + if !ok { + return false + } + err = ue.Unwrap() + } +} + +type kubernetesPodContext struct { + *v1.Pod +} + +func (this *kubernetesPodContext) GetField(name string) (any, bool, error) { + switch name { + case "namespace": + return this.Namespace, true, nil + case "name": + return this.Name, true, nil + case "image": + for _, candidate := range this.Spec.Containers { + if candidate.Name == "bifroest" { + return candidate.Image, true, nil + } + } + return nil, true, nil + case "flow": + if this.Labels == nil { + return nil, true, nil + } + plain, ok := this.Labels[KubernetesLabelFlow] + if !ok { + return nil, true, nil + } + var flow configuration.FlowName + if err := flow.Set(plain); err != nil { + return nil, false, err + } + if flow.IsZero() { + return nil, true, nil + } + return flow, true, nil + default: + return nil, false, fmt.Errorf("unknown field %q", name) + } +} diff --git a/pkg/environment/kubernetes-usage-specific.go b/pkg/environment/kubernetes-usage-specific.go new file mode 100644 index 0000000..feb80b9 --- /dev/null +++ b/pkg/environment/kubernetes-usage-specific.go @@ -0,0 +1,222 @@ +package environment + +import ( + "context" + "io" + "os" + "slices" + "strings" + "sync" + + log "github.com/echocat/slf4g" + glssh "github.com/gliderlabs/ssh" + v1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/remotecommand" + + "github.com/engity-com/bifroest/pkg/common" + "github.com/engity-com/bifroest/pkg/connection" + "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/imp" + "github.com/engity-com/bifroest/pkg/net" + "github.com/engity-com/bifroest/pkg/session" + "github.com/engity-com/bifroest/pkg/ssh" + "github.com/engity-com/bifroest/pkg/sys" +) + +func (this *kubernetes) Banner(req Request) (io.ReadCloser, error) { + b, err := this.repository.conf.Banner.Render(req) + if err != nil { + return nil, err + } + + return io.NopCloser(strings.NewReader(b)), nil +} + +func (this *kubernetes) Run(t Task) (exitCode int, rErr error) { + fail := func(err error) (int, error) { + return -1, err + } + failf := func(msg string, args ...any) (int, error) { + return fail(errors.System.Newf(msg, args...)) + } + + auth := t.Authorization() + sess := auth.FindSession() + if sess == nil { + return failf("authorization without session is not supported to run kubernetes environment") + } + sshSess := t.SshSession() + l := t.Connection().Logger() + + clientSet, err := this.repository.client.ClientSet() + if err != nil { + return fail(err) + } + + req := clientSet.CoreV1().RESTClient().Post(). + Resource("pods"). + Namespace(this.namespace). + Name(this.name). + SubResource("exec") + + opts := v1.PodExecOptions{ + Container: "bifroest", + Stdin: true, + Stdout: true, + Stderr: true, + } + // TODO! this.user / this.directory is ignored!? + + streamOpts := remotecommand.StreamOptions{ + Stdin: sshSess, + Stdout: sshSess, + Stderr: sshSess.Stderr(), + } + + ev := sys.EnvVars{} + if v, ok := os.LookupEnv("TZ"); ok { + ev.Set("TZ", v) + } + ev.AddAllOf(t.Authorization().EnvVars()) + ev.Add(t.SshSession().Environ()...) + ev.Set(session.EnvName, sess.Id().String()) + ev.Set(connection.EnvName, t.Connection().Id().String()) + + switch t.TaskType() { + case TaskTypeShell: + if v := sshSess.Command(); len(v) > 0 { + opts.Command = append(this.execCommand, v...) + } else { + opts.Command = slices.Clone(this.shellCommand) + } + case TaskTypeSftp: + opts.Command = slices.Clone(this.sftpCommand) + default: + return failf("illegal task type: %v", t.TaskType()) + } + // TODO! Wrap this into the wrapper exec command + + if ssh.AgentRequested(sshSess) { + ln, err := this.impSession.InitiateNamedPipe(t.Context(), t.Connection().Id(), "ssh-agent") + if err != nil { + return fail(err) + } + defer common.IgnoreCloseError(ln) + go ssh.ForwardAgentConnections(ln, l, sshSess) + ev.Set(ssh.AuthSockEnvName, ln.Path()) + } + + if ptyReq, winCh, isPty := sshSess.Pty(); isPty { + ev.Set("TERM", ptyReq.Term) + opts.TTY = true + streamOpts.Tty = true + streamOpts.TerminalSizeQueue = &terminalQueueSizeFromSsh{winCh} + } + // TODO! we need to set the environment variables some way + // opts.Env = ev.Strings() + + req.VersionedParams(&opts, scheme.ParameterCodec) + exec, err := remotecommand.NewSPDYExecutor(this.repository.client.RestConfig(), "POST", req.URL()) + if err != nil { + return fail(err) + } + + signals := make(chan glssh.Signal, 1) + streamDone := make(chan error, 1) + var activeRoutines sync.WaitGroup + defer func() { + go func() { + activeRoutines.Wait() + defer close(signals) + defer close(streamDone) + }() + }() + + activeRoutines.Add(1) + go func() { + cErr := exec.StreamWithContext(t.Context(), streamOpts) + if this.isRelevantError(cErr) { + streamDone <- cErr + } else { + streamDone <- nil + } + l.Trace("streaming finished") + }() + + finish := func() (int, error) { + // TODO! Find a way to retrieve error code. + return 0, nil + } + + sshSess.Signals(signals) + for { + select { + case s, ok := <-signals: + if ok { + this.signal(t.Context(), l, t.Connection(), s) + } + case <-t.Context().Done(): + return -2, rErr + case err, ok := <-streamDone: + _ = sshSess.CloseWrite() + if ok && err != nil && rErr == nil { + return -1, err + } + if rErr == nil { + if ec, err := finish(); err != nil { + return -1, err + } else if ec >= 0 { + return ec, nil + } + } + } + } +} + +type terminalQueueSizeFromSsh struct { + c <-chan glssh.Window +} + +func (this *terminalQueueSizeFromSsh) Next() *remotecommand.TerminalSize { + win, ok := <-this.c + if !ok { + return nil + } + return &remotecommand.TerminalSize{ + Width: uint16(win.Width), + Height: uint16(win.Height), + } +} + +func (this *kubernetes) signal(ctx context.Context, logger log.Logger, conn connection.Connection, sshSignal glssh.Signal) { + var signal sys.Signal + if err := signal.Set(string(sshSignal)); err != nil { + signal = sys.SIGKILL + } + + if err := this.impSession.Kill(ctx, conn.Id(), 0, signal); errors.Is(err, imp.ErrNoSuchProcess) { + // Ok. + } else if err != nil { + logger.WithError(err). + With("signal", signal). + Warn("cannot send signal to process") + } +} + +func (this *kubernetes) IsPortForwardingAllowed(_ net.HostPort) (bool, error) { + return this.portForwardingAllowed, nil +} + +func (this *kubernetes) NewDestinationConnection(ctx context.Context, dest net.HostPort) (io.ReadWriteCloser, error) { + if !this.portForwardingAllowed { + return nil, errors.Newf(errors.Permission, "portforwarning not allowed") + } + + connId, err := connection.NewId() + if err != nil { + return nil, err + } + + return this.impSession.InitiateTcpForward(ctx, connId, dest) +} diff --git a/pkg/environment/kubernetes.go b/pkg/environment/kubernetes.go new file mode 100644 index 0000000..23ea4b3 --- /dev/null +++ b/pkg/environment/kubernetes.go @@ -0,0 +1,204 @@ +package environment + +import ( + "context" + "encoding/json" + "fmt" + "io" + gonet "net" + "strconv" + "sync/atomic" + "syscall" + "time" + + log "github.com/echocat/slf4g" + v1 "k8s.io/api/core/v1" + + "github.com/engity-com/bifroest/pkg/common" + "github.com/engity-com/bifroest/pkg/connection" + "github.com/engity-com/bifroest/pkg/crypto" + "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/imp" + "github.com/engity-com/bifroest/pkg/net" + "github.com/engity-com/bifroest/pkg/session" + "github.com/engity-com/bifroest/pkg/sys" +) + +type kubernetes struct { + repository *KubernetesRepository + + name string + namespace string + sessionId session.Id + + remoteUser string + remoteHost net.Host + + shellCommand []string + execCommand []string + sftpCommand []string + user string + directory string + + portForwardingAllowed bool + + impSession imp.Session + + owners atomic.Int32 +} + +func (this *kubernetes) SessionId() session.Id { + return this.sessionId +} + +func (this *kubernetes) PublicKey() crypto.PublicKey { + return nil +} + +func (this *kubernetes) Dial(ctx context.Context) (gonet.Conn, error) { + return this.repository.client.DialPod(ctx, this.namespace, this.name, strconv.Itoa(imp.ServicePort)) +} + +func (this *KubernetesRepository) new(ctx context.Context, pod *v1.Pod, logger log.Logger) (*kubernetes, error) { + fail := func(err error) (*kubernetes, error) { + return nil, errors.System.Newf("cannot create environment from pod %v/%v of flow %v: %w", pod.Namespace, pod.Name, this.flow, err) + } + + result := &kubernetes{ + repository: this, + } + if err := result.parsePod(pod); err != nil { + return fail(err) + } + var err error + if result.impSession, err = this.imp.Open(ctx, result); err != nil { + return fail(err) + } + + connId, err := connection.NewId() + if err != nil { + return fail(err) + } + + for try := 1; try <= 200; try++ { + if err := result.impSession.Ping(ctx, connId); err == nil { + break + } else if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || sys.IsNotExist(err) { + // try waiting... + } else { + return fail(err) + } + l := logger.With("try", try) + if try <= 2 { + l.Debug("waiting for container's imp getting ready...") + } else { + l.Info("still waiting for container's imp getting ready...") + } + time.Sleep(500 * time.Millisecond) + } + + result.owners.Add(1) + + return result, nil +} + +func (this *kubernetes) Dispose(ctx context.Context) (_ bool, rErr error) { + fail := func(err error) (bool, error) { + return false, errors.Newf(errors.System, "cannot dispose environment: %w", err) + } + + defer this.repository.sessionIdMutex.Lock(this.sessionId)() + defer common.KeepError(&rErr, this.closeGuarded) + + ok, err := this.repository.removePod(ctx, this.namespace, this.name) + if err != nil { + return fail(err) + } + + return ok, nil +} + +func (this *kubernetes) Close() (rErr error) { + defer this.repository.sessionIdMutex.Lock(this.sessionId)() + + return this.closeGuarded() +} + +func (this *kubernetes) closeGuarded() error { + if this.owners.Add(-1) > 0 { + return nil + } + this.repository.activeInstances.Delete(this.sessionId) + return nil +} + +func (this *kubernetes) isRelevantError(err error) bool { + return err != nil && !errors.Is(err, syscall.EIO) && !sys.IsClosedError(err) +} + +var ( + podContainsProblemsErr = errors.System.Newf("pod contains problems") +) + +func (this *kubernetes) parsePod(pod *v1.Pod) (err error) { + fail := func(err error) error { + return fmt.Errorf("%w: %v", podContainsProblemsErr, err) + } + failf := func(msg string, args ...any) error { + return fail(errors.System.Newf(msg, args...)) + } + decodeStrings := func(in string) (result []string, err error) { + err = json.Unmarshal([]byte(in), &result) + return result, err + } + + this.name = pod.Name + this.namespace = pod.Namespace + + labels := pod.Labels + if labels == nil { + pod.Labels = map[string]string{} + } + if v := labels[KubernetesLabelFlow]; v == "" { + return failf("missing label %s", KubernetesLabelFlow) + } else if v != this.repository.flow.String() { + return failf("expected flow: %v; bot container had: %v", this.repository.flow, v) + } + if v := labels[KubernetesLabelSessionId]; v == "" { + return failf("missing label %s", KubernetesLabelSessionId) + } else if err = this.sessionId.UnmarshalText([]byte(v)); err != nil { + return failf("cannot decode label %s: %w", KubernetesLabelSessionId, err) + } + + annotations := pod.Annotations + if annotations == nil { + pod.Annotations = map[string]string{} + } + this.remoteUser = annotations[KubernetesAnnotationCreatedRemoteUser] + if v := annotations[KubernetesAnnotationCreatedRemoteHost]; v == "" { + return failf("missing annotation %s", KubernetesAnnotationCreatedRemoteHost) + } else if err = this.remoteHost.Set(v); err != nil { + return failf("cannot decode annotation %s: %w", KubernetesAnnotationCreatedRemoteHost, err) + } + if v := annotations[KubernetesAnnotationShellCommand]; v == "" { + return failf("missing annotation %s", KubernetesAnnotationShellCommand) + } else if this.shellCommand, err = decodeStrings(v); err != nil { + return failf("cannot decode annotation %s: %w", KubernetesAnnotationShellCommand, err) + } + if v := annotations[KubernetesAnnotationExecCommand]; v == "" { + return failf("missing annotation %s", KubernetesAnnotationExecCommand) + } else if this.execCommand, err = decodeStrings(v); err != nil { + return failf("cannot decode annotation %s: %w", KubernetesAnnotationExecCommand, err) + } + if v := annotations[KubernetesAnnotationSftpCommand]; v == "" { + this.sftpCommand = nil + } else if this.sftpCommand, err = decodeStrings(v); err != nil { + return failf("cannot decode annotation %s: %w", KubernetesAnnotationSftpCommand, err) + } + + this.user = annotations[KubernetesAnnotationUser] + this.directory = annotations[KubernetesAnnotationDirectory] + this.portForwardingAllowed = annotations[KubernetesAnnotationPortForwardingAllowed] == "true" + + return nil +} diff --git a/pkg/imp/imp.go b/pkg/imp/imp.go index a18855e..d417139 100644 --- a/pkg/imp/imp.go +++ b/pkg/imp/imp.go @@ -3,15 +3,18 @@ package imp import ( "context" "io" + gonet "net" "github.com/engity-com/bifroest/internal/imp/protocol" "github.com/engity-com/bifroest/pkg/crypto" - "github.com/engity-com/bifroest/pkg/net" "github.com/engity-com/bifroest/pkg/session" ) const ( EnvVarMasterPublicKey = "BIFROEST_MASTER_PUBLIC_KEY" + + DefaultInitPathUnix = `/var/lib/engity/bifroest/init` + DefaultInitPathWindows = `C:\ProgramData\Engity\Bifroest\init` ) var ( @@ -21,7 +24,7 @@ var ( type Ref interface { SessionId() session.Id PublicKey() crypto.PublicKey - EndpointAddr() net.HostPort + Dial(context.Context) (gonet.Conn, error) } func NewImp(ctx context.Context, bifroestPrivateKey crypto.PrivateKey) (Imp, error) { diff --git a/pkg/imp/imp_unix.go b/pkg/imp/imp_unix.go new file mode 100644 index 0000000..3b52f65 --- /dev/null +++ b/pkg/imp/imp_unix.go @@ -0,0 +1,7 @@ +//go:build unix + +package imp + +const ( + DefaultInitPath = DefaultInitPathUnix +) diff --git a/pkg/imp/imp_windows.go b/pkg/imp/imp_windows.go new file mode 100644 index 0000000..753986b --- /dev/null +++ b/pkg/imp/imp_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package imp + +const ( + DefaultInitPath = DefaultInitPathWindows +) diff --git a/pkg/imp/roundtrip_test.go b/pkg/imp/roundtrip_test.go index 1c56c8e..72291ed 100644 --- a/pkg/imp/roundtrip_test.go +++ b/pkg/imp/roundtrip_test.go @@ -466,6 +466,6 @@ func (this refImpl) PublicKey() crypto.PublicKey { return nil } -func (this refImpl) EndpointAddr() net.HostPort { - return roundtripTestImpAddress +func (this refImpl) Dial(ctx context.Context) (gonet.Conn, error) { + return new(gonet.Dialer).DialContext(ctx, "tcp", roundtripTestImpAddress.String()) } diff --git a/pkg/kubernetes/client-port-dial.go b/pkg/kubernetes/client-port-dial.go new file mode 100644 index 0000000..d693bca --- /dev/null +++ b/pkg/kubernetes/client-port-dial.go @@ -0,0 +1,216 @@ +package kubernetes + +import ( + "context" + "fmt" + "io" + gonet "net" + "net/http" + "os" + "time" + + v1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/httpstream" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + + "github.com/engity-com/bifroest/pkg/common" + "github.com/engity-com/bifroest/pkg/errors" +) + +func (this *client) DialPod(ctx context.Context, namespace, name, port string) (gonet.Conn, error) { + fail := func(err error) (gonet.Conn, error) { + return nil, err + } + + clientSet, err := this.ClientSet() + if err != nil { + return fail(err) + } + + return this.dial( + ctx, + clientSet.CoreV1().RESTClient(), + schema.GroupVersionKind{ + Version: "v1", + Kind: "pod", + }, + namespace, name, + port, + ) + +} + +func (this *client) dial(ctx context.Context, restClient rest.Interface, gvk schema.GroupVersionKind, namespace, name, port string) (gonet.Conn, error) { + fail := func(err error) (gonet.Conn, error) { + return nil, err + } + + req := restClient.Post(). + Resource(gvk.Kind + "s"). + Namespace(namespace). + Name(name). + SubResource("portforward") + + transport, upgrader, err := spdy.RoundTripperFor(this.RestConfig()) + if err != nil { + return fail(err) + } + + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", req.URL()) + + success := false + rawConn, _, err := dialer.Dial(portforward.PortForwardProtocolV1Name) + if err != nil { + var statusErr kerrors.APIStatus + if errors.As(err, &statusErr) && statusErr.Status().Reason == metav1.StatusReasonNotFound { + return fail(os.ErrNotExist) + } + return fail(err) + } + defer common.IgnoreCloseErrorIfFalse(&success, rawConn) + + headers := http.Header{} + headers.Set(v1.StreamType, v1.StreamTypeError) + headers.Set(v1.PortHeader, port) + headers.Set(v1.PortForwardRequestIDHeader, "1") + + errorStream, err := rawConn.CreateStream(headers) + if err != nil { + return nil, errors.Network.Newf("error creating err stream: %w", err) + } + defer func() { + if !success { + rawConn.RemoveStreams(errorStream) + } + }() + defer common.IgnoreCloseErrorIfFalse(&success, errorStream) + + headers.Set(v1.StreamType, v1.StreamTypeData) + dataStream, err := rawConn.CreateStream(headers) + if err != nil { + return nil, errors.Network.Newf("error creating data stream: %w", err) + } + defer func() { + if !success { + rawConn.RemoveStreams(dataStream) + } + }() + defer common.IgnoreCloseErrorIfFalse(&success, dataStream) + + result := &httpstreamConn{ + delegate: rawConn, + port: port, + errCh: make(chan error), + err: errorStream, + data: dataStream, + ownerOvk: gvk, + ownerNamespace: namespace, + ownerName: name, + } + go result.watchErr(ctx) + + success = true + return result, nil +} + +type httpstreamConn struct { + delegate httpstream.Connection + errCh chan error + data, err httpstream.Stream + port string + ownerOvk schema.GroupVersionKind + ownerNamespace string + ownerName string +} + +func (this *httpstreamConn) watchErr(ctx context.Context) { + // This should only return if an err comes back. + bs, err := io.ReadAll(this.err) + if err != nil { + select { + case <-ctx.Done(): + case this.errCh <- errors.Network.Newf("error during read: %w", err): + } + } + if len(bs) > 0 { + select { + case <-ctx.Done(): + case this.errCh <- errors.Network.Newf("error during read: %s", string(bs)): + } + } +} + +func (this *httpstreamConn) Read(b []byte) (n int, err error) { + select { + case err := <-this.errCh: + return 0, err + default: + return this.data.Read(b) + } +} + +func (this *httpstreamConn) Write(b []byte) (n int, err error) { + select { + case err := <-this.errCh: + return 0, err + default: + return this.data.Write(b) + } +} + +func (this *httpstreamConn) Close() (rErr error) { + defer common.KeepCloseError(&rErr, this.delegate) + defer this.delegate.RemoveStreams(this.data, this.err) + defer common.KeepCloseError(&rErr, this.err) + defer common.KeepCloseError(&rErr, this.data) + + select { + case err := <-this.errCh: + return err + default: + return nil + } +} + +func (this *httpstreamConn) LocalAddr() gonet.Addr { + return httpstreamAddr("local") +} + +func (this *httpstreamConn) RemoteAddr() gonet.Addr { + return httpstreamAddr(fmt.Sprintf("%v/%s/%s:%s", + this.ownerOvk, + this.ownerNamespace, + this.ownerName, + this.port, + )) +} + +func (this *httpstreamConn) SetDeadline(t time.Time) error { + this.delegate.SetIdleTimeout(time.Until(t)) + return nil +} + +func (this *httpstreamConn) SetReadDeadline(t time.Time) error { + this.delegate.SetIdleTimeout(time.Until(t)) + return nil +} + +func (this *httpstreamConn) SetWriteDeadline(t time.Time) error { + this.delegate.SetIdleTimeout(time.Until(t)) + return nil +} + +type httpstreamAddr string + +func (f httpstreamAddr) Network() string { + return "k8s-api" +} + +func (f httpstreamAddr) String() string { + return string(f) +} diff --git a/pkg/kubernetes/client.go b/pkg/kubernetes/client.go new file mode 100644 index 0000000..17f6bc6 --- /dev/null +++ b/pkg/kubernetes/client.go @@ -0,0 +1,71 @@ +package kubernetes + +import ( + "context" + gonet "net" + "reflect" + "sync/atomic" + + dynamicT "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/engity-com/bifroest/pkg/errors" +) + +type Client interface { + RestConfig() *rest.Config + ClientSet() (kubernetes.Interface, error) + DialPod(ctx context.Context, namespace, name, port string) (gonet.Conn, error) + + ContextName() string + Namespace() string +} + +type client struct { + restConfig *rest.Config + plainSource string + contextName string + namespace string + + typed atomic.Pointer[kubernetes.Clientset] + dynamic atomic.Pointer[dynamicT.Interface] +} + +func (this *client) String() string { + return this.plainSource +} + +func (this *client) RestConfig() *rest.Config { + return this.restConfig +} + +func (this *client) ClientSet() (kubernetes.Interface, error) { + for { + result := this.typed.Load() + if result != nil { + return result, nil + } + + if this.restConfig == nil { + // TODO! We should find a way to implement this, too + return nil, errors.System.Newf("currently there is no support for mock of %v", reflect.TypeOf((*kubernetes.Interface)(nil)).Elem()) + } + + result, err := kubernetes.NewForConfig(this.restConfig) + if err != nil { + return nil, errors.System.Newf("cannot create new typed kubernetes client from %q: %w", this.plainSource, err) + } + if this.typed.CompareAndSwap(nil, result) { + return result, nil + } + } +} + +func (this *client) ContextName() string { + return this.contextName +} + +func (this *client) Namespace() string { + return this.namespace +} diff --git a/pkg/kubernetes/kubeconfig-loader.go b/pkg/kubernetes/kubeconfig-loader.go new file mode 100644 index 0000000..e9cb866 --- /dev/null +++ b/pkg/kubernetes/kubeconfig-loader.go @@ -0,0 +1,33 @@ +package kubernetes + +import ( + "dario.cat/mergo" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type kubeconfigLoader struct { + clientcmd.ClientConfigLoader + loader func() (*clientcmdapi.Config, error) + context string +} + +func (this kubeconfigLoader) Load() (*clientcmdapi.Config, error) { + result := clientcmdapi.NewConfig() + result.CurrentContext = this.context + + loaded, err := this.loader() + if err != nil { + return nil, err + } + if err := mergo.Merge(result, loaded); err != nil { + return nil, err + } + + return result, nil +} + +func (this kubeconfigLoader) IsDefaultConfig(*restclient.Config) bool { + return false +} diff --git a/pkg/kubernetes/kubeconfig-overwrite.go b/pkg/kubernetes/kubeconfig-overwrite.go new file mode 100644 index 0000000..c41404c --- /dev/null +++ b/pkg/kubernetes/kubeconfig-overwrite.go @@ -0,0 +1,61 @@ +package kubernetes + +import ( + "net" + "os" + "os/user" + "path/filepath" + + "k8s.io/client-go/rest" +) + +type kubeconfigOverwrites struct { + defaultPath string + + serviceHost string + serviceTokenFile string + serviceRootCaFile string + serviceNamespaceFile string +} + +func (this kubeconfigOverwrites) resolveDefaultPath() string { + if v := this.defaultPath; v != "" { + return v + } + if u, err := user.Current(); err == nil { + return filepath.Join(u.HomeDir, ".kube", "config") + } + return filepath.Join(".kube", "config") +} + +func (this kubeconfigOverwrites) resolveServiceHost() (string, error) { + if v := this.serviceHost; v != "" { + return v, nil + } + host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") + if len(host) == 0 || len(port) == 0 { + return "", rest.ErrNotInCluster + } + return "https://" + net.JoinHostPort(host, port), nil +} + +func (this kubeconfigOverwrites) resolveServiceTokenFile() string { + if v := this.serviceTokenFile; v != "" { + return v + } + return ServiceTokenFile +} + +func (this kubeconfigOverwrites) resolveServiceRootCaFile() string { + if v := this.serviceRootCaFile; v != "" { + return v + } + return ServiceRootCAFile +} + +func (this kubeconfigOverwrites) resolveServiceNamespaceFile() string { + if v := this.serviceNamespaceFile; v != "" { + return v + } + return ServiceNamespaceFile +} diff --git a/pkg/kubernetes/kubeconfig.go b/pkg/kubernetes/kubeconfig.go new file mode 100644 index 0000000..966deb6 --- /dev/null +++ b/pkg/kubernetes/kubeconfig.go @@ -0,0 +1,248 @@ +package kubernetes + +import ( + "fmt" + "os" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/client-go/transport" + certutil "k8s.io/client-go/util/cert" + + "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/sys" +) + +const ( + ServiceTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" + ServiceRootCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + ServiceNamespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + + KubeconfigInCluster = "incluster" + KubeconfigMock = "mock" + EnvVarKubeconfig = "KUBE_CONFIG" + DefaultContext = "default" +) + +func NewKubeconfig(plain string) (Kubeconfig, error) { + var buf Kubeconfig + if err := buf.Set(plain); err != nil { + return Kubeconfig{}, err + } + return buf, nil +} + +func MustNewKubeconfig(plain string) Kubeconfig { + buf, err := NewKubeconfig(plain) + if err != nil { + panic(err) + } + return buf +} + +type Kubeconfig struct { + plain string + overwrites kubeconfigOverwrites +} + +func (this *Kubeconfig) MarshalText() ([]byte, error) { + return []byte(this.plain), nil +} + +func (this *Kubeconfig) UnmarshalText(text []byte) error { + plain := string(text) + switch plain { + case "", KubeconfigInCluster, KubeconfigMock: + *this = Kubeconfig{plain: plain, overwrites: this.overwrites} + return nil + default: + fi, err := os.Stat(plain) + if err != nil { + return errors.Config.Newf("illegal kubeconfig %q: %w", plain, err) + } + if fi.IsDir() { + return errors.Config.Newf("illegal kubeconfig %q: not a file", plain) + } + *this = Kubeconfig{plain: plain, overwrites: this.overwrites} + return nil + } +} + +func (this *Kubeconfig) String() string { + v, err := this.MarshalText() + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + return string(v) +} + +func (this *Kubeconfig) Validate() error { + return nil +} + +func (this *Kubeconfig) Set(plain string) error { + return this.UnmarshalText([]byte(plain)) +} + +func (this *Kubeconfig) IsZero() bool { + return len(this.plain) == 0 +} + +func (this Kubeconfig) IsEqualTo(other any) bool { + if other == nil { + return false + } + switch v := other.(type) { + case Kubeconfig: + return this.isEqualTo(&v) + case *Kubeconfig: + return this.isEqualTo(v) + default: + return false + } +} + +func (this Kubeconfig) isEqualTo(other *Kubeconfig) bool { + return this.plain == other.plain +} + +func (this *Kubeconfig) GetClient(contextName string) (Client, error) { + return this.getClient(contextName) +} + +func (this *Kubeconfig) getClient(contextName string) (*client, error) { + switch this.plain { + case "": + return this.loadDefaultClient(contextName) + case KubeconfigInCluster: + return this.loadInclusterClient(contextName) + case KubeconfigMock: + return this.loadMockClient(contextName) + default: + return this.loadFromFileClient(this.plain, contextName) + } +} + +func (this *Kubeconfig) loadDefaultClient(contextName string) (*client, error) { + if v, ok := os.LookupEnv(EnvVarKubeconfig); ok { + // As path was empty, but KUBE_CONFIG is set, use it's content. + return this.loadDirectClient(EnvVarKubeconfig, []byte(v), contextName) + } + + if contextName == "" || contextName == DefaultContext { + // Try in cluster... + if result, err := this.loadInclusterClient(contextName); sys.IsNotExist(err) || errors.Is(err, rest.ErrNotInCluster) { + // Ignore... + } else if err != nil { + return nil, err + } else { + return result, nil + } + } + + path := this.overwrites.resolveDefaultPath() + result, err := this.loadFromFileClient(path, contextName) + if sys.IsNotExist(err) { + return nil, errors.Config.Newf("neither does the default kubeconfig %q exists nor was a specific file provided nor was the environment variable %s provided nor does this instance run inside kubernetes directly", path, EnvVarKubeconfig) + } + if err != nil { + return nil, err + } + return result, nil +} + +func (this *Kubeconfig) loadInclusterClient(contextName string) (*client, error) { + if contextName != "" && contextName != DefaultContext { + return nil, errors.Config.Newf("kubeconfig of type %s does not support contexts; but got: %s", KubeconfigInCluster, contextName) + } + + result := client{ + plainSource: KubeconfigInCluster, + restConfig: &rest.Config{ + TLSClientConfig: rest.TLSClientConfig{}, + }, + } + + var err error + + if result.restConfig.Host, err = this.overwrites.resolveServiceHost(); err != nil { + return nil, err + } + + tokenFile := this.overwrites.resolveServiceTokenFile() + ts := transport.NewCachedFileTokenSource(tokenFile) + if _, err := ts.Token(); err != nil { + return nil, err + } + result.restConfig.WrapTransport = transport.TokenSourceWrapTransport(ts) + + rootCaFile := this.overwrites.resolveServiceRootCaFile() + if _, err := certutil.NewPool(rootCaFile); err != nil { + return nil, errors.System.Newf("expected to load root CA config from %s, but got err: %v", rootCaFile, err) + } + result.restConfig.TLSClientConfig.CAFile = rootCaFile + + namespaceFile := this.overwrites.resolveServiceNamespaceFile() + if nsb, err := os.ReadFile(namespaceFile); err != nil { + return nil, errors.System.Newf("expected to load namespace from %s, but got err: %v", namespaceFile, err) + } else { + result.namespace = string(nsb) + } + + return &result, nil +} + +func (this *Kubeconfig) loadMockClient(contextName string) (*client, error) { + if contextName == "" || contextName == DefaultContext { + contextName = "mock" + } + return &client{ + plainSource: KubeconfigMock, + contextName: contextName, + }, nil +} + +func (this *Kubeconfig) loadDirectClient(plainSource string, content []byte, contextName string) (*client, error) { + return this.loadClientUsing(plainSource, &kubeconfigLoader{ + context: contextName, + loader: func() (*clientcmdapi.Config, error) { + return clientcmd.Load(content) + }, + }) +} + +func (this *Kubeconfig) loadFromFileClient(file, contextName string) (*client, error) { + return this.loadClientUsing(file, &kubeconfigLoader{ + context: contextName, + loader: func() (*clientcmdapi.Config, error) { + return clientcmd.LoadFromFile(file) + }, + }) +} + +func (this *Kubeconfig) loadClientUsing(plainSource string, loader *kubeconfigLoader) (*client, error) { + loadedConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loader, + &clientcmd.ConfigOverrides{ + CurrentContext: loader.context, + }, + ) + + rc, err := loadedConfig.RawConfig() + if err != nil { + return nil, err + } + if rc.CurrentContext == "" { + return nil, clientcmd.ErrNoContext + } + restConfig, err := loadedConfig.ClientConfig() + if err != nil { + return nil, err + } + return &client{ + plainSource: plainSource, + restConfig: restConfig, + contextName: rc.CurrentContext, + }, nil +} diff --git a/pkg/kubernetes/kubeconfig_test.go b/pkg/kubernetes/kubeconfig_test.go new file mode 100644 index 0000000..8d50b8f --- /dev/null +++ b/pkg/kubernetes/kubeconfig_test.go @@ -0,0 +1,244 @@ +package kubernetes + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/clientcmd" +) + +func Test_Kubeconfig_GetClient_emptyAndNoDefault_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + instance := Kubeconfig{} + instance.overwrites.defaultPath = "resources/does_not_exist.yml" + + actual, actualErr := instance.GetClient("") + require.ErrorContains(t, actualErr, `neither does the default kubeconfig "resources/does_not_exist.yml" exists nor was a `) + require.Nil(t, actual) +} + +func Test_Kubeconfig_GetClient_emptyAndTwoContexts_succeeds(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + instance := Kubeconfig{} + instance.overwrites.defaultPath = "resources/kubeconfig_two_contexts.yml" + + actual, actualErr := instance.getClient("") + require.NoError(t, actualErr) + require.NotNil(t, actual) + require.Equal(t, "resources/kubeconfig_two_contexts.yml", actual.plainSource) + require.Equal(t, "http://127.0.0.1:8080", actual.restConfig.Host) + require.Equal(t, "context1", actual.contextName) + require.Equal(t, "", actual.namespace) +} + +func Test_Kubeconfig_GetClient_emptyTwoContexts_specificContext_succeeds(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + instance := Kubeconfig{} + instance.overwrites.defaultPath = "resources/kubeconfig_two_contexts.yml" + + actual, actualErr := instance.getClient("context2") + require.NoError(t, actualErr) + require.NotNil(t, actual) + require.Equal(t, "resources/kubeconfig_two_contexts.yml", actual.plainSource) + require.Equal(t, "http://127.0.0.2:8080", actual.restConfig.Host) + require.Equal(t, "context2", actual.contextName) + require.Equal(t, "", actual.namespace) +} + +func Test_Kubeconfig_GetClient_emptyAndEnvVarSet_succeeds(t *testing.T) { + defer setEnvVarTemporaryToFileContent(t, EnvVarKubeconfig, "resources/kubeconfig_alternative.yml")() + instance := Kubeconfig{} + instance.overwrites.defaultPath = "resources/kubeconfig_two_contexts.yml" + + actual, actualErr := instance.getClient("") + require.NoError(t, actualErr) + require.NotNil(t, actual) + require.Equal(t, "http://127.0.0.3:8080", actual.restConfig.Host) + require.Equal(t, "context3", actual.contextName) + require.Equal(t, "", actual.namespace) +} + +func Test_Kubeconfig_GetClient_emptyAndTwoContexts_withoutCurrentContext_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + instance := Kubeconfig{} + instance.overwrites.defaultPath = "resources/kubeconfig_without_current_context.yml" + + actual, actualErr := instance.getClient("") + require.Equal(t, clientcmd.ErrNoContext, actualErr) + require.Nil(t, actual) +} + +func Test_Kubeconfig_GetClient_emptyTwoContexts_specificNonExistingContext_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + instance := Kubeconfig{} + instance.overwrites.defaultPath = "resources/kubeconfig_two_contexts.yml" + + actual, actualErr := instance.getClient("wrong") + require.ErrorContains(t, actualErr, `context "wrong" does not exist`) + require.Nil(t, actual) +} + +func Test_Kubeconfig_GetClient_nonExistingFile_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + instance := Kubeconfig{plain: "resources/does_not_exist.yml"} + + actual, actualErr := instance.getClient("wrong") + require.ErrorIs(t, actualErr, os.ErrNotExist) + require.Nil(t, actual) +} + +func Test_Kubeconfig_GetClient_mock_emptyContext_succeeds(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + instance := Kubeconfig{plain: "mock"} + + actual, actualErr := instance.getClient("") + require.NoError(t, actualErr) + require.NotNil(t, actual) + require.Equal(t, "mock", actual.plainSource) + require.Nil(t, actual.restConfig) + require.Equal(t, "mock", actual.contextName) + require.Equal(t, "", actual.namespace) +} + +func Test_Kubeconfig_GetClient_mock_specificContext_succeeds(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + instance := Kubeconfig{plain: KubeconfigMock} + + actual, actualErr := instance.getClient("foobar") + require.NoError(t, actualErr) + require.NotNil(t, actual) + require.Equal(t, KubeconfigMock, actual.plainSource) + require.Nil(t, actual.restConfig) + require.Equal(t, "foobar", actual.contextName) + require.Equal(t, "", actual.namespace) +} + +func Test_Kubeconfig_GetClient_incluster_succeeds(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_HOST", "127.0.0.66")() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_PORT", "8081")() + instance := Kubeconfig{plain: KubeconfigInCluster} + instance.overwrites.serviceTokenFile = "resources/serviceaccount_token" + instance.overwrites.serviceRootCaFile = "resources/serviceaccount_ca.crt" + instance.overwrites.serviceNamespaceFile = "resources/serviceaccount_namespace" + + actual, actualErr := instance.getClient("") + require.NoError(t, actualErr) + require.NotNil(t, actual) + require.Equal(t, KubeconfigInCluster, actual.plainSource) + require.Equal(t, "https://127.0.0.66:8081", actual.restConfig.Host) + require.Equal(t, "", actual.contextName) + require.Equal(t, "aNamespace", actual.namespace) +} + +func Test_Kubeconfig_GetClient_incluster_withoutServiceHost_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + defer unsetEnvVarTemporary("KUBERNETES_SERVICE_HOST")() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_PORT", "8081")() + instance := Kubeconfig{plain: KubeconfigInCluster} + instance.overwrites.serviceTokenFile = "resources/serviceaccount_token" + instance.overwrites.serviceRootCaFile = "resources/serviceaccount_ca.crt" + instance.overwrites.serviceNamespaceFile = "resources/serviceaccount_namespace" + + actual, actualErr := instance.getClient("unable to load in-cluster configuration, KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT must be defined") + require.ErrorContains(t, actualErr, "") + require.Nil(t, actual) +} + +func Test_Kubeconfig_GetClient_incluster_withoutServicePort_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_HOST", "127.0.0.66")() + defer unsetEnvVarTemporary("KUBERNETES_SERVICE_PORT")() + instance := Kubeconfig{plain: KubeconfigInCluster} + instance.overwrites.serviceTokenFile = "resources/serviceaccount_token" + instance.overwrites.serviceRootCaFile = "resources/serviceaccount_ca.crt" + instance.overwrites.serviceNamespaceFile = "resources/serviceaccount_namespace" + + actual, actualErr := instance.getClient("unable to load in-cluster configuration, KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT must be defined") + require.ErrorContains(t, actualErr, "") + require.Nil(t, actual) +} + +func Test_Kubeconfig_GetClient_incluster_withoutTokenFile_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_HOST", "127.0.0.66")() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_PORT", "8081")() + instance := Kubeconfig{plain: KubeconfigInCluster} + instance.overwrites.serviceTokenFile = "resources/serviceaccount_token_non_existing" + instance.overwrites.serviceRootCaFile = "resources/serviceaccount_ca.crt" + instance.overwrites.serviceNamespaceFile = "resources/serviceaccount_namespace" + + actual, actualErr := instance.getClient("") + require.ErrorContains(t, actualErr, `failed to read token file "resources/serviceaccount_token_non_existing"`) + require.Nil(t, actual) +} + +func Test_Kubeconfig_GetClient_incluster_withoutRootCaFile_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_HOST", "127.0.0.66")() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_PORT", "8081")() + instance := Kubeconfig{plain: KubeconfigInCluster} + instance.overwrites.serviceTokenFile = "resources/serviceaccount_token" + instance.overwrites.serviceRootCaFile = "resources/serviceaccount_ca.crt_non_existing" + instance.overwrites.serviceNamespaceFile = "resources/serviceaccount_namespace" + + actual, actualErr := instance.getClient("") + require.ErrorContains(t, actualErr, `expected to load root CA config from resources/serviceaccount_ca.crt_non_existing`) + require.Nil(t, actual) +} + +func Test_Kubeconfig_GetClient_incluster_withoutNamespaceFail_fails(t *testing.T) { + defer unsetEnvVarTemporary(EnvVarKubeconfig)() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_HOST", "127.0.0.66")() + defer setEnvVarTemporaryTo("KUBERNETES_SERVICE_PORT", "8081")() + instance := Kubeconfig{plain: KubeconfigInCluster} + instance.overwrites.serviceTokenFile = "resources/serviceaccount_token" + instance.overwrites.serviceRootCaFile = "resources/serviceaccount_ca.crt" + instance.overwrites.serviceNamespaceFile = "resources/serviceaccount_namespace_non_existing" + + actual, actualErr := instance.getClient("") + require.ErrorContains(t, actualErr, `expected to load namespace from resources/serviceaccount_namespace_non_existing`) + require.Nil(t, actual) +} + +func setEnvVarTemporaryTo(key, value string) (rollback envVarRollback) { + if oldValue, oldContentExists := os.LookupEnv(key); oldContentExists { + rollback = func() { + _ = os.Setenv(key, oldValue) + } + } else { + rollback = func() { + _ = os.Unsetenv(key) + } + } + _ = os.Setenv(key, value) + return +} + +func unsetEnvVarTemporary(key string) (rollback envVarRollback) { + if oldValue, oldContentExists := os.LookupEnv(key); oldContentExists { + rollback = func() { + _ = os.Setenv(key, oldValue) + } + } else { + rollback = func() { + _ = os.Unsetenv(key) + } + } + _ = os.Unsetenv(key) + return +} + +func setEnvVarTemporaryToFileContent(t testing.TB, key, filename string) (rollback envVarRollback) { + value, err := os.ReadFile(filename) + if err != nil { + t.Errorf("cannot set contents of %s to environment %s", filename, key) + t.Fail() + return + } + + return setEnvVarTemporaryTo(key, string(value)) +} + +type envVarRollback func() diff --git a/pkg/kubernetes/resources/kubeconfig_alternative.yml b/pkg/kubernetes/resources/kubeconfig_alternative.yml new file mode 100644 index 0000000..407ed60 --- /dev/null +++ b/pkg/kubernetes/resources/kubeconfig_alternative.yml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Config +current-context: context3 + +clusters: + - name: cluster3 + cluster: + server: http://127.0.0.3:8080 + +users: + - name: user3 + +contexts: + - name: context3 + context: + cluster: cluster3 + user: user3 diff --git a/pkg/kubernetes/resources/kubeconfig_two_contexts.yml b/pkg/kubernetes/resources/kubeconfig_two_contexts.yml new file mode 100644 index 0000000..2795b6e --- /dev/null +++ b/pkg/kubernetes/resources/kubeconfig_two_contexts.yml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Config +current-context: context1 + +clusters: + - name: cluster1 + cluster: + server: http://127.0.0.1:8080 + - name: cluster2 + cluster: + server: http://127.0.0.2:8080 + +users: + - name: user1 + - name: user2 + +contexts: + - name: context1 + context: + cluster: cluster1 + user: user1 + + - name: context2 + context: + cluster: cluster2 + user: user2 + diff --git a/pkg/kubernetes/resources/kubeconfig_without_current_context.yml b/pkg/kubernetes/resources/kubeconfig_without_current_context.yml new file mode 100644 index 0000000..2cd828e --- /dev/null +++ b/pkg/kubernetes/resources/kubeconfig_without_current_context.yml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Config + +clusters: + - name: cluster1 + cluster: + server: http://127.0.0.1:8080 + - name: cluster2 + cluster: + server: http://127.0.0.2:8080 + +users: + - name: user1 + - name: user2 + +contexts: + - name: context1 + context: + cluster: cluster1 + user: user1 + + - name: context2 + context: + cluster: cluster2 + user: user2 + diff --git a/pkg/kubernetes/resources/serviceaccount_ca.crt b/pkg/kubernetes/resources/serviceaccount_ca.crt new file mode 100644 index 0000000..baa4ca4 --- /dev/null +++ b/pkg/kubernetes/resources/serviceaccount_ca.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC0zCCAbugAwIBAgIMFT/8dNKAeUUAGreXMA0GCSqGSIb3DQEBCwUAMBUxEzAR +BgNVBAMTCmt1YmVybmV0ZXMwHhcNMTgwNzA4MTA1MjU3WhcNMjgwNzA3MTA1MjU3 +WjAVMRMwEQYDVQQDEwprdWJlcm5ldGVzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEArMMkinwNfG0o8d656/oru33Ev6sW9ThjLYhG0qS8w1VAMVZpB3tt +Hx9SPi8nyomWmjU/KCaswqgKXdddQNPWT5E6mwxm1CLR6FWptzlDA9VA6DVzASJ6 +70bqW6heD2MgPosOmIffYNXhaDYfhpQKlNOWTuLr5E5cJtZpCZmWiag+lu4887YJ +73Ud7O+R/7zth4hR14i9rnCOyJgyyyIFPa0uHUiJaS++DB5sdhQ9ZlHkxSxshiIp +PVXxu9WYfBYFF/XKeaCn1fg3k9PKINvj6XJ+LV58Dhvc7EqhkL50VfUYBFunQ4Lj ++M2/0ggBO+mfHjtZwZOnvy+E+YzyeExqfwIDAQABoyMwITAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEARLktTOsNTZkb +HO7klyJK2hTJYFszQFwkUMGwRwY8g0AKoNQZI8kbJCAupsQBqEjZCHOhx5Sv5ZFC +jlL4/NN4m4J8efUqfNXy7H3EWqY2rEh1ig8clPds1yiexsen4z0V3wPdTtMxWm1x +IJ4NiUK+Nk/kFdPTaG1DL9zz7o3+rLvV3db2e+sD3pGUI9p2gVMgiIH/G1wYRLcZ +SJ04I/AV72c91tzB+28rYvCL2IYd5YqmfVG7Ow3MmAthE6vkTbklrJhHPXFB3HU1 +EYHH1Xj2TpzoDlMe4e1tEKnDR9t7DcRJJq1i4CKEI/SenyPKdTHDRNvdBvAvcHMV +qOt4vnJc2w== +-----END CERTIFICATE----- diff --git a/pkg/kubernetes/resources/serviceaccount_namespace b/pkg/kubernetes/resources/serviceaccount_namespace new file mode 100644 index 0000000..720c0e3 --- /dev/null +++ b/pkg/kubernetes/resources/serviceaccount_namespace @@ -0,0 +1 @@ +aNamespace \ No newline at end of file diff --git a/pkg/kubernetes/resources/serviceaccount_token b/pkg/kubernetes/resources/serviceaccount_token new file mode 100644 index 0000000..02bd091 --- /dev/null +++ b/pkg/kubernetes/resources/serviceaccount_token @@ -0,0 +1 @@ +QMdBp8Hc0aC4oMgQzIlghY8IkFt916fWxhitg1S4zePqvSnpuOFbhmGl0vG5yZCK0uUCvVnj1uIhiErWY4NLOP2zIjA9pHopuLND09Op4QYhNpzJhZ9RifF3sbgZYjUN1q3P0X5pRoq2pGZ4am51maeK7W79FsZDYpYktFm5OIaza1DTcd7mLmpcYakM1sMVNvGol3WXgvyqwngAlLKDPENEx3AY3h2P0a777RnTUjuhkDZhfkOSRaIXWkXgQqlISrBTa0yHGEgPonTn83RAIGULjOIVoqYfscicb2yfj0TxT33sfp1gPR8pADVWw7yTvaN1Ke1rKX9gAJAyw5jsphLH3XMNR6hia6k8lk7n70Ys8wqOrfqnPkewGUCBLPtoNyf3cPFxfYy08cAAKqCiJnYcWah7OdNARykOhTfoU5IP6M8WWMKZ21c8DcxUOM9OXI8i3IhhoZmD02UYBTENeS8lSX1pU3KyTzK1fP959ZjTyMjKEAnJ0NB6I6mUtSMNuEv10LXavWDJuRfF3ysvtqZ3FskBF5rELg51at6gainfvJqhlEH1OKSdHE9t4a98M6fwzI7wPO5e90gyj9Smq7nvnzbsE1L6wrRepvRCQh7isWt1NzAAFW62xtMnPseyncxwSPavDU35HPjPKMEn55TpnfkGzKLnoJ2gf7rd7glBME7weGATVlSzpg3eDeuGIwZjuDVwtfPevRnHz1oTBWPDQZ1uwc1sD26vGKDyZ4oTU3OXFeV0RqzUyLRSo4NjZtzzKG5EHauaxgVDhm9W7kYyN3p0cVXSCvcwTqWDJnKYgB3vs9QwKKg44ijuYw3EbVxnWfQERbimIbsq8Hd \ No newline at end of file diff --git a/pkg/net/address.go b/pkg/net/address.go index d479e9b..52feea1 100644 --- a/pkg/net/address.go +++ b/pkg/net/address.go @@ -8,35 +8,35 @@ import ( "strings" ) -func NewNetAddress(plain string) (NetAddress, error) { - var buf NetAddress +func NewAddress(plain string) (Address, error) { + var buf Address if err := buf.Set(plain); err != nil { - return NetAddress{}, nil + return Address{}, nil } return buf, nil } -func MustNewNetAddress(plain string) NetAddress { - buf, err := NewNetAddress(plain) +func MustNewAddress(plain string) Address { + buf, err := NewAddress(plain) if err != nil { panic(err) } return buf } -type NetAddress struct { +type Address struct { v gonet.Addr } -func (this NetAddress) IsZero() bool { +func (this Address) IsZero() bool { return this.v == nil } -func (this NetAddress) MarshalText() (text []byte, err error) { +func (this Address) MarshalText() (text []byte, err error) { return []byte(this.String()), nil } -func (this NetAddress) String() string { +func (this Address) String() string { pv := this.v if pv == nil { return "" @@ -50,7 +50,7 @@ func (this NetAddress) String() string { } } -func (this NetAddress) Listen() (gonet.Listener, error) { +func (this Address) Listen() (gonet.Listener, error) { pv := this.v if pv == nil { return nil, fmt.Errorf("cannot listen to empty address") @@ -64,7 +64,7 @@ func (this NetAddress) Listen() (gonet.Listener, error) { } } -func (this *NetAddress) UnmarshalText(text []byte) error { +func (this *Address) UnmarshalText(text []byte) error { if len(text) == 0 { this.v = nil return nil @@ -98,39 +98,39 @@ func (this *NetAddress) UnmarshalText(text []byte) error { return nil } -func (this *NetAddress) Set(text string) error { +func (this *Address) Set(text string) error { return this.UnmarshalText([]byte(text)) } -func (this NetAddress) Get() gonet.Addr { +func (this Address) Get() gonet.Addr { return this.v } -func (this NetAddress) IsEqualTo(other any) bool { +func (this Address) IsEqualTo(other any) bool { if other == nil { return false } switch v := other.(type) { - case NetAddress: + case Address: return this.isEqualTo(&v) - case *NetAddress: + case *Address: return this.isEqualTo(v) default: return false } } -func (this NetAddress) isEqualTo(other *NetAddress) bool { +func (this Address) isEqualTo(other *Address) bool { return reflect.DeepEqual(this.v, other.v) } -type NetAddresses []NetAddress +type NetAddresses []Address func (this *NetAddresses) Trim() error { if this == nil { return nil } - *this = slices.DeleteFunc(*this, func(e NetAddress) bool { + *this = slices.DeleteFunc(*this, func(e Address) bool { return e.IsZero() }) return nil diff --git a/pkg/net/notify-closed_windows.go b/pkg/net/notify-closed_windows.go index 4a058b6..065f41a 100644 --- a/pkg/net/notify-closed_windows.go +++ b/pkg/net/notify-closed_windows.go @@ -43,6 +43,7 @@ func notifyClosed(rc syscall.RawConn, onClosed func(), onUnexpectedEnd func(erro }() _, err = windows.WaitForSingleObject(eventHandle, windows.INFINITE) + //goland:noinspection GoTypeAssertionOnErrors if sce, ok := err.(syscall.Errno); ok && sce == 0 { // Ok } else if err != nil { @@ -65,6 +66,7 @@ func notifyClosed(rc syscall.RawConn, onClosed func(), onUnexpectedEnd func(erro func wsaCreateEvent() (windows.Handle, error) { ret, _, err := procWSACreateEvent.Call() + //goland:noinspection GoTypeAssertionOnErrors if sce, ok := err.(syscall.Errno); ok && sce == 0 { return windows.Handle(ret), nil } @@ -84,6 +86,7 @@ func wsaEventSelect(fd windows.Handle, kind uint32) (windows.Handle, error) { return 0, err } _, _, err = procWSAEventSelect.Call(uintptr(fd), uintptr(event), uintptr(kind)) + //goland:noinspection GoTypeAssertionOnErrors if sce, ok := err.(syscall.Errno); ok && sce == 0 { return event, nil } diff --git a/pkg/oci/images/builder.go b/pkg/oci/images/builder.go new file mode 100644 index 0000000..242fb35 --- /dev/null +++ b/pkg/oci/images/builder.go @@ -0,0 +1,256 @@ +package images + +import ( + "context" + "embed" + _ "embed" + "io/fs" + "iter" + "strings" + "time" + + "github.com/google/go-containerregistry/pkg/crane" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/engity-com/bifroest/pkg/common" + "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/sys" +) + +const ( + ImageMinimal = "minimal" + ImageScratch = "scratch" + + fromMinimalLinux = "alpine" + fromMinimalWindows = "mcr.microsoft.com/windows/nanoserver:ltsc2022" +) + +var ( + //go:embed contrib + contrib embed.FS +) + +func NewBuilder() (*Builder, error) { + return &Builder{}, nil +} + +type Builder struct { +} + +type BuildRequest struct { + From string + Os sys.Os + Arch sys.Arch + Time time.Time + + Env sys.EnvVars + EntryPoint []string + Cmd []string + ExposedPorts map[string]struct{} + Annotations map[string]string + + Contents iter.Seq2[LayerItem, error] + BifroestBinarySource string + BifroestBinarySourceFs fs.FS + AddDummyConfiguration bool + AddSkeletonStructure bool +} + +func (this BuildRequest) ToOciPlatform() (*v1.Platform, error) { + return v1.ParsePlatform(this.Os.String() + "/" + this.Arch.Oci()) +} + +func (this *Builder) Build(ctx context.Context, req BuildRequest) (Image, error) { + fail := func(err error) (Image, error) { + return nil, err + } + failf := func(msg string, args ...any) (Image, error) { + return fail(errors.System.Newf(msg, args...)) + } + + success := false + + var result image + + platform, err := req.ToOciPlatform() + if err != nil { + return fail(err) + } + + if strings.EqualFold(req.From, ImageMinimal) { + switch req.Os { + case sys.OsWindows: + req.From = fromMinimalWindows + case sys.OsLinux: + req.From = fromMinimalLinux + default: + return failf("unsupported operating system: %v", req.Os) + } + } + + var img v1.Image + if strings.EqualFold(req.From, ImageScratch) { + img = empty.Image + img = mutate.MediaType(img, types.OCIManifestSchema1) + img = mutate.ConfigMediaType(img, types.OCIConfigJSON) + } else { + if img, err = crane.Pull(req.From, + crane.WithPlatform(platform), + crane.WithContext(ctx), + ); err != nil { + return fail(err) + } + } + + cfg, err := img.ConfigFile() + if err != nil { + return fail(err) + } + cfg = cfg.DeepCopy() + cfg.Architecture = platform.Architecture + cfg.OS = platform.OS + cfg.OSVersion = platform.OSVersion + cfg.OSFeatures = platform.OSFeatures + cfg.Variant = platform.Variant + cfg.Config.Labels = make(map[string]string) + if v := req.ExposedPorts; v != nil { + cfg.Config.ExposedPorts = v + } else { + cfg.Config.ExposedPorts = make(map[string]struct{}) + } + if v := req.EntryPoint; len(v) > 0 { + cfg.Config.Entrypoint = v + } + if v := req.Cmd; len(v) > 0 { + cfg.Config.Cmd = v + } + cfg.Config.Env = req.Env.Strings() + img, err = mutate.ConfigFile(img, cfg) + if err != nil { + return fail(err) + } + + annotations := req.Annotations + if annotations == nil { + annotations = make(map[string]string) + } + img = mutate.Annotations(img, annotations).(v1.Image) + + contents, err := this.collectBaseContents(req) + if err != nil { + return fail(err) + } + + if v := req.Contents; v != nil { + contents = common.JoinSeq2[LayerItem, error](v, contents) + } + + if contents != nil { + bufferedLayer, err := NewTarLayer(contents, LayerOpts{ + Os: req.Os, + Id: req.Os.String() + "-" + req.Arch.String(), + Time: req.Time, + }) + if err != nil { + return fail(err) + } + defer common.IgnoreCloseErrorIfFalse(&success, bufferedLayer) + + if img, err = mutate.AppendLayers(img, bufferedLayer.Layer); err != nil { + return fail(err) + } + + result.closers = append(result.closers, bufferedLayer) + } + + result.Image = img + success = true + return &result, nil +} + +func (this *Builder) collectBaseContents(req BuildRequest) (iter.Seq2[LayerItem, error], error) { + fail := func(err error) (iter.Seq2[LayerItem, error], error) { + return nil, err + } + failf := func(msg string, args ...any) (iter.Seq2[LayerItem, error], error) { + return fail(errors.System.Newf(msg, args...)) + } + + var result []LayerItem + + if req.AddDummyConfiguration { + v := LayerItem{ + SourceFs: contrib, + Mode: 0644, + } + + switch req.Os { + case sys.OsWindows: + v.TargetFile = `C:\ProgramData\Engity\Bifroest\configuration.yaml` + v.SourceFile = "contrib/configuration-windows.yaml" + case sys.OsLinux: + v.TargetFile = `/etc/engity/bifroest/configuration.yaml` + if strings.EqualFold(req.From, ImageScratch) { + v.SourceFile = "contrib/configuration-unix.yaml" + } else { + v.SourceFile = "contrib/configuration-unix-extended.yaml" + } + default: + return failf("cannot add dummy configuration for os %v", req.Os) + } + + result = append(result, v) + } + + if req.AddSkeletonStructure { + switch req.Os { + case sys.OsLinux: + if strings.EqualFold(req.From, ImageScratch) { + result = append(result, LayerItem{ + SourceFs: contrib, + SourceFile: "contrib/passwd", + TargetFile: "/etc/passwd", + Mode: 0644, + }, LayerItem{ + SourceFs: contrib, + SourceFile: "contrib/group", + TargetFile: "/etc/group", + Mode: 0644, + }, LayerItem{ + SourceFs: contrib, + SourceFile: "contrib/shadow", + TargetFile: "/etc/shadow", + Mode: 0600, + }) + } + case sys.OsWindows: + // ignore + default: + return failf("cannot add dummy configuration for os %v", req.Os) + } + } + + if v := req.BifroestBinarySource; v != "" { + item := LayerItem{ + SourceFs: req.BifroestBinarySourceFs, + SourceFile: v, + TargetFile: sys.BifroestBinaryLocation(req.Os), + Mode: 0755, + } + + if len(item.TargetFile) == 0 { + return failf("cannot resolve Bifröest binary for os %v", req.Os) + } + + result = append(result, item) + } + + if len(result) == 0 { + return nil, nil + } + + return common.Seq2ErrOf[LayerItem](result...), nil +} diff --git a/pkg/oci/images/contrib/configuration-unix-extended.yaml b/pkg/oci/images/contrib/configuration-unix-extended.yaml new file mode 100644 index 0000000..be1c552 --- /dev/null +++ b/pkg/oci/images/contrib/configuration-unix-extended.yaml @@ -0,0 +1,14 @@ +startMessage: > + Welcome to Engity's Bifröst! + This instance runs a default configuration. + See https://bifroest.engity.org/setup/ for more details. + +flows: + - name: local + authorization: + type: local + pamService: "sshd" + + environment: + type: local + name: "{{.authorization.user.name}}" diff --git a/pkg/oci/images/contrib/configuration-unix.yaml b/pkg/oci/images/contrib/configuration-unix.yaml new file mode 100644 index 0000000..99473b6 --- /dev/null +++ b/pkg/oci/images/contrib/configuration-unix.yaml @@ -0,0 +1,38 @@ +startMessage: > + Welcome to Engity's Bifröst! + This instance runs a demo configuration that is NOT intended for + production use. Therefore, it will simply display a message + similar to this one and will close the connection immediately. + See https://bifroest.engity.org/setup/ for more details. + You can log in to this instance with the username "demo" and the + password that should be printed the first time Bifröst was + started on this machine, before this message. + +ssh: + banner: |+ + Transcend with Engity's Bifröst + =============================== + + This instance runs a demo configuration that is NOT intended for + production use. Please refer the following page to complete the + setup: https://bifroest.engity.org/setup/ + + You should be able to log in to this instance using the credentials + printed the frist time Bifröst was started on this machine. + +flows: + - name: default + authorization: + type: simple + entries: + - name: demo + passwordFile: "/etc/engity/bifroest/passwords/demo" + createPasswordFileIfAbsentOfType: plain + environment: + type: dummy + banner: |+ + Yay! You have successfully logged in to Bifröst. + + Now refer https://bifroest.engity.org/setup/ to continue. + + Bye! diff --git a/pkg/oci/images/contrib/configuration-windows.yaml b/pkg/oci/images/contrib/configuration-windows.yaml new file mode 100644 index 0000000..05f87bc --- /dev/null +++ b/pkg/oci/images/contrib/configuration-windows.yaml @@ -0,0 +1,38 @@ +startMessage: > + Welcome to Engity's Bifröst! + This instance runs a demo configuration that is NOT intended for + production use. Therefore, it will simply display a message + similar to this one and will close the connection immediately. + See https://bifroest.engity.org/setup/ for more details. + You can log in to this instance with the username "demo" and the + password that should be printed the first time Bifröst was + started on this machine, before this message. + +ssh: + banner: |+ + Transcend with Engity's Bifröst + =============================== + + This instance runs a demo configuration that is NOT intended for + production use. Please refer the following page to complete the + setup: https://bifroest.engity.org/setup/ + + You should be able to log in to this instance using the credentials + printed the frist time Bifröst was started on this machine. + +flows: + - name: default + authorization: + type: simple + entries: + - name: demo + passwordFile: "C:\\ProgramData\\Engity\\Bifroest\\passwords\\dummy" + createPasswordFileIfAbsentOfType: plain + environment: + type: dummy + banner: |+ + Yay! You have successfully logged in to Bifröst. + + Now refer https://bifroest.engity.org/setup/ to continue. + + Bye! diff --git a/pkg/oci/images/contrib/group b/pkg/oci/images/contrib/group new file mode 100644 index 0000000..1dbf901 --- /dev/null +++ b/pkg/oci/images/contrib/group @@ -0,0 +1 @@ +root:x:0: diff --git a/pkg/oci/images/contrib/passwd b/pkg/oci/images/contrib/passwd new file mode 100644 index 0000000..6d9b028 --- /dev/null +++ b/pkg/oci/images/contrib/passwd @@ -0,0 +1 @@ +root:x:0:0:root:/:/usr/bin/bifroest diff --git a/pkg/oci/images/contrib/shadow b/pkg/oci/images/contrib/shadow new file mode 100644 index 0000000..cafaee5 --- /dev/null +++ b/pkg/oci/images/contrib/shadow @@ -0,0 +1 @@ +root:*:19683:0:99999:7::: diff --git a/pkg/oci/images/image.go b/pkg/oci/images/image.go new file mode 100644 index 0000000..5ee6d25 --- /dev/null +++ b/pkg/oci/images/image.go @@ -0,0 +1,28 @@ +package images + +import ( + "io" + + v1 "github.com/google/go-containerregistry/pkg/v1" + + "github.com/engity-com/bifroest/pkg/common" +) + +type Image interface { + v1.Image + io.Closer +} + +type image struct { + v1.Image + + closers []io.Closer +} + +func (this *image) Close() (rErr error) { + for _, closer := range this.closers { + //goland:noinspection GoDeferInLoop + defer common.KeepCloseError(&rErr, closer) + } + return nil +} diff --git a/pkg/oci/images/layer-item.go b/pkg/oci/images/layer-item.go new file mode 100644 index 0000000..91a8e18 --- /dev/null +++ b/pkg/oci/images/layer-item.go @@ -0,0 +1,13 @@ +package images + +import ( + "io/fs" + gos "os" +) + +type LayerItem struct { + SourceFs fs.FS + SourceFile string + TargetFile string + Mode gos.FileMode +} diff --git a/pkg/oci/images/layer.go b/pkg/oci/images/layer.go new file mode 100644 index 0000000..915493b --- /dev/null +++ b/pkg/oci/images/layer.go @@ -0,0 +1,236 @@ +package images + +import ( + "archive/tar" + "fmt" + "io" + "io/fs" + "iter" + gos "os" + "path" + "path/filepath" + "slices" + "strings" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/uuid" + + "github.com/engity-com/bifroest/pkg/common" + "github.com/engity-com/bifroest/pkg/errors" + "github.com/engity-com/bifroest/pkg/sys" +) + +type LayerOpts struct { + Os sys.Os + Id string + Time time.Time +} + +func NewTarLayer(from iter.Seq2[LayerItem, error], opts LayerOpts) (*BufferedLayer, error) { + fail := func(err error) (*BufferedLayer, error) { + return nil, errors.System.Newf("cannot create tar layer: %w", err) + } + failf := func(msg string, args ...any) (*BufferedLayer, error) { + return fail(errors.System.Newf(msg, args...)) + } + + dir := filepath.Join("var", "layers") + _ = gos.MkdirAll(dir, 0755) + + if opts.Os.IsZero() { + return failf("no os provided") + } + if opts.Id == "" { + u, err := uuid.NewUUID() + if err != nil { + return fail(err) + } + opts.Id = u.String() + } + if opts.Time.IsZero() { + opts.Time = time.Now() + } + + fAlreadyClosed := false + success := false + f, err := gos.CreateTemp(dir, opts.Id+"-*.tar") + if err != nil { + return fail(err) + } + defer common.IgnoreErrorIfFalse(&success, func() error { return gos.Remove(f.Name()) }) + defer common.IgnoreCloseErrorIfFalse(&fAlreadyClosed, f) + + if err := createImageArtifactLayerTar(&opts, from, f); err != nil { + return fail(err) + } + common.IgnoreCloseError(f) + fAlreadyClosed = true + + result := &BufferedLayer{ + bufferFilename: f.Name(), + } + + if result.Layer, err = tarball.LayerFromOpener(result.open); err != nil { + return fail(err) + } + + success = true + return result, nil +} + +func createImageArtifactLayerTar(opts *LayerOpts, items iter.Seq2[LayerItem, error], target io.Writer) error { + fail := func(err error) error { + return err + } + failf := func(msg string, args ...any) error { + return fail(fmt.Errorf(msg, args...)) + } + + tw := tar.NewWriter(target) + + var format tar.Format + var paxRecords map[string]string + + writeHeader := func( + dir bool, + name string, + size int64, + mode int64, + ) error { + header := tar.Header{ + Name: name, + Size: size, + Mode: mode, + Format: format, + PAXRecords: paxRecords, + ModTime: opts.Time, + } + if dir { + header.Typeflag = tar.TypeDir + } else { + header.Typeflag = tar.TypeReg + } + return tw.WriteHeader(&header) + } + + adjustTargetFilename := func(v string) string { + // Paths needs to be always relative + if len(v) > 1 && (v[0] == '/' || v[0] == '\\') { + v = v[1:] + } + return v + } + var dirMode int64 = 0755 + alreadyCreatedDirectories := map[string]struct{}{} + + if opts.Os == sys.OsWindows { + dirMode = 0555 + format = tar.FormatPAX + paxRecords = map[string]string{ + "MSWINDOWS.rawsd": windowsUserOwnerAndGroupSID, + } + + if err := writeHeader(true, "Files", 0, dirMode); err != nil { + return fail(err) + } + alreadyCreatedDirectories["Files"] = struct{}{} + if err := writeHeader(true, "Hives", 0, dirMode); err != nil { + return fail(err) + } + alreadyCreatedDirectories["Hives"] = struct{}{} + + adjustTargetFilename = func(v string) string { + // At Windows, we need to always use /, because of the TAR format. + // ...and they need to start always with "Files/" instead of "C:\" or similar... + v = strings.ReplaceAll(v, "\\", "/") + if len(v) > 3 && (v[0] == 'C' || v[0] == 'c') && v[1] == ':' && v[2] == '/' { + v = "Files/" + v[3:] + } + return v + } + } + + addItem := func(item LayerItem) (rErr error) { + var f fs.File + var err error + if v := item.SourceFs; v != nil { + f, err = v.Open(item.SourceFile) + } else { + f, err = gos.Open(item.SourceFile) + } + if err != nil { + return fail(err) + } + defer common.KeepCloseError(&rErr, f) + fi, err := f.Stat() + if err != nil { + return fail(err) + } + + targetFile := adjustTargetFilename(item.TargetFile) + + var directoriesToCreate []string + currentPath := path.Dir(targetFile) + for currentPath != "." && currentPath != "" { + if _, ok := alreadyCreatedDirectories[currentPath]; !ok { + directoriesToCreate = append(directoriesToCreate, currentPath) + alreadyCreatedDirectories[currentPath] = struct{}{} + } + currentPath = path.Dir(currentPath) + } + slices.Reverse(directoriesToCreate) + for _, dir := range directoriesToCreate { + if err := writeHeader(true, dir, 0, dirMode); err != nil { + return fail(err) + } + } + + if err := writeHeader(false, targetFile, fi.Size(), int64(item.Mode)); err != nil { + return fail(err) + } + _, err = io.Copy(tw, f) + + return err + } + + for item, err := range items { + if err != nil { + return fail(err) + } + + if err := addItem(item); err != nil { + return failf("cannot add item %q -> %q: %w", item.SourceFile, item.TargetFile, err) + } + } + + if err := tw.Flush(); err != nil { + return fail(err) + } + + return nil +} + +type BufferedLayer struct { + bufferFilename string + Layer v1.Layer +} + +func (this *BufferedLayer) open() (io.ReadCloser, error) { + return gos.Open(this.bufferFilename) +} + +func (this *BufferedLayer) Close() error { + err := gos.Remove(this.bufferFilename) + if sys.IsNotExist(err) { + return nil + } + return err +} + +// userOwnerAndGroupSID is a magic value needed to make the binary executable +// in a Windows container. +// +// owner: BUILTIN/Users group: BUILTIN/Users ($sddlValue="O:BUG:BU") +const windowsUserOwnerAndGroupSID = "AQAAgBQAAAAkAAAAAAAAAAAAAAABAgAAAAAABSAAAAAhAgAAAQIAAAAAAAUgAAAAIQIAAA==" diff --git a/pkg/service/context.go b/pkg/service/context.go index c677ecf..f05ce75 100644 --- a/pkg/service/context.go +++ b/pkg/service/context.go @@ -158,6 +158,8 @@ func (this *environmentContext) GetField(name string) (any, bool, error) { return this.connection.Remote(), true, nil case "authorization": return this.authorization, true, nil + case "session": + return this.authorization.FindSession(), true, nil default: return nil, false, fmt.Errorf("unknown field %q", name) } diff --git a/pkg/service/service-direct-tcp-ip.go b/pkg/service/service-direct-tcp-ip.go index 0c6fded..402dcc5 100644 --- a/pkg/service/service-direct-tcp-ip.go +++ b/pkg/service/service-direct-tcp-ip.go @@ -163,7 +163,8 @@ func (this *service) isAcceptableNewConnectionError(err error) bool { var sce syscall.Errno if errors.As(err, &sce) { - switch sce { + switch //goland:noinspection GoDirectComparisonOfErrors + sce { case syscall.ECONNREFUSED, syscall.ETIMEDOUT, syscall.EHOSTDOWN, syscall.ENETUNREACH: return true default: diff --git a/pkg/service/service.go b/pkg/service/service.go index 27c897c..79f78e1 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -76,7 +76,7 @@ func (this *Service) Run(ctx context.Context) (rErr error) { lns := make([]struct { ln gonet.Listener - addr bnet.NetAddress + addr bnet.Address }, len(this.Configuration.Ssh.Addresses)) var lnMutex sync.Mutex closeLns := func() { @@ -85,6 +85,7 @@ func (this *Service) Run(ctx context.Context) (rErr error) { for _, ln := range lns { if ln.ln != nil { + //goland:noinspection GoDeferInLoop defer func(target *gonet.Listener) { *target = nil }(&ln.ln) diff --git a/pkg/session/fs.go b/pkg/session/fs.go index cb367e6..37b2cf1 100644 --- a/pkg/session/fs.go +++ b/pkg/session/fs.go @@ -40,6 +40,10 @@ func (this *fs) init(repository *FsRepository, flow configuration.FlowName, id I this.info.init(this) } +func (this *fs) GetField(name string, ce contextEnabled) (any, bool, error) { + return this.info.GetField(name, ce) +} + func (this *fs) Info(context.Context) (Info, error) { return &this.info, nil } diff --git a/pkg/sys/binaries.go b/pkg/sys/binaries.go new file mode 100644 index 0000000..5e9761a --- /dev/null +++ b/pkg/sys/binaries.go @@ -0,0 +1,17 @@ +package sys + +const ( + BifroestBinaryLocationUnix = `/usr/bin/bifroest` + BifroestBinaryLocationWindows = `C:\Program Files\Engity\Bifroest\bifroest.exe` +) + +func BifroestBinaryLocation(os Os) string { + switch os { + case OsWindows: + return BifroestBinaryLocationWindows + case OsLinux: + return BifroestBinaryLocationUnix + default: + return "" + } +} diff --git a/pkg/sys/env.go b/pkg/sys/env.go index bd1f1e6..6b82f1f 100644 --- a/pkg/sys/env.go +++ b/pkg/sys/env.go @@ -58,13 +58,13 @@ func (this *EnvVars) AddAllOf(in EnvVars) { } } -func (this *EnvVars) Strings() []string { - if *this == nil { +func (this EnvVars) Strings() []string { + if this == nil { return nil } - result := make([]string, len(*this)) + result := make([]string, len(this)) var i int - for k, v := range *this { + for k, v := range this { result[i] = k + "=" + v i++ } diff --git a/pkg/sys/errors.go b/pkg/sys/errors.go index 993a0da..284497b 100644 --- a/pkg/sys/errors.go +++ b/pkg/sys/errors.go @@ -8,6 +8,9 @@ import ( ) func IsNotExist(err error) bool { + if os.IsNotExist(err) { + return true + } var pe *os.PathError return errors.As(err, &pe) && os.IsNotExist(pe) } diff --git a/pkg/sys/errors_windows.go b/pkg/sys/errors_windows.go index 2c53417..0393290 100644 --- a/pkg/sys/errors_windows.go +++ b/pkg/sys/errors_windows.go @@ -17,7 +17,8 @@ func isClosedError(err error) bool { } var sce = &os.SyscallError{} if errors.As(err, &sce) && sce.Err != nil { - switch sce.Err { + switch //goland:noinspection GoDirectComparisonOfErrors + sce.Err { case windows.WSAECONNRESET, windows.WSAECONNABORTED: return true } diff --git a/pkg/sys/os.go b/pkg/sys/os.go index bd0cdeb..68faccd 100644 --- a/pkg/sys/os.go +++ b/pkg/sys/os.go @@ -35,6 +35,16 @@ func (this *Os) UnmarshalText(in []byte) error { return nil } +func (this Os) Validate() error { + _, err := this.MarshalText() + return err +} + +func (this Os) AppendExtToFilename(filename string) string { + v := osToExt[this] + return filename + v +} + func (this *Os) Set(plain string) error { return this.UnmarshalText([]byte(plain)) } @@ -63,6 +73,9 @@ var ( OsLinux: "linux", OsWindows: "windows", } + osToExt = map[Os]string{ + OsWindows: ".exe", + } stringToOs = func(in map[Os]string) map[string]Os { result := make(map[string]Os, len(in)) for k, v := range in { diff --git a/pkg/template/duration.go b/pkg/template/duration.go new file mode 100644 index 0000000..9e4201d --- /dev/null +++ b/pkg/template/duration.go @@ -0,0 +1,132 @@ +package template + +import ( + "fmt" + "strings" + "time" + + "github.com/engity-com/bifroest/internal/text/template" +) + +func NewDuration(plain string) (Duration, error) { + var buf Duration + if err := buf.Set(plain); err != nil { + return Duration{}, err + } + return buf, nil +} + +func DurationOf(v time.Duration) Duration { + return Duration{ + isHardCoded: true, + hardCodedValue: v, + plain: v.String(), + } +} + +func MustNewDuration(plain string) Duration { + buf, err := NewDuration(plain) + if err != nil { + panic(err) + } + return buf +} + +type Duration struct { + isHardCoded bool + hardCodedValue time.Duration + plain string + tmpl *template.Template +} + +func (this Duration) Render(data any) (time.Duration, error) { + if this.isHardCoded { + return this.hardCodedValue, nil + } + + if tmpl := this.tmpl; tmpl != nil { + var buf strings.Builder + if err := tmpl.Execute(&buf, data); err != nil { + return 0, err + } + plain := strings.TrimSpace(buf.String()) + if len(plain) == 0 { + return 0, nil + } + + result, err := time.ParseDuration(plain) + if err != nil { + return 0, fmt.Errorf("templated duration results in a value that cannot be parsed as duration: %q", buf.String()) + } + return result, nil + } + return 0, nil +} + +func (this Duration) IsHardCoded() bool { + return this.isHardCoded +} + +func (this Duration) String() string { + return this.plain +} + +func (this Duration) IsZero() bool { + return len(this.plain) == 0 +} + +func (this Duration) MarshalText() (text []byte, err error) { + return []byte(this.String()), nil +} + +func (this *Duration) UnmarshalText(text []byte) error { + if len(text) == 0 { + *this = Duration{ + isHardCoded: true, + hardCodedValue: 0, + plain: "", + } + return nil + } + + if v, err := time.ParseDuration(string(text)); err == nil { + *this = DurationOf(v) + return nil + } + + tmpl, err := NewTemplate("duration", string(text)) + if err != nil { + return fmt.Errorf("illegal duration template: %w", err) + } + *this = Duration{ + plain: string(text), + tmpl: tmpl, + } + return nil +} + +func (this *Duration) Set(text string) error { + return this.UnmarshalText([]byte(text)) +} + +func (this Duration) Validate() error { + return nil +} + +func (this Duration) IsEqualTo(other any) bool { + if other == nil { + return false + } + switch v := other.(type) { + case Duration: + return this.isEqualTo(&v) + case *Duration: + return this.isEqualTo(v) + default: + return false + } +} + +func (this Duration) isEqualTo(other *Duration) bool { + return this.plain == other.plain +} diff --git a/pkg/template/duration_test.go b/pkg/template/duration_test.go new file mode 100644 index 0000000..70bd407 --- /dev/null +++ b/pkg/template/duration_test.go @@ -0,0 +1,97 @@ +package template + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDuration(t *testing.T) { + cases := []struct { + plain string + data any + expected time.Duration + expectedNewErr error + expectedRenderErr error + isHardCoded bool + hardCodedValue time.Duration + }{{ + data: map[string]any{"foo": "666s"}, + plain: "{{.foo}}", + expected: time.Second * 666, + }, { + data: map[string]any{"foo": "-11"}, + plain: "{{.foo}}", + expectedRenderErr: errors.New(`templated duration results in a value that cannot be parsed as duration: "-11"`), + }, { + data: map[string]any{"foobar": "666"}, + plain: "{{.foo}}", + expectedRenderErr: errors.New(`template: duration:1:2: executing "duration" at <.foo>: map has no entry for key "foo"`), + }, { + data: map[string]any{"foo": map[string]any{"bar": "abc"}}, + plain: "{{.foo.bars}}", + expectedRenderErr: errors.New(`template: duration:1:6: executing "duration" at <.foo.bars>: map has no entry for key "bars"`), + }, { + data: map[string]any{"foo": map[string]any{"bar": "666"}}, + plain: "{{get .foo `bars`}}", + expected: 0, + }, { + data: map[string]any{"foo": map[string]any{"bar": nil}}, + plain: "{{.foo.bar}}", + expected: 0, + }, { + plain: "666m", + expected: 666 * time.Minute, + isHardCoded: true, + hardCodedValue: 666 * time.Minute, + }, { + plain: "", + expected: 0, + isHardCoded: true, + hardCodedValue: 0, + }, { + plain: "-666m", + expected: -666 * time.Minute, + isHardCoded: true, + hardCodedValue: -666 * time.Minute, + }} + for i, c := range cases { + t.Run(fmt.Sprintf("case-%d", i), func(t *testing.T) { + instance, actualErr := NewDuration(c.plain) + if expected := c.expectedNewErr; expected != nil { + if actualErr != nil { + if actualErr.Error() != expected.Error() { + t.Fatalf("expected error: %v; but got: %v", expected, actualErr) + } + } else { + t.Fatalf("expected error %v; but got nothing", expected) + } + } else if actualErr != nil { + t.Fatalf("expected no error; but got: %v", actualErr) + } + + assert.Equal(t, c.isHardCoded, instance.isHardCoded) + assert.Equal(t, c.hardCodedValue, instance.hardCodedValue) + + actual, actualErr := instance.Render(c.data) + if expected := c.expectedRenderErr; expected != nil { + if actualErr != nil { + if actualErr.Error() != expected.Error() { + t.Fatalf("expected error: %v; but got: %v", expected, actualErr) + } + } else { + t.Fatalf("expected error %v; but got nothing", expected) + } + } else if actualErr != nil { + t.Fatalf("expected no error; but got: %v", actualErr) + } + + if actual != c.expected { + t.Fatalf("expected %v; but got: %v", c.expected, actual) + } + }) + } +} diff --git a/pkg/template/int64.go b/pkg/template/int64.go index 3a30758..14597e7 100644 --- a/pkg/template/int64.go +++ b/pkg/template/int64.go @@ -90,7 +90,7 @@ func (this *Int64) UnmarshalText(text []byte) error { } if v, err := strconv.ParseInt(string(text), 10, 64); err == nil { - *this = Int64Of(int64(v)) + *this = Int64Of(v) return nil } diff --git a/pkg/user/common_test.go b/pkg/user/common_test.go index b2329af..1f0d5fc 100644 --- a/pkg/user/common_test.go +++ b/pkg/user/common_test.go @@ -32,10 +32,6 @@ func bs(ins ...string) [][]byte { return result } -func newTestFile(t testing.TB, name string) *testFile { - return newTestDir(t).file(name) -} - func newNamedTestFile(t testing.TB, fn string) *testFile { f, err := os.OpenFile(fn, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) require.NoError(t, err)